A lightweight, 100% SwiftUI emoji picker for iOS 15+, inspired by the macOS emoji panel. No UIKit view controllers, no external dependencies — present it as a sheet, a native popover, or embed it inline.
- 🧩 Pure SwiftUI — embeddable view, a sheet modifier, and a popover modifier. No
UIViewControllerRepresentable. - 🎯 macOS-style popover — SwiftUI's native popover: an arrow anchored to your button on iPad (all versions) and iPhone (iOS 16.4+), with a graceful sheet fallback on older iPhones.
- 🌗 Translucent & theme-aware —
.ultraThinMaterialbackdrop by default, adapting to light/dark. - 🗂️ 8 categories with a macOS-style scrolling grid, sticky headers, and a synced tab bar.
- 🔎 Search by name and keyword — in the user's language (CLDR annotations, English & French bundled) and in English, case- and diacritic-insensitive (
coeurfinds ❤️,pinatafinds 🪅). - 🕘 Recently used section, persisted across launches.
- 🎨 Skin tones — long-press any supported emoji to pick a Fitzpatrick tone; the choice is remembered per emoji.
- 📐 OS-aware dataset — emoji newer than the running OS are filtered out automatically, so you never render tofu (□).
- 🌍 Localized (English & French out of the box) — UI strings, search keywords, and VoiceOver emoji names (from CLDR).
- 📦 Built from the official Unicode 17.0
emoji-test.txt(1,914 emoji, 313 with skin tones).
- iOS 15.0+
- Xcode 16+ (the package builds in Swift 6 language mode with strict concurrency)
In Xcode: File ▸ Add Package Dependencies… and enter the repository URL, or
add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/julienpouget/EmojiPicker.git", from: "1.0.0"),
]import SwiftUI
import EmojiPicker
struct ContentView: View {
@State private var showPicker = false
@State private var emoji = "🤙"
var body: some View {
Button(emoji) { showPicker = true }
.emojiPicker(isPresented: $showPicker, selection: $emoji)
}
}Or use the closure form to react to each selection:
.emojiPicker(isPresented: $showPicker) { picked in
print("Picked \(picked)")
}For the macOS look — a panel with an arrow pointing back at the button — use the popover modifier. It uses SwiftUI's native popover: a true arrow popover on iPad (all versions) and iPhone on iOS 16.4+, with an automatic sheet fallback on iPhone running iOS 15–16.3.
Button("Add reaction") { showPopover = true }
.emojiPickerPopover(isPresented: $showPopover, selection: $emoji)Control the arrow edge and size if needed:
.emojiPickerPopover(
isPresented: $showPopover,
arrowEdge: .top, // edge of the button the arrow points from
contentSize: CGSize(width: 340, height: 400), // popover size; the sheet fallback fills instead
selection: $emoji
)A closure form (onSelection:) is available too, mirroring the sheet modifier.
EmojiPickerView(
configuration: EmojiPickerConfiguration(dismissesOnSelection: false)
) { picked in
self.emoji = picked
}
.frame(height: 320)Every aspect is tunable via EmojiPickerConfiguration:
EmojiPickerConfiguration(
emojiFontSize: 30,
minimumCellSize: 44,
cellSpacing: 4,
accentColor: .pink,
showsSearchBar: true,
showsRecents: true,
allowsSkinToneSelection: true,
dismissesOnSelection: true,
background: .ultraThinMaterial, // .thinMaterial / .regularMaterial / .thickMaterial / .solid
versionPolicy: .device // .minimumOS(major:minor:) / .maxEmojiVersion(_)
)The default backdrop is a translucent .ultraThinMaterial that adapts to light
and dark mode (the macOS panel look). The sheet, popover, and inline pickers all
use the same configured background, so they look consistent across presentations.
Filtering is per-device at runtime — the picker never offers an emoji the
current OS can't render (no tofu), whatever your deployment target. versionPolicy
chooses which set, always capped by what the device can render:
.device(default) — show everything the current device can render. Maximises the set per device; the offered set therefore differs across OS versions..minimumOS(major:minor:)— cap at the highest Emoji version guaranteed on every OS down to your deployment target, so the whole fleet sees the same set (e.g..minimumOS(major: 15, minor: 0)→ Emoji 13.1 everywhere)..maxEmojiVersion(_)— cap at an explicit Emoji version (e.g. to match a backend).
Recents and per-emoji skin-tone preferences are stored via
EmojiPreferenceStore. The default uses UserDefaults.standard; pass your own
to isolate state (e.g. a shared app-group suite):
let store = EmojiPreferenceStore(
defaults: UserDefaults(suiteName: "group.com.example")!,
keyPrefix: "ReactionPicker",
maxRecent: 24
)
view.emojiPicker(isPresented: $show, selection: $emoji, store: store)Model/ Emoji, EmojiCategory, EmojiSkinTone — pure value types
Data/ JSON decoding, OS availability filtering, cached EmojiProvider, CLDR annotations (localized names + keywords)
Store/ EmojiPreferenceStore — recents + tone prefs (UserDefaults)
ViewModel/ EmojiPickerViewModel — sections, search, selection (@MainActor)
View/ EmojiPickerView and its subviews (grid cell, search, tab bar, tone bar) + sheet & popover modifiers
Support/ Haptics, background-style helper, search folding
Resources/ emojis.json + annotations-<locale>.json + Localizable.xcstrings (String Catalog)
The skin-tone bar is positioned with SwiftUI Anchor/overlayPreferenceValue,
so it works identically on iOS 15+ without relying on popover adaptation APIs.
The dataset is public, so you can read it without showing the UI:
EmojiProvider.all // every available Emoji, flat
EmojiProvider.categorized // [(EmojiCategory, [Emoji])], in display order
EmojiProvider.byValue["👋"] // fast lookup by default glyph
EmojiProvider.unicodeVersion // "17.0"
EmojiAvailability.maxAvailableVersion // highest Emoji version this OS renders
let wave = EmojiProvider.byValue["👋"]!
wave.string(for: .dark) // "👋🏿"The bundled emojis.json is generated by Scripts/generate-emoji-data.py,
which downloads the Unicode emoji data directly. With no arguments it fetches
the latest released version and writes the bundled resource:
python3 Scripts/generate-emoji-data.py # latest released version
python3 Scripts/generate-emoji-data.py --version 16.0 # a specific version
python3 Scripts/generate-emoji-data.py --input emoji-test.txt # a local fileIt reads the version from the file's own # Version: header (so the JSON's
unicodeVersion always matches the source) and skips the in-progress draft.
The localized names and search keywords come from the CLDR annotations (the
same cldr-json source for every locale) and are regenerated per locale into
annotations-<locale>.json:
python3 Scripts/generate-emoji-data.py --annotations en fr # latest (main)
python3 Scripts/generate-emoji-data.py --annotations en fr --cldr-ref 48.2.0 # reproduce a releaseThe CLDR version used is recorded as cldrVersion in each file; pass it back
via --cldr-ref to regenerate byte-identical output. Regenerate whenever the
dataset is bumped, and add the new locale to Package.swift resources when
introducing one.
When you bump to a newer Emoji version, also add the matching OS→Emoji-version
row to EmojiAvailability.releaseMap — otherwise the new emoji are filtered out
on every OS (and just bloat the bundle) since the availability ceiling never
rises to include them.
An example app lives in Example/. Open
Example/EmojiPickerDemo.xcodeproj and run it on a simulator.
Inspired by MCEmojiPicker and this article. Emoji data © Unicode, Inc. (UTS #51).
Copyright © 2026 Julien Pouget. Released under the MIT License — see LICENSE.


