SwiftUI Menu Bar App With a Floating Window: Best Practices
SwiftUI Menu Bar App With a Floating Window: Best Practices
Menu bar apps with floating windows look simple. A status item, a popover, maybe a detached window that hovers over whatever the user is working on. In practice, almost every interesting behavior (focus, activation, multi monitor, click outside to dismiss, keyboard shortcuts) fights the default SwiftUI lifecycle. This guide is the checklist I wish I had before building one.
Pick the Right Primitive: MenuBarExtra vs NSStatusItem + NSPanel
SwiftUI ships MenuBarExtra since macOS 13. It is the right default for simple cases: a menu, a small popover, a handful of controls. Use .menuBarExtraStyle(.window) and you get a SwiftUI view in a popover that dismisses on click outside, for free.
Reach for NSStatusItem plus a custom NSPanel when you need any of these:
- A window that stays visible while the user clicks into other apps (a HUD or reference panel)
- A window larger than a popover that should not feel like a sheet
- Custom positioning (not anchored to the menu bar icon)
- Precise control over key window behavior, shadows, or corner radius
- Multiple simultaneous floating windows
If you are unsure, start with MenuBarExtra(.window) and only graduate to NSPanel when you hit a wall. The AppKit path is more powerful but gives up a lot of SwiftUI defaults.
The NSPanel Floating Window Recipe
When you need the AppKit path, the floating window itself is an NSPanel with a specific style mask and behavior:
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 520),
styleMask: [.nonactivatingPanel, .titled, .closable, .fullSizeContentView],
backing: .buffered,
defer: false
)
panel.titlebarAppearsTransparent = true
panel.titleVisibility = .hidden
panel.isFloatingPanel = true
panel.level = .floating
panel.hidesOnDeactivate = false
panel.isMovableByWindowBackground = true
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.becomesKeyOnlyIfNeeded = true
Three settings matter most:
.nonactivatingPanelmeans clicking the panel does not activate your app. The user keeps their current app frontmost. This is the difference between a HUD and a regular window.becomesKeyOnlyIfNeeded = truemeans the panel only becomes key when the user interacts with a text field or control that actually needs keyboard input. Otherwise the underlying app keeps key focus.collectionBehaviorwith.canJoinAllSpacesmakes the panel follow the user across Spaces and show over full screen apps.
Host your SwiftUI view inside via NSHostingView:
let hostingView = NSHostingView(rootView: ContentView())
panel.contentView = hostingView
Positioning Relative to the Menu Bar Icon
The status item has a button view. Use its window to get the icon frame in screen coordinates, then place the panel under it:
guard let button = statusItem.button,
let buttonWindow = button.window else { return }
let buttonFrame = buttonWindow.convertToScreen(button.frame)
let panelWidth = panel.frame.width
let x = buttonFrame.midX - panelWidth / 2
let y = buttonFrame.minY - panel.frame.height - 4
panel.setFrameOrigin(NSPoint(x: x, y: y))
Clamp x against the visible frame of the screen the icon lives on so the panel never slides off the edge on a small display:
if let screen = buttonWindow.screen {
let visible = screen.visibleFrame
let clampedX = min(max(x, visible.minX + 8), visible.maxX - panelWidth - 8)
panel.setFrameOrigin(NSPoint(x: clampedX, y: y))
}
Click Outside to Dismiss
NSPanel does not dismiss on outside click by default. Install a global event monitor when the panel opens and tear it down when it closes:
var outsideMonitor: Any?
func showPanel() {
panel.orderFrontRegardless()
outsideMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown]
) { [weak self] _ in
self?.hidePanel()
}
}
func hidePanel() {
panel.orderOut(nil)
if let monitor = outsideMonitor {
NSEvent.removeMonitor(monitor)
outsideMonitor = nil
}
}
Global monitors only fire for events outside your app, which is exactly what you want. If you also need to dismiss on Escape, add a local monitor for .keyDown and check event.keyCode == 53.
Stay Out of the Dock With LSUIElement
A menu bar app should not show a Dock icon or appear in the Command Tab switcher. Set LSUIElement to YES in Info.plist (or Application is agent (UIElement) in Xcode). In a SwiftUI App struct project, add it to the target's Info tab.
One consequence: your app will not become active on launch. If you want the panel to appear immediately, call NSApp.activate(ignoringOtherApps: true) only when the user actually clicks the status item, not on launch.
Focus and First Responder Gotchas
With .nonactivatingPanel + becomesKeyOnlyIfNeeded, the panel will not steal focus from the frontmost app. That is usually what you want, but it breaks text fields: the user clicks into a TextField and nothing happens because the panel is not key.
The fix is to explicitly make the panel key when a control that needs keyboard input is clicked. The simplest approach is to call panel.makeKey() from the control's onTapGesture, or use an NSViewRepresentable wrapper that calls window?.makeKey() in updateNSView when its binding is focused.
For pure-SwiftUI focus, use @FocusState and drive it from a button that also calls panel.makeKey().
Multi Monitor and Display Changes
When the user drags the menu bar to a different display, or plugs in a new monitor, cached screen frames become wrong. Observe NSApplication.didChangeScreenParametersNotification and recompute the panel position the next time it opens. Do not try to move the panel while it is hidden; screens can come and go and you will end up with stale coordinates.
Global Keyboard Shortcut to Toggle the Panel
For a user-configurable hotkey, the KeyboardShortcuts Swift package by Sindre Sorhus is the standard. It handles the registration dance with RegisterEventHotKey and exposes a SwiftUI recorder view. Bind the hotkey action to a function that toggles the panel:
KeyboardShortcuts.onKeyUp(for: .togglePanel) { [weak self] in
self?.togglePanel()
}
Register it once in applicationDidFinishLaunching, not in a view's onAppear, otherwise the handler gets attached multiple times as views come in and out of existence.
Persisting Panel State Across Launches
Save the panel frame to UserDefaults when the user moves or resizes it, and restore on launch. Use NSWindow.didMoveNotification and NSWindow.didEndLiveResizeNotification. Only persist the origin if the frame is still on a visible screen at save time, otherwise the next launch will place the panel on a monitor that no longer exists.
Common Pitfalls
- Panel does not appear: check
panel.level. Anything below.floatingwill be hidden behind full screen apps. - Panel steals focus: you forgot
.nonactivatingPanelin the style mask, or you are callingNSApp.activate(ignoringOtherApps: true)at the wrong time. - Text fields ignore typing:
becomesKeyOnlyIfNeededis doing its job. You need tomakeKey()explicitly when a control needs input. - Panel disappears when switching Spaces: add
.canJoinAllSpacestocollectionBehavior. - Dock icon keeps showing:
LSUIElementonly takes effect on a clean launch. Quit and relaunch, do not stop from Xcode. - Status item button is nil on launch:
statusItem.buttonis only available after the app finishes launching. Create the status item inapplicationDidFinishLaunching, not ininit. - Window flashes on Sonoma/Sequoia: recent macOS versions animate window appearance more aggressively. Set
panel.animationBehavior = .utilityWindowto get a subtle fade instead of the default.
Minimal Working Example
The smallest useful skeleton, assuming an App with LSUIElement set and an AppDelegate:
import SwiftUI
import AppKit
@main
struct MenuBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene { Settings { EmptyView() } }
}
final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem!
private var panel: NSPanel!
private var monitor: Any?
func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.button?.image = NSImage(
systemSymbolName: "bolt.fill",
accessibilityDescription: nil
)
statusItem.button?.action = #selector(togglePanel)
statusItem.button?.target = self
panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 380, height: 480),
styleMask: [.nonactivatingPanel, .titled, .closable, .fullSizeContentView],
backing: .buffered,
defer: false
)
panel.titlebarAppearsTransparent = true
panel.titleVisibility = .hidden
panel.isFloatingPanel = true
panel.level = .floating
panel.hidesOnDeactivate = false
panel.becomesKeyOnlyIfNeeded = true
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.animationBehavior = .utilityWindow
panel.contentView = NSHostingView(rootView: ContentView())
}
@objc private func togglePanel() {
if panel.isVisible {
hidePanel()
} else {
showPanel()
}
}
private func showPanel() {
guard let button = statusItem.button, let buttonWindow = button.window else { return }
let frame = buttonWindow.convertToScreen(button.frame)
let x = frame.midX - panel.frame.width / 2
let y = frame.minY - panel.frame.height - 4
panel.setFrameOrigin(NSPoint(x: x, y: y))
panel.orderFrontRegardless()
monitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown]
) { [weak self] _ in self?.hidePanel() }
}
private func hidePanel() {
panel.orderOut(nil)
if let m = monitor { NSEvent.removeMonitor(m); monitor = nil }
}
}
That is the skeleton. Everything else (settings, onboarding, keyboard shortcuts, persistence) hangs off it.
Wrapping Up
A floating menu bar window is one of those features where every individual piece is documented, but nothing tells you which pieces to combine. The short version: NSPanel with .nonactivatingPanel, becomesKeyOnlyIfNeeded, .floating level, and .canJoinAllSpaces; host SwiftUI via NSHostingView; dismiss with a global event monitor; and set LSUIElement to keep the Dock clean. Once that skeleton is in place, the SwiftUI content inside behaves like any other view.
Fazm is an open source macOS AI agent built with SwiftUI and a floating menu bar window. Open source on GitHub.