Building Menu Bar Apps with SwiftUI

Menu bar apps are my favourite kind of Mac software to build. They're always one click away, they don't fight for space in the Dock, and they suit the small, focused utilities I like making. I've shipped five of them so far: CloudMonitor, UpBar, WorkBar, CaptureBar and CamPeek.

SwiftUI makes the first version easy. The polish is harder: focus, sizing, right-click menus, launch at login, and the parts that behave differently because your app has no Dock icon. This post covers what I've learned getting those right.

MenuBarExtra: the quick start

Since macOS 13, SwiftUI has a scene type for this. A working menu bar app is a few lines:

@main
struct MyMenuBarApp: App {
    var body: some Scene {
        MenuBarExtra("My App", systemImage: "bolt.fill") {
            ContentView()
                .frame(width: 320)
        }
        .menuBarExtraStyle(.window)
    }
}

The default style, .menu, renders your content as a standard NSMenu, which is fine for a list of commands. .window gives you a popover-like panel where you can put any SwiftUI view.

It's a great place to start, and for some apps it's all you need. The limits show up quickly, though:

Once I needed any of those, I switched to AppKit for the shell and kept SwiftUI for everything inside it.

Going custom: NSStatusItem + NSPopover

The custom version is an NSStatusItem for the icon and an NSPopover that hosts your SwiftUI view. You still use the SwiftUI App lifecycle. An app delegate owns the status item:

@main
struct MyMenuBarApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        Settings { SettingsView() }
    }
}

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

    func applicationDidFinishLaunching(_ notification: Notification) {
        let hosting = NSHostingController(rootView: ContentView())
        hosting.sizingOptions = .preferredContentSize
        popover.contentViewController = hosting
        popover.behavior = .transient

        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        if let button = statusItem.button {
            button.image = NSImage(systemSymbolName: "bolt.fill",
                                   accessibilityDescription: "My App")
            button.target = self
            button.action = #selector(statusItemClicked(_:))
            button.sendAction(on: [.leftMouseUp, .rightMouseUp])
        }
    }

    @objc private func statusItemClicked(_ sender: NSStatusBarButton) {
        if NSApp.currentEvent?.type == .rightMouseUp {
            showContextMenu()
        } else if popover.isShown {
            popover.performClose(sender)
        } else {
            NSApp.activate()
            popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
        }
    }
}

A few details in there do a lot of work:

Sizing the popover

Popover sizing caused me the most trouble early on. Without help, the popover picks a size once and then ignores what your SwiftUI content wants. That gives you clipped views, or the popover jumping when you switch tabs.

On macOS 13+, setting sizingOptions = .preferredContentSize on the hosting controller (as above) makes the popover follow the SwiftUI view's ideal size. Then give the view a fixed width and let the height come from the content:

ContentView()
    .frame(width: 320)
    .fixedSize(horizontal: false, vertical: true)

For content that can grow without limit, like a list of files or sites, cap the height with a ScrollView and .frame(maxHeight: 480). A popover taller than the screen is worse than one that scrolls.

A right-click menu for power users

A left click opens the popover and a right click shows a short menu with Settings, About and Quit. The trick is to attach a menu to the status item just long enough to show it, then remove it. Otherwise the menu also takes over left clicks:

private func showContextMenu() {
    let menu = NSMenu()
    let settings = menu.addItem(withTitle: "Settings…",
                                action: #selector(openSettings),
                                keyEquivalent: ",")
    settings.target = self
    menu.addItem(.separator())
    menu.addItem(withTitle: "Quit",
                 action: #selector(NSApplication.terminate(_:)),
                 keyEquivalent: "q")

    statusItem.menu = menu
    statusItem.button?.performClick(nil)
    statusItem.menu = nil
}

It's a small thing, but people who use a lot of menu bar apps expect it, and it means Quit is never buried in a settings screen.

Living without a Dock icon

Set LSUIElement ("Application is agent (UIElement)") to YES in Info.plist. The app then has no Dock icon, doesn't show in ⌘-Tab, and has no menu bar of its own while it's active. That's what you want, but it has side effects:

Launch at login

A menu bar app that doesn't start at login gets forgotten. Since macOS 13 this is one call:

import ServiceManagement

func setLaunchAtLogin(_ enabled: Bool) throws {
    if enabled {
        try SMAppService.mainApp.register()
    } else {
        try SMAppService.mainApp.unregister()
    }
}

var launchesAtLogin: Bool {
    SMAppService.mainApp.status == .enabled
}

Read the state from SMAppService each time instead of saving your own Bool in UserDefaults. Users can turn it off in System Settings → General → Login Items, and your toggle should reflect that.

Floating panels

Some things don't belong in a popover that closes when you click away, like a notepad you type into during a call, or a camera feed you keep in the corner of the screen. WorkBar's notes and CamPeek's pop-out cameras both use a floating panel:

final class FloatingPanel: NSPanel {
    init(contentRect: NSRect) {
        super.init(contentRect: contentRect,
                   styleMask: [.titled, .closable, .resizable,
                               .nonactivatingPanel, .fullSizeContentView],
                   backing: .buffered,
                   defer: false)
        isFloatingPanel = true
        level = .floating
        hidesOnDeactivate = false
        collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
        titleVisibility = .hidden
        titlebarAppearsTransparent = true
        isMovableByWindowBackground = true
    }

    override var canBecomeKey: Bool { true }
}

.nonactivatingPanel lets you click into the panel without pulling focus away from the app you're using. .canJoinAllSpaces and .fullScreenAuxiliary keep it visible when you switch Spaces or go full screen. Overriding canBecomeKey lets text fields inside it accept typing. Put your SwiftUI view in with NSHostingView, and you can reuse the same view in the popover and the panel.

Gotchas

Use a template image for the icon

The menu bar switches between light and dark depending on the wallpaper, not just the system appearance. SF Symbols handle this for you. For a custom icon, use a PDF or SVG set to "Render As: Template Image" in the asset catalog, or set isTemplate = true. Aim for about 18pt tall and keep it simple. At that size, detail turns into noise.

Don't break the material

Popovers and MenuBarExtra windows get a translucent background that adapts to light and dark mode. If you put .background(Color.white) on your root view, you lose it and the app looks out of place. Leave the root background alone and use materials or Color(nsColor: .controlBackgroundColor) for cards inside it.

Stop working when nobody's looking

A menu bar app runs all day, so wasted work adds up. Ask whether a timer that refreshes the UI every second needs to run while the popover is closed. Usually it doesn't. With NSPopover, use the popoverWillShow and popoverDidClose delegate methods to start and stop UI-only work. Keep real background work, like UpBar's uptime checks, on a sensible interval, and use NSBackgroundActivityScheduler so the system can group it with other work.

Keep the menu bar text short

It's tempting to show live data in the menu bar itself, like a percentage or a counter. That's useful, but on a MacBook with a notch every point of width counts, and items that don't fit get hidden. Prefer an icon with a short value and use NSStatusItem.variableLength carefully. A title that keeps changing width also makes the icons next to it shift around.

What I've settled on


If you want to see how these patterns feel in practice, all five apps are on the Mac App Store: CloudMonitor, UpBar, WorkBar, CaptureBar and CamPeek. Questions or corrections are welcome at joem.dev@icloud.com.