Vedran Burojevic
← WritingAugust 30, 2026

Menu bar apps with SwiftUI and AppKit: panels, windows, and focus

A practical guide to building macOS menu bar utilities that combine SwiftUI views with AppKit panels, windows, activation rules, and focus behavior.

On this page

Menu bar apps look smaller than regular Mac apps.

That is the trap.

A menu bar utility still has to deal with activation, keyboard focus, multiple displays, Settings, permissions, launch behavior, privacy boundaries, and the difference between a quick popover and a real window. The icon is small. The product surface is not.

SwiftUI makes the first version pleasantly direct with MenuBarExtra. AppKit still matters when the app needs tighter control over panels, responder-chain behavior, status-item sessions, window ordering, or legacy integrations. The job is not to pick one framework and defend it until the codebase becomes folklore. The job is to put each framework on the side of the boundary where it behaves well.

A good menu bar app should feel instant, quiet, and predictable. If it steals focus, loses keyboard input, opens duplicate panels, or hides state behind a mysterious icon, users notice immediately. Menu bar apps live in the user's working context. They do not get much forgiveness.

1. Decide whether the menu bar is the app or an entry point

The first architectural decision is product-shaped, not framework-shaped.

A menu bar item can be:

  1. The whole app: a small utility that mostly lives in the menu bar.
  2. A companion surface: quick status and actions for a larger windowed app.
  3. A launcher: a fast route into one or more workflows.
  4. A monitor: a passive indicator with occasional intervention.

Those are different products.

A clipboard cleaner, snippet launcher, focus timer, VPN status indicator, or small build monitor can often be menu-bar-first. A project management app, recipe library, finance tool, or document editor should usually treat the menu bar as an entry point, not the main workspace.

The mistake is trying to fit a full app into a tiny transient panel because the menu bar feels elegant in a mockup. That elegance dies the first time the user needs search results, editing, keyboard navigation, account recovery, and a settings toggle inside a 320-point popover.

Use the menu bar for work that is:

  • fast to start
  • narrow in scope
  • safe to interrupt
  • useful without navigation depth
  • recoverable if the panel disappears

Use a normal window when the task needs sustained attention, complex editing, drag and drop, inspector-style detail, or multiple steps that should survive focus changes.

2. Start with MenuBarExtra, but do not pretend it replaces AppKit

For modern SwiftUI apps, MenuBarExtra is the right first move.

It gives the app a system-managed status item and lets the content be written as SwiftUI:

@main
struct ClipUtilityApp: App {
    var body: some Scene {
        MenuBarExtra("Clip Utility", systemImage: "doc.on.clipboard") {
            ClipboardMenuView()
        }
    }
}

For a simple command menu, the default style is enough. The menu can show buttons, dividers, toggles, and small groups of actions. It is best when the surface behaves like a menu: choose a thing, run an action, close.

For richer content, use the window style:

MenuBarExtra("Clip Utility", systemImage: "doc.on.clipboard") {
    ClipboardPanelView()
        .frame(width: 360, height: 480)
}
.menuBarExtraStyle(.window)

That buys room for custom layout, search, previews, lists, and a more app-like panel. It does not remove the need to design activation, focus, dismissal, and settings deliberately.

If the app is menu-bar-only, set LSUIElement so it does not appear in the Dock or app switcher. If the app also has normal windows, be careful with that choice. Hiding the Dock icon for an app that has a real workspace can make it harder to recover windows, understand lifecycle, and use standard Mac behaviors.

The status item should match the product contract:

  • Use a stable symbol for passive utilities.
  • Use stateful appearance only when status matters.
  • Avoid using the menu bar as a dense dashboard.
  • Provide a clear route to Settings and Quit.

The menu bar is premium territory. Treat it like shared infrastructure, not free billboard space.

3. Keep the popover shallow

Menu bar panels are transient by default.

That should shape the feature set.

A menu bar surface is good for:

  • search and copy
  • quick capture
  • current status
  • recent items
  • one-step actions
  • starting or stopping a background operation
  • opening a detailed window at the right place

It is weak for:

  • multi-screen forms
  • account onboarding
  • bulk organization
  • conflict resolution
  • long-running editing
  • complicated permission repair

For a snippet app, the menu bar can search snippets and copy one result. Editing a whole snippet library belongs in a normal window. For a packing app, the menu bar can show the next trip and a few urgent items. Building the trip template belongs in the app. For a clipboard tool, the menu bar can show safe recent clips. Privacy rules, filtering, and exclusions deserve a real settings surface.

A practical split looks like this:

Menu bar panel

      ├── Search recent items
      ├── Run quick actions
      ├── Show current status
      └── Open full window for editing, history, settings, or recovery

That split keeps the menu bar fast. It also keeps complex product decisions in surfaces that can support them.

4. Model windows as product surfaces, not implementation leftovers

A menu bar app often needs more than one kind of surface.

Use SwiftUI scenes deliberately:

@main
struct ClipUtilityApp: App {
    var body: some Scene {
        MenuBarExtra("Clip Utility", systemImage: "doc.on.clipboard") {
            MenuBarRootView()
        }
        .menuBarExtraStyle(.window)
 
        WindowGroup("Library") {
            LibraryWindow()
        }
 
        Window("Activity", id: "activity") {
            ActivityWindow()
        }
        .defaultSize(width: 420, height: 560)
 
        Settings {
            SettingsView()
        }
    }
}

The scene choice matters:

  • Use MenuBarExtra for the status item and quick panel.
  • Use WindowGroup for primary content that can have multiple windows.
  • Use Window for singleton auxiliary windows.
  • Use Settings for preferences instead of inventing a custom settings panel.
  • Use UtilityWindow when the app needs a floating auxiliary panel on systems where it is available.

Avoid WindowGroup for singleton state. It creates standard “New Window” behavior and allows duplicates. That is correct for documents and workspaces. It is wrong for a global activity monitor, recorder controls, or one shared diagnostics window.

When opening data-driven windows, pass identifiers, not large model values:

@Environment(\.openWindow) private var openWindow
 
Button("Open in Library") {
    openWindow(value: selectedClip.id)
}

Resolve the identifier from the store inside the destination window. That keeps state restoration smaller, avoids copying value types, and gives every window a clear identity.

5. Treat activation as a feature, not a side effect

Menu bar apps live between two modes:

  1. the app should stay quiet while the user works elsewhere
  2. the app should become interactive instantly when asked

That line is easy to blur.

A status indicator should not activate the app just because it refreshed. A search panel should accept keyboard input immediately after the user clicks the menu bar icon or presses the global shortcut. A full editor window should behave like a normal Mac window once opened.

Define activation behavior per surface:

  • Passive status: update without stealing focus.
  • Menu command: run and close without activating more than necessary.
  • Search panel: focus the search field and support Escape.
  • Full window: activate the app, bring the window forward, restore selection.
  • Settings: open predictably from the menu bar and app menu.

Do not rely on “whatever SwiftUI did this time” as the activation model. That is not architecture. It is an implementation accident waiting for a regression.

For pure MenuBarExtra surfaces, let SwiftUI manage the standard behavior as far as it can. When you need custom NSPanel behavior, own it explicitly: decide whether the panel becomes key, whether it hides on deactivation, whether Escape closes it, and whether it participates in the responder chain.

The user should never have to ask: “where did the window go?”

6. Make focus and keyboard behavior boring

A good menu bar utility is often used from the keyboard.

That means focus cannot be an afterthought.

For SwiftUI content, use @FocusState to put the cursor where the user expects:

struct ClipboardPanelView: View {
    @State private var query = ""
    @FocusState private var searchFocused: Bool
 
    var body: some View {
        VStack(spacing: 0) {
            TextField("Search clips", text: $query)
                .textFieldStyle(.plain)
                .focused($searchFocused)
 
            ClipResultsList(query: query)
        }
        .task {
            searchFocused = true
        }
    }
}

Then test it like a Mac user:

  1. open from the menu bar
  2. type immediately
  3. arrow through results
  4. press Return
  5. press Escape
  6. switch apps and come back
  7. use Full Keyboard Navigation both on and off

If any of those steps feels strange, the implementation is not done.

SwiftUI participates in the AppKit responder chain, but you still need to wire behavior intentionally. Use .focusable() for non-control views that need key commands. Use .onMoveCommand, .onDeleteCommand, .onExitCommand, and .onCommand where the focused view should respond to keyboard input.

For app-wide commands, route through focused scene values instead of global state:

extension FocusedValues {
    @Entry var selectedClipIDs: Set<Clip.ID>?
}
 
struct LibraryView: View {
    @State private var selection: Set<Clip.ID> = []
 
    var body: some View {
        ClipTable(selection: $selection)
            .focusedSceneValue(\.selectedClipIDs, selection)
    }
}
 
struct ClipCommands: Commands {
    @FocusedValue(\.selectedClipIDs) private var selectedClipIDs
 
    var body: some Commands {
        CommandMenu("Clip") {
            Button("Delete") {
                delete(selectedClipIDs ?? [])
            }
            .keyboardShortcut(.delete, modifiers: [])
            .disabled(selectedClipIDs?.isEmpty ?? true)
        }
    }
}

The menu bar is shared across windows. The focused window decides what the command means. If a command reaches into a singleton AppState.shared.selectedThing, the second window will eventually prove the design wrong in public, which is rude but efficient.

7. Use AppKit where Mac behavior is the product

SwiftUI should own the view hierarchy when it can.

AppKit should own behavior SwiftUI cannot express cleanly.

Common reasons to cross the bridge:

  • custom NSPanel ordering and dismissal
  • precise responder-chain control
  • NSSearchField behavior that keeps focus while arrow keys move selection
  • NSToolbar customization and validation
  • dynamic main menus through NSHostingMenu
  • file panels with directory selection or accessory views
  • legacy drag and drop pasteboard behavior
  • status-item behavior not covered by MenuBarExtra

When embedding AppKit in SwiftUI, keep the representable small:

struct SearchField: NSViewRepresentable {
    @Binding var text: String
    var onMoveSelection: (MoveCommandDirection) -> Void
 
    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
 
    func makeNSView(context: Context) -> NSSearchField {
        let field = KeyRoutingSearchField()
        field.delegate = context.coordinator
        field.onMoveUp = { onMoveSelection(.up) }
        field.onMoveDown = { onMoveSelection(.down) }
        return field
    }
 
    func updateNSView(_ field: NSSearchField, context: Context) {
        context.coordinator.parent = self
        if field.stringValue != text {
            field.stringValue = text
        }
    }
}

The important details are boring and non-negotiable:

  • create the AppKit view once
  • update only changed properties
  • refresh the coordinator in updateNSView
  • do not set frames directly inside the representable
  • let SwiftUI own layout from the outside
  • let the responder chain work instead of manually forwarding every event

Bridging is not a failure. Random bridging is the failure.

8. Give Settings and permissions a real home

Menu bar apps often depend on privileges:

  • launch at login
  • accessibility access
  • input monitoring
  • screen recording
  • file access
  • notifications
  • clipboard monitoring
  • network permissions

Do not hide those behind a tiny warning row in the popover.

Use Settings for durable configuration:

Settings {
    TabView {
        GeneralSettingsView()
            .tabItem { Label("General", systemImage: "gear") }
 
        PrivacySettingsView()
            .tabItem { Label("Privacy", systemImage: "hand.raised") }
 
        ShortcutsSettingsView()
            .tabItem { Label("Shortcuts", systemImage: "keyboard") }
    }
    .scenePadding()
    .frame(width: 520, height: 420)
}

The menu bar panel can show status and a direct repair action:

  • “Accessibility permission required.”
  • “Open Settings.”
  • “Retry after granting access.”

The full settings window should explain the policy:

  • what the app reads
  • what it stores
  • what it ignores
  • how to pause capture
  • how to delete history
  • what never leaves the device

This matters especially for clipboard and automation tools. Users will tolerate power. They will not tolerate mystery.

9. Design dismissal and recovery paths

A menu bar panel disappearing is normal. Losing work because it disappeared is not.

Treat every transient surface as interruptible.

Good defaults:

  • Search query can reset when the panel closes.
  • A copied result can close the panel.
  • A destructive action asks for confirmation or moves to undoable state.
  • Draft input either autosaves or moves to a real window.
  • Long-running work continues outside the panel and exposes status later.

Bad defaults:

  • closing the panel discards a complex form
  • Escape cancels work without warning
  • switching apps loses selection during keyboard workflows
  • a background operation can only be inspected while the panel is open
  • permission repair has no retry path

For a menu bar app, recovery is part of polish. The surface is small; the state model cannot be.

10. Verify it outside the happy path

Menu bar apps fail in places previews do not cover.

Before shipping, test the boring matrix:

  1. first launch with no permissions
  2. launch at login
  3. hidden Dock icon if LSUIElement is enabled
  4. multiple displays with different scale factors
  5. keyboard-only use
  6. app switching while the panel is open
  7. Escape, Return, arrow keys, and standard shortcuts
  8. Settings open from both the menu bar and app menu
  9. sleep and wake
  10. active background work while the panel is closed

Also test with real data volume. A menu bar panel with ten snippets is not evidence. Try hundreds or thousands, then profile search, image loading, and view updates. Small windows can still jank, and users notice because the surface is supposed to feel instant.

Use Instruments when behavior feels slow. Use OSLog signposts around menu open, search, copy, and permission checks. A utility should feel instant because the implementation is measured, not because the demo data was tiny.

11. Keep the boundary clean

The clean architecture for a menu bar app usually looks like this:

Domain model and services

      ├── Menu bar projection
      ├── Main window presentation
      ├── Settings presentation
      └── AppKit adapters for panels, status item, responder-chain edges

The domain model should not care whether an action came from the menu bar, a keyboard shortcut, a Settings toggle, a widget, or a full window. It should expose product operations. Surfaces should adapt those operations to Mac behavior.

That gives you a small menu bar app without a small architecture.

The right standard is simple: the app should disappear when it is not needed, appear instantly when asked, and never make the user think about focus, activation, or framework boundaries. SwiftUI can carry most of the interface. AppKit is there for the parts where Mac behavior is the feature.

Use both deliberately. The user did not install a framework purity experiment. They installed a tool.

Command menu

Navigate the site or run an action