Technical Deep Dive
Building a Native Emoji Picker for macOS in Swift
No Electron, No Bloat
This is the build log behind Emoji Picker for macOS. We built it because the default picker kept interrupting our own writing flow while chatting. The built-in option required switching to the mouse and is slow to use. The goal was simple: type, pick, and keep moving.
In This Deep Dive
The Real Workflow Problem
macOS already ships an emoji picker. For occasional use, it is fine. In high-volume text workflows, it was slowing us down. Open panel, move hand to mouse, pick, close panel, recover context. Repeat this dozens of times in one day and the interruption cost becomes obvious.
Early internal testing exposed how annoying false activations were. We tightened the rules until activation felt boring and predictable: type ;, stay in the current app, pick via keyboard, continue writing.
- Support and ticket workflows need fast insertions while triaging many short messages.
- In code review comments and commit messages, we usually know intent already, so category browsing is slower than typing a query.
- Switching panel state 40+ times per day breaks typing rhythm fast.
Architecture Decisions
Global hotkey handling
Input is driven by a Core Graphics event tap plus a guarded state machine. A bounded, in-memory buffer keeps the most recent printable characters for the frontmost app so the configured trigger can be recognized. It resets on app changes, shortcuts, non-printable input, and cancellation. A configurable popup delay keeps the UI calm while a query is still being typed.
NSPanel vs NSPopover decision
We started with NSPopover and switched to NSPanel. NSPopover lost focus too easily in Slack and Chrome during quick app switches. NSPanel gave us explicit lifecycle control and predictable keyboard routing across app boundaries.
Accessibility permission and insertion strategy
Accessibility permission allows Emoji Picker to monitor those global events and post keyboard events back to the active app. We rejected clipboard-based insertion because overriding clipboard state is surprising and hard to trust. The app does not inspect a text field through AXUIElement; it records the intended frontmost app when a session begins and verifies that the same app is still frontmost before insertion.
Counted text replacement
An inline session counts the trigger and query characters that reached the target app. On selection, Emoji Picker posts that many Backspace key events and then posts the chosen Unicode text as a synthetic keyboard event. It never mutates the clipboard. Custom controls can still interpret synthetic keyboard events differently, which is why target-app checks and cancellation behavior matter.
Context Detection Rules
These are the practical rules we use to keep activation predictable:
- Ignore Emoji Picker's own windows and apps on the user's exclusion list.
- Tie recent input and each active session to the frontmost app; switching apps cancels the session.
- Match the configured trigger against a bounded suffix of recent printable input.
- Accept letters, numbers, underscore, plus, and hyphen in an inline query; other input cancels it.
- Apply the user's opening-sequence blocklist, minimum query length, and popup delay before showing UI.
- Support direct-open trigger customization (for example ;; or //) for users who prefer explicit invocation.
Simplified state logic (trimmed for readability):
func handle(_ event: KeyEvent, frontmostApp: App) {
guard frontmostApp != emojiPicker, !isExcluded(frontmostApp) else {
resetRecentInput()
return
}
if session == nil, recentInput.appending(event).hasSuffix(inlineTrigger) {
session = .inline(target: frontmostApp, deleteCount: inlineTrigger.count)
} else if session?.accepts(event) == true {
session?.appendToQuery(event)
showAfterConfiguredDelay()
}
}
func commit(_ text: String) {
guard session?.target == frontmostApp else { return cancel() }
postBackspaceEvents(count: session?.deleteCount ?? 0)
postUnicodeKeyboardEvent(text)
}
A Bug That Forced A Better State Model
One issue in v1.3 changelog notes exposed a blind spot in our updater status model: Checking... could remain visible after a no-update result, and Available could stay unknown. The bug was small, but the trust impact was large.
Fixing it forced us to tighten the state machine instead of patching labels in place. That changed how we treat UI status in the rest of the app: explicit transitions, fewer inferred states, and stronger sync between async checks and visible status.
- Symptom: stale updater status text after successful no-update checks.
- Root cause: incomplete state transitions under async update callbacks.
- Outcome: explicit state transitions now drive status rendering.
Performance Notes
The metric that matters: time from trigger to inserted emoji. That is why we stayed with a native Swift + AppKit runtime instead of a browser-engine stack.
Measured on Mac15,11 (Apple M3 Max), macOS 26.2 build 25C56, using v1.3.1 from the shipped ZIP. Launch time below means process-visible startup from open -n until the app process appears.
First launch in session
217.68 ms (single run, process-visible startup).
Warm relaunch
110.02 ms median across 7 relaunch runs.
Idle memory
110.4 MB RSS via ps; Activity Monitor on the same run showed 99.4 MB real and 29.6 MB private memory.
Package size
4.49 MB ZIP, 12.96 MB app bundle on disk.
Memory metrics are presented as-is because they use different accounting models. Most RSS here is shared AppKit framework memory; private memory stays below 30 MB in our tests.
Swift + AppKit vs Electron for macOS Utilities
For always-on utility tools, runtime shape matters more than UI portability. We chose native Swift + AppKit for these practical reasons:
- Small distribution artifact and direct startup path without a JavaScript runtime warm-up.
- Direct accessibility and text insertion integration using macOS-native APIs.
- No embedded browser-engine process tree for a lightweight keyboard utility.
For measured startup timings across built-in, Raycast, and Emoji Picker, see the comparison page.
Design Tradeoffs
Why menu bar
Menu bar tools are easy to keep alive and fast to summon. For this product, that matched reality: users call it repeatedly during chats, tickets, commit messages, and docs.
Why intentionally minimal UI
We removed almost every decorative element that slowed choice. Clear categories, obvious keyboard navigation, and minimal chrome worked better than richer visual treatments in our own daily usage.
Where the built-in picker still wins
If you insert emoji rarely, the macOS picker is already good enough and requires zero setup. Our app is for users who do this all day and feel the interruption cost repeatedly.
Why local-first
Local-first was non-negotiable. No account requirement, no cloud dependency, and no routing typing behavior through external services.
What Surprised Us
- Small status bugs can damage trust more than obvious visual bugs.
- Activation false-positives are noticed immediately by power users.
- No one asked for more features until activation stopped misfiring.
The main lesson: keyboard tools are judged by consistency, not novelty. If the app behaves the same way every time, it earns a permanent place in workflow.
Known Constraints
- Accessibility permission is required for global trigger monitoring and cross-app insertion.
- The app does not inspect focused-field metadata, including whether a field is secure. Apps where trigger monitoring is not wanted can be added to the exclusion list.
- Some custom text controls interpret synthetic Backspace or Unicode keyboard events differently and can reduce insertion reliability.
- The trigger-detection path has been tested most heavily on Latin keyboard layouts.
Support Repository
We use GitHub as a support and feedback channel: github.com/inndevs/emoji-picker-macos-public
- Bug reports with reproducible steps are prioritized.
- Feature requests are discussed publicly before implementation.
- This repository is for support workflows, not as an open-source code release.
Try It And Tell Us Where It Breaks
If you can break it, file an issue. Repro steps beat feature ideas.