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.

Published 2026-02-16 · by inndevs GmbH

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 global key monitoring plus a guarded state machine. Our first prototype opened too eagerly on :, which looked good in demos and felt noisy in real work (timestamps, URLs, markdown). We now gate activation on text context and use a short debounce window before showing UI.

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 API integration and insertion strategy

Text context and insertion points are coordinated through macOS accessibility APIs. We briefly considered clipboard-based insertion as a fallback path, but rejected it early because overriding clipboard state is surprising and hard to trust. The current path edits the active text target directly with fewer side effects.

Deterministic text replacement

On selection, the typed query token is replaced with the chosen emoji or snippet. The exact token range is captured, replaced, and validated before closing UI. No inferred selection, no clipboard mutation.

Context Detection Rules

These are the practical rules we use to keep activation predictable:

  • Only track triggers when the focused element is editable via accessibility APIs.
  • Require a valid token after the trigger; empty or whitespace-only queries do not open the picker.
  • Suppress activation for URL-like or time-like tokens, such as https://, 10:30, or key:value.
  • Use a short debounce window so fast typing can settle before state transitions to open UI.
  • Support direct-open trigger customization (for example :: or ;;) for users who prefer explicit invocation.

Simplified state logic (trimmed for readability):

enum TriggerState {
    case idle
    case tracking(tokenRange: NSRange)
    case open(query: String, tokenRange: NSRange)
}

func transition(_ event: KeyEvent, ctx: Context, state: TriggerState) -> TriggerState {
    guard ctx.hasEditableAXTarget else { return .idle }
    guard !ctx.looksLikeURL && !ctx.looksLikeTime else { return .idle }

    switch state {
    case .idle:
        return event.isTrigger ? .tracking(tokenRange: ctx.currentTokenRange) : .idle
    case .tracking(let range):
        if ctx.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return .tracking(tokenRange: range) }
        if event.didPauseTyping { return .open(query: ctx.query, tokenRange: range) }
        return event.didBlurFocus ? .idle : .tracking(tokenRange: range)
    case .open:
        return event.didCommitSelection ? .idle : state
    }
}
idle tracking open trigger debounced query commit, blur, or cancel
State flow overview: idle -> tracking -> open -> idle.

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 : 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 cross-app text context and insertion.
  • Secure text fields (for example password inputs) are intentionally not targeted.
  • Some custom, non-standard text controls expose limited accessibility metadata 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.

🏢

Imprint

inndevs GmbH
Dammstrasse 19
6300 Zug
Switzerland

VAT ID: CHE-239.879.310