Building macOS Menu Bar Apps with Swift in 2026

🍎 Everything here
is battle-tested on
OpenWhisper β€” a
voice dictation app
I built and ship
as open source.

A menu bar app is, I think, the perfect side-project format. The surface area is tiny: one icon, one popover, zero windows to manage. It runs all day in the corner of the screen, doing exactly one thing. Users barely notice it’s there until they need it β€” and then it’s right there.

I built OpenWhisper β€” a push-to-talk voice dictation app that uses on-device Whisper β€” as a menu bar app, and the journey taught me more about macOS internals than any documentation ever could. Dark corners of AppKit, the Accessibility permission dance, CGEventTap callbacks, panels that refuse to steal focus. All of it.

This post is a thorough brain-dump of everything I know. Whether you’re building a clipboard manager, a quick-timer, a focus mode toggle, or something I haven’t imagined, you’ll hit the same walls. Let me show you how to break through them.

Popular menu bar apps for reference: Bartender, Dato, Rectangle, CleanMyMac, 1Password mini, and β€” yes β€” OpenWhisper. All of them follow the same architectural skeleton underneath their polished UIs.


Architecture overview

Before writing a single line of code, it helps to see the mental model. A menu bar app is a stack of three or four OS-level objects that sit on top of each other. Click any layer below to explore it.

πŸ“Š NSStatusBar System-provided status bar container
πŸ”² NSStatusItem + NSStatusButton Your icon slot in the menu bar
πŸ“‹ NSMenu / NSPopover Dropdown menu or floating popover UI
πŸͺŸ NSPanel / SwiftUI View Optional custom floating window

The minimal setup β€” five key areas

Let’s build from zero. I’ll walk through each decision point with real, annotated code.

1 β€” App protocol vs AppDelegate

SwiftUI 3+ lets you skip AppDelegate entirely for simple cases. For menu bar apps I recommend a hybrid: use the App protocol as the entry point but keep a reference to your AppDelegate for status item management.

import SwiftUI

@main
struct OpenWhisperApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate

    var body: some Scene {
        // An empty WindowGroup is required by the compiler
        // but LSUIElement = YES ensures it never shows.
        Settings { EmptyView() }
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {
    var statusItem: NSStatusItem!
    var popover = NSPopover()

    func applicationDidFinishLaunching(_ note: Notification) {
        setupStatusItem()
        setupPopover()
        registerGlobalHotkey()
    }
}

LSUIElement = YES is
the single line that
makes your app
invisible to the
Dock and App Switcher
β€” arguably the most
important line in
any menu bar app.

2 β€” Custom status bar icon

Always use an SF Symbol rendered as a template image. Template images automatically adapt to dark and light menu bars, and invert when your menu/popover is open.

func setupStatusItem() {
    statusItem = NSStatusBar.system
        .statusItem(withLength: NSStatusItem.variableLength)

    guard let button = statusItem.button else { return }

    // SF Symbol β€” use a 16pt weight that reads well at small sizes
    let config = NSImage.SymbolConfiguration(
        pointSize: 16, weight: .medium
    )
    let img = NSImage(
        systemSymbolName: "waveform.circle",
        accessibilityDescription: "OpenWhisper"
    )!.withSymbolConfiguration(config)!

    img.isTemplate = true  // ← critical
    button.image = img
    button.action = #selector(togglePopover)
    button.target = self
    button.sendAction(on: [.leftMouseUp, .rightMouseUp])
}

3 β€” Popover management

Show on click, dismiss on click-outside. Deceptively tricky because NSPopover doesn’t tell you when it dismisses itself.

func setupPopover() {
    popover.contentSize    = NSSize(width: 380, height: 480)
    popover.behavior       = .transient  // auto-dismiss on outside click
    popover.animates       = true
    popover.contentViewController =
        NSHostingController(rootView: ContentView())
}

@objc func togglePopover() {
    guard let button = statusItem.button else { return }

    if popover.isShown {
        popover.performClose(nil)
    } else {
        popover.show(
            relativeTo: button.bounds,
            of:         button,
            preferredEdge: .minY
        )
        // Bring to front so it receives key events immediately
        popover.contentViewController?.view.window?.makeKey()
    }
}

4 β€” Window level (floating panel)

When you need the panel to float above all other apps:

func makeFloatingPanel() -> NSPanel {
    let panel = NSPanel(
        contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
        styleMask: [
            .nonactivatingPanel,  // ← magic: no focus steal
            .fullSizeContentView,
            .borderless
        ],
        backing: .buffered,
        defer: false
    )
    panel.level                  = .floating
    panel.collectionBehavior      = [.canJoinAllSpaces, .fullScreenAuxiliary]
    panel.isMovableByWindowBackground = true
    panel.backgroundColor        = .clear
    panel.isOpaque               = false
    panel.hasShadow              = true
    panel.contentViewController  =
        NSHostingController(rootView: PanelView())
    return panel
}

NSPanel with
.nonactivatingPanel
is magic β€” clicks
inside don’t steal
focus from whatever
the user was typing
in. Perfect for
dictation apps.

5 β€” Global hotkey with CGEventTap

This is where most tutorials stop and most bugs live. I’ll cover it properly in its own section below.


The Accessibility challenge

The Accessibility
permission dialog
appears once.
If the user denies,
you must send them
to System Settings
β€” you cannot re-prompt.

Building a menu bar app that interacts with other apps β€” reading what’s on screen, injecting keystrokes, monitoring global hotkeys β€” requires the Accessibility entitlement and usually the Input Monitoring entitlement too. Getting these wrong is the number one reason menu bar apps break after macOS updates.

Use the interactive checker below. Toggle which features your app needs and see which entitlements are required.

πŸ” Entitlement requirements checker

Need? Capability Entitlement / Permission Status
Toggle the checkboxes to mark what your app needs.
// Check capabilities above to see the required entitlements.plist snippet.

Global key monitor β€” CGEventTap deep dive

CGEventTap callbacks
are C function pointers.
In Swift you bridge
them via a closure
stored in a
Unmanaged context
or a global function.

This is the most technically dense part of any menu bar app, and it’s where OpenWhisper spent most of its early bug-squashing sessions. A CGEventTap lets you listen for (and optionally intercept) every key event system-wide β€” without the user having to click in your app first.

import CoreGraphics

// The callback must be a C-compatible function.
// In Swift we use a private global function as the bridge.
private func eventTapCallback(
    proxy:  CGEventTapProxy,
    type:   CGEventType,
    event:  CGEvent,
    refcon: UnsafeMutableRawPointer?
) -> Unmanaged<CGEvent>? {

    guard let refcon else { return Unmanaged.passRetained(event) }
    let delegate = Unmanaged<AppDelegate>
        .fromOpaque(refcon).takeUnretainedValue()

    if type == .keyDown {
        let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
        if keyCode == 63 { // kVK_Function (FN key)
            DispatchQueue.main.async { delegate.startRecording() }
            return nil  // consume the event
        }
    }
    if type == .keyUp {
        let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
        if keyCode == 63 {
            DispatchQueue.main.async { delegate.stopRecording() }
            return nil
        }
    }
    return Unmanaged.passRetained(event)
}

func registerGlobalHotkey() {
    let mask: CGEventMask = (
        (1 << CGEventType.keyDown.rawValue) |
        (1 << CGEventType.keyUp.rawValue)
    )
    let selfPtr = Unmanaged.passUnretained(self).toOpaque()

    guard let tap = CGEvent.tapCreate(
        tap:        .cgSessionEventTap,
        place:      .headInsertEventTap,
        options:    .defaultTap,
        eventsOfInterest: mask,
        callback:   eventTapCallback,
        userInfo:   selfPtr
    ) else {
        print("⚠️ Failed to create event tap β€” Accessibility permission?")
        return
    }

    let source = CFMachPortCreateRunLoopSource(nil, tap, 0)
    CFRunLoop.main.add(source, forMode: .common)
    CGEvent.tapEnable(tap: tap, enable: true)
}
⚠️ Always run on CFRunLoop.main. If you add the run loop source to a background queue's run loop, the tap silently stops delivering events after the system suspends that run loop. Seen this burn two days of debugging. Use CFRunLoop.main.

Re-registering after sleep is also essential. Wire it up in applicationDidFinishLaunching:

NotificationCenter.default.addObserver(
    forName: NSWorkspace.willSleepNotification,
    object: nil,
    queue: .main
) { [weak self] _ in
    self?.teardownEventTap()
}
NotificationCenter.default.addObserver(
    forName: NSWorkspace.didWakeNotification,
    object: nil,
    queue: .main
) { [weak self] _ in
    self?.registerGlobalHotkey()
}

Popover vs Panel β€” Interactive comparison

Click on each approach to see its trade-offs in detail.

NSPopover

Attached arrow, auto-dismiss, traditional macOS feel

NSPanel

Free-floating, non-activating, draggable, persistent

NSPopover β€” the default choice
  • Arrow automatically anchors to your status bar button
  • .transient behavior dismisses on outside click β€” no event monitor needed
  • Captures keyboard focus when shown β€” great for forms and search
  • Limitation: cannot be resized by the user; fixed contentSize
  • Limitation: dismissed when the user switches Spaces or opens Mission Control
  • SwiftUI hosted via NSHostingController β€” works perfectly
NSPanel β€” the power move
  • Set .nonactivatingPanel to prevent focus stealing β€” perfect for dictation
  • Set level = .floating to stay above all other app windows
  • Add .canJoinAllSpaces to persist across Spaces
  • You manage show/hide yourself β€” dismiss on Escape with NSEvent.addLocalMonitorForEvents
  • Complexity: you handle click-outside detection manually
  • Best for: live transcript viewers, always-on timers, HUD overlays

Distributing without the App Store

Apple Silicon’s
Neural Engine makes
local ML models
viable in menu bar
apps in 2026 β€”
think on-device
Whisper, LLMs,
image classifiers.
Notarize and ship
direct β€” no App
Store tax.

Skipping the App Store means no 30% cut, no review delays, and no sandboxing restrictions that would cripple a hotkey monitor. The trade-off: you handle notarization yourself. Here’s the full flow, with a live terminal animation showing the exact commands.

The four steps:

  1. Code-sign with your Developer ID Application certificate
  2. Notarize by submitting to Apple’s notarization service
  3. Wait for the JSON response (usually under 5 minutes)
  4. Staple the ticket so the app works offline
Terminal β€” notarize.sh
$
πŸ’‘ Store your App Store Connect password in Keychain. Use xcrun notarytool store-credentials to create a named profile, then reference it with --keychain-profile "YourProfile" instead of inline credentials. Never put passwords in shell scripts.

For GitHub Actions CI/CD, store your certificate as a base64-encoded secret and use the create-dmg action to produce a signed, notarized DMG automatically on every tagged release. OpenWhisper’s workflow does exactly this β€” the whole release pipeline is under 80 lines of YAML.


Common gotchas β€” flip to see the fix

I’ve hit every one of these. Flip the cards to see the solution.

01
App shows in the Dock even though it's a menu bar app
tap to see fix β†’
βœ“ Fix
Set LSUIElement to YES in your Info.plist. This marks the app as a "UI Element" β€” no Dock icon, no App Switcher entry. Without this, every time your popover becomes key, a Dock icon flashes in.
<key>LSUIElement</key>
<true/>
02
FN key events are not intercepted at all
tap to see fix β†’
βœ“ Fix
CGEventTap for keyboard events requires Accessibility permission. Check AXIsProcessTrusted() at launch and prompt the user via AXIsProcessTrustedWithOptions with the prompt flag set to true.
AXIsProcessTrustedWithOptions(
[kAXTrustedCheckOptionPrompt: true]
as CFDictionary)
03
Menu bar icon disappears after logout / reboot
tap to see fix β†’
βœ“ Fix
Use SMAppService (macOS 13+) to register as a Login Item. The old LSSharedFileList API is deprecated. SMAppService.mainApp.register() persists across reboots without prompting.
try SMAppService.mainApp.register()
04
SwiftUI popover flickers or is blank on first open
tap to see fix β†’
βœ“ Fix
SwiftUI lazily initialises views. Warm up the hosting controller at launch by triggering a layout pass on the view before the popover is ever shown. Create the NSHostingController in applicationDidFinishLaunching, not lazily.
// In applicationDidFinishLaunching:
_ = popover.contentViewController?.view
05
CGEventTap stops delivering events after system sleep
tap to see fix β†’
βœ“ Fix
The event tap is silently disabled on wake. Listen for NSWorkspace.didWakeNotification and call CGEvent.tapEnable(tap:enable:true) β€” or tear down and re-create the tap entirely. Re-creating is more reliable.
NSWorkspace.didWakeNotification β†’
teardownEventTap()
registerGlobalHotkey()

Putting it all together β€” the OpenWhisper flow

Here’s how all the pieces connect in a real app. The entire interaction loop for push-to-talk dictation:

// 1. FN key down β†’ CGEventTap fires eventTapCallback
// 2. AVAudioEngine starts capturing mic
// 3. Status bar icon animates (pulsing red dot)
// 4. FN key up β†’ stop capture
// 5. WhisperKit transcribes the audio buffer on-device
// 6. Text is pasted into the frontmost app via CGEvent

func pasteText(_ text: String) {
    // Write to pasteboard
    NSPasteboard.general.clearContents()
    NSPasteboard.general.setString(text, forType: .string)

    // Synthesise Cmd+V
    let src = CGEventSource(stateID: .hidSystemState)
    let down = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true)!
    let up   = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false)!
    down.flags = .maskCommand
    up.flags   = .maskCommand
    down.post(tap: .cgAnnotatedSessionEventTap)
    up.post(tap:   .cgAnnotatedSessionEventTap)
}
πŸ’‘ WhisperKit is the open-source Swift package that brings on-device Whisper transcription to Apple Silicon. On an M2 MacBook, a 10-second audio clip transcribes in under 400ms. No server, no API key, no privacy concerns.

Closing thoughts

Menu bar apps are the best kind of side project: small enough to ship in a weekend, useful enough to keep open all day, and technically deep enough that you’ll learn something new every time you dig in.

I built OpenWhisper in roughly three weekends of focused work. The CGEventTap bugs alone took most of weekend two, which is partly why this post exists β€” so you don’t lose the same hours I did.

A few parting principles I’ve landed on:

  • Do one thing. The icon takes up permanent space on every user’s screen. Justify the pixel.
  • Respect focus. If you can use .nonactivatingPanel, use it. Never steal the keyboard from the user.
  • Handle permissions gracefully. Guide the user to System Settings with a clear explanation, not just a crash.
  • Warm up your SwiftUI views. The first-open flicker is the first impression. Don’t let it be a bad one.
  • Re-register your event tap after sleep. Every time. No exceptions.

The full source for OpenWhisper β€” including the CGEventTap bridge, the WhisperKit integration, and the GitHub Actions notarization workflow β€” is on GitHub at github.com/malukenho/openwhisper. Go build something.

The whole OpenWhisper
codebase is under
2 000 lines of
Swift. Small is
a feature, not
a limitation.


Swift 6 Β· macOS 14+ Β· Tested on Apple Silicon & Intel Β· Last updated April 2026