SwiftUI Menu Bar App With a Floating Window: Best Practices

Matthew Diakonov··8 min read

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:

  • .nonactivatingPanel means 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 = true means 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.
  • collectionBehavior with .canJoinAllSpaces makes 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 .floating will be hidden behind full screen apps.
  • Panel steals focus: you forgot .nonactivatingPanel in the style mask, or you are calling NSApp.activate(ignoringOtherApps: true) at the wrong time.
  • Text fields ignore typing: becomesKeyOnlyIfNeeded is doing its job. You need to makeKey() explicitly when a control needs input.
  • Panel disappears when switching Spaces: add .canJoinAllSpaces to collectionBehavior.
  • Dock icon keeps showing: LSUIElement only takes effect on a clean launch. Quit and relaunch, do not stop from Xcode.
  • Status item button is nil on launch: statusItem.button is only available after the app finishes launching. Create the status item in applicationDidFinishLaunching, not in init.
  • Window flashes on Sonoma/Sequoia: recent macOS versions animate window appearance more aggressively. Set panel.animationBehavior = .utilityWindow to 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.

Related Posts