Menu bar apps are the best form factor on macOS for utilities. One click on a status bar icon, a popover appears, the user does what they need, clicks away, and the popover vanishes. No Dock icon, no window management, no full-screen mode. Just a small panel that stays out of the way.
We ship several menu bar apps in our portfolio — AI Battery lives entirely in the menu bar. Building them with SwiftUI inside NSPopover is straightforward once you understand the boundary between SwiftUI and AppKit. Before that understanding, it is a sequence of silent failures that produce no compiler errors and no runtime crashes — just UI that does not appear, views that clip without warning, and environment objects that vanish.
This post covers the full architecture: NSStatusItem setup, wiring SwiftUI into NSPopover, the contentSize trap, environment propagation across window boundaries, and why the Settings scene breaks when you put NavigationStack inside it.
The Skeleton: NSStatusItem + NSPopover
A menu bar app on macOS needs two things: an NSStatusItem (the icon in the menu bar) and an NSPopover (the panel that appears when you click it). SwiftUI does not have native equivalents for either. You write the shell in AppKit and host your SwiftUI views inside it.
@main
struct MyMenuBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
Settings {
SettingsView()
}
}
}
final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem!
private var popover: NSPopover!
func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(
withLength: NSStatusItem.squareLength
)
if let button = statusItem.button {
button.image = NSImage(systemName: "gauge.medium")
button.action = #selector(togglePopover)
}
popover = NSPopover()
popover.contentSize = NSSize(width: 320, height: 400)
popover.behavior = .transient
popover.contentViewController = NSHostingController(
rootView: PopoverContentView()
)
}
@objc private 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
)
}
}
}
This is the standard pattern. The @NSApplicationDelegateAdaptor bridges the SwiftUI app lifecycle with the AppKit delegate where you set up the status item. The popover hosts a SwiftUI view through NSHostingController.
One timing detail: statusItem.button can be nil if you try to access it before the status bar has finished laying out. In practice, applicationDidFinishLaunching is late enough. But if you try to show the popover programmatically during launch — say, for a first-run onboarding flow — defer the call with DispatchQueue.main.asyncAfter(deadline: .now() + 0.1). Without the delay, show(relativeTo:of:preferredEdge:) silently does nothing. No error, no popover.
The contentSize Trap
This is the issue that caused an App Store rejection for us.
NSPopover.contentSize defines the maximum size of the popover window. If your SwiftUI view requests more space than contentSize allows, the bottom of the view is clipped. There is no warning. No runtime error. No layout complaint from SwiftUI. The content simply disappears at the boundary.
// The popover is 400pt tall
popover.contentSize = NSSize(width: 320, height: 400)
// But the SwiftUI view needs 460pt
struct PopoverContentView: View {
var body: some View {
VStack {
// ... content that measures 460pt
}
.frame(width: 320, height: 460) // Silently clipped at 400pt
}
}
The fix is to ensure contentSize is at least as large as your SwiftUI view's maximum frame. We add a 40-point buffer as a safety margin. If the popover content is dynamic (a list that grows, for example), you need to update contentSize at runtime or use ScrollView to contain the overflow.
Environment Does Not Cross Window Boundaries
This is the most counterintuitive behavior in SwiftUI on macOS. On iOS, environment propagation is straightforward — parent views pass environment values down the view hierarchy, and sheets inherit them. On macOS, a .sheet() opens a separate NSWindow. Environment objects do not propagate across NSWindow boundaries.
@Observable
final class AppState {
var isPremium = false
}
struct PopoverContentView: View {
@Environment(AppState.self) var state
var body: some View {
Button("Upgrade") {
showPaywall = true
}
.sheet(isPresented: $showPaywall) {
PaywallView() // AppState is nil here on macOS
}
}
}
On iOS, PaywallView inherits the AppState environment from PopoverContentView. On macOS, it does not. The sheet opens in a new NSWindow, and the environment is empty. If your view uses @Environment(AppState.self), it crashes with a missing environment error. If it uses an optional pattern, it silently gets the default value.
The fix is to explicitly pass the environment on every .sheet() call:
.sheet(isPresented: $showPaywall) {
PaywallView()
.environment(state) // Must be explicit on macOS
}
We hit this across three apps in the same week. eXpense, Phygital Timer, and Mbox Splitter Pro all had sheets that worked on iOS and silently broke on macOS because of missing environment propagation. The fix is always the same: add .environment(yourObject) to the sheet content.
Settings Scene + NavigationStack = Blank Window
macOS apps declare a Settings scene to add a Preferences window (Cmd+,). The natural instinct is to put a NavigationStack inside it for multi-pane navigation:
Settings {
NavigationStack {
SettingsView() // Renders blank on macOS
}
}
This renders a blank window. No error, no crash. The Settings scene on macOS uses its own window container that conflicts with NavigationStack. The solution is to use Form directly inside Settings, and replace any NavigationLink with Button + .sheet():
Settings {
Form {
Section("Account") {
Button("Manage Subscription") {
showPaywall = true
}
}
}
.sheet(isPresented: $showPaywall) {
PaywallView()
.environment(state) // Remember: explicit on macOS
}
}
This pattern works reliably. Form gives you the native macOS settings appearance, and sheets handle any sub-navigation.
Hiding the Dock Icon
A menu bar-only app should not appear in the Dock. Add this to your Info.plist (or project.yml if using XcodeGen):
# In Info.plist
LSUIElement: true
# In XcodeGen project.yml
settings:
base:
INFOPLIST_KEY_LSUIElement: true
With LSUIElement set, the app has no Dock icon, no main menu bar, and no Cmd+Tab entry. The only visible presence is the status item in the menu bar.
One consequence: without a Dock icon, there is no standard way for the user to bring up Preferences. You need to provide a gear icon or "Settings" button inside the popover that opens the Settings scene programmatically:
Button {
NSApp.sendAction(
Selector(("showSettingsWindow:")),
to: nil,
from: nil
)
} label: {
Image(systemName: "gear")
}
Popover Dismissal and Focus
The .transient behavior on NSPopover means it dismisses when the user clicks outside it. This is the correct default for a menu bar utility. But there is a subtlety: when the popover dismisses, it does not send a "will close" notification to your SwiftUI views. Any .onDisappear modifiers on the root view of the popover fire unreliably.
If you need to save state when the popover closes, use NSPopover's delegate:
extension AppDelegate: NSPopoverDelegate {
func popoverWillClose(_ notification: Notification) {
// Save state, cancel timers, etc.
}
}
Also: if you open the popover, then open a sheet from inside it, dismissing the sheet sometimes dismisses the popover too. The workaround is to set the popover's behavior to .applicationDefined while a sheet is open, then switch back to .transient when the sheet closes. Without this, users lose the popover mid-interaction.
Passing Data Between Popover and Settings
The popover (hosted in NSPopover via NSHostingController) and the Settings scene (declared in the SwiftUI App body) run in separate view hierarchies. They do not share environment by default.
The cleanest pattern is a shared @Observable singleton:
@Observable
final class AppState {
static let shared = AppState()
var isPremium = false
var usageCount = 0
}
// In AppDelegate, when creating the popover:
popover.contentViewController = NSHostingController(
rootView: PopoverContentView()
.environment(AppState.shared)
)
// In the Settings scene:
Settings {
SettingsView()
.environment(AppState.shared)
}
Both the popover and the Settings window see the same instance. Changes in one immediately reflect in the other because @Observable tracks property access.
Screenshots for App Store Review
Menu bar apps present a screenshot challenge for App Store submissions. The reviewer needs to see the popover, but screencapture -l <windowID> captures a specific window — and the popover is a separate window from the status bar.
Our approach: use screencapture -l targeting the popover's window ID specifically. You can find it with CGWindowListCopyWindowInfo in a helper script, filtering for windows owned by your app's process ID with a non-zero height. The popover window has no title, so filter by size instead. We covered the full screenshot automation pipeline in our post on building apps with a shared Swift package.
Key Takeaways
- NSPopover.contentSize must match your SwiftUI view's frame. Content that overflows is silently clipped with no warning. Add a 40pt buffer.
- Environment does not cross NSWindow boundaries. Every
.sheet()on macOS needs an explicit.environment()call. This is the single most common SwiftUI-on-macOS bug. - Never put NavigationStack inside Settings. Use Form + .sheet() instead. NavigationStack renders a blank window with no error.
- Set LSUIElement: true to hide the Dock icon. Provide a settings button inside the popover since there is no menu bar.
- statusItem.button can be nil during early launch. Defer programmatic popover display by a run loop tick if you need to show it at startup.
- Use a shared @Observable singleton to pass state between the popover and the Settings scene. They are separate view hierarchies.