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:
- There's no public API to open or close it from code, for example from a global shortcut or a notification.
- You can't tell a left click from a right click, so there's no easy context menu.
- You can't detach it into a floating window or resize it.
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:
behavior = .transientcloses the popover when you click anywhere else, which is what people expect from the menu bar.NSApp.activate()before showing makes your app active, so text fields get focus and keyboard input goes to your popover instead of the app behind it. On macOS 13 and earlier, useNSApp.activate(ignoringOtherApps: true).sendAction(on:)makes the button fire for right clicks too. By default it only fires on left mouse up.
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:
- Windows open behind other apps. If you open Settings or an onboarding window from the popover, call
NSApp.activate()first or it will open behind whatever the user was using. From SwiftUI on macOS 14+, useSettingsLinkor theopenSettingsenvironment action to open the Settings scene. - Edit shortcuts can go missing. ⌘C, ⌘V and ⌘A in text fields work through the Edit menu. If you build the app without one (a purely AppKit setup with no main menu, for example), those shortcuts stop working in your popover. Add a standard Edit menu to
NSApp.mainMenu. Nobody sees it, but the key equivalents work again. - Users can't find the app. If someone hides your icon with a menu bar manager, or the MacBook notch covers it, there's no Dock icon to fall back on. Reopening the app from Finder or Spotlight should do something useful, such as showing the popover or a window. Handle
applicationShouldHandleReopen(_:hasVisibleWindows:)in the app delegate.
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
- 320pt wide is about right. It's wide enough for a two-column layout, still narrow enough to feel like a menu, and it fits next to icons near the right edge of the screen.
- Open instantly. Keep a model that updates in the background, and make opening the popover only render data you already have. A spinner every time you click the icon makes the app feel slow, even if the fetch is quick.
- Left click to open, right click for a menu. Settings, About and Quit go in the right-click menu, and everything else in the popover.
- Start with MenuBarExtra and switch when you need to. All of your SwiftUI views carry over, so you only rewrite the shell.
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.