Building Menu Bar Apps with SwiftUI

macOS menu bar apps are the unsung utility format of the platform. A well-made one lives out of the way, surfaces information instantly, and disappears just as fast. Calendar popups, clipboard managers, weather widgets, audio controllers, VPN toggles — these are all menu bar apps at heart.

In SwiftUI, building one has become straightforward. This guide covers everything from the basic MenuBarExtra setup to window management, settings, and common patterns used in production apps. The core MenuBarExtra API starts at macOS 13; the settings and observation examples below assume macOS 14 or newer.

MenuBarExtra: the basics

MenuBarExtra was introduced in macOS 13 Ventura and is the SwiftUI-native way to create menu bar apps. Before it, you needed AppKit’s NSStatusBarButton and manual event handling.

A minimal example:

import SwiftUI

@main
struct ClipboardHistoryApp: App {
    var body: some Scene {
        MenuBarExtra("Clipboard", systemImage: "clipboard") {
            Button("Show History") { }
            Divider()
            Button("Clear All") { }
            Divider()
            SettingsLink {
                Text("Settings")
            }
            Divider()
            Button("Quit") {
                NSApplication.shared.terminate(nil)
            }
        }
    }
}

That is enough to put an icon in the menu bar, show a dropdown menu on click, and provide the standard quit path. MenuBarExtra creates the menu bar item; hiding the Dock icon and App Switcher entry is a separate app configuration step with LSUIElement.

Adding a popover instead of a menu

A plain Menu style is fine for simple actions. Many menu bar apps use a popover: a small SwiftUI view that appears in a floating window below the icon.

For this, use MenuBarExtra with a view content style instead of a menu:

MenuBarExtra("Clipboard", systemImage: "clipboard") {
    ClipboardPopover()
}
.menuBarExtraStyle(.window)

.menuBarExtraStyle(.window) changes the dropdown from a standard menu to a popover-like window hosting your SwiftUI view. This gives you full control over layout and interactivity.

The same approach works for any view — a compact calendar, a clipboard item list, a weather panel, or an audio level indicator.

Customizing the icon

The icon can be a system symbol, a custom image, or programmatic drawing:

@main
struct WeatherBarApp: App {
    @State private var temperature = "72°"

    var body: some Scene {
        MenuBarExtra(temperature, systemImage: "thermometer.sun") {
            // ...
        }
    }
}

The title passed to the systemImage initializer is used by accessibility. If you want visible dynamic text in the menu bar, use the label initializer:

MenuBarExtra {
    // ...
} label: {
    Text(temperature)
}

That label updates automatically when temperature changes. This is useful for dynamic values like temperature, battery level, or unread count.

For a custom icon without text:

MenuBarExtra {
    // ...
} label: {
    Image(systemName: "leaf.fill")
        .foregroundStyle(.green)
}

NSStatusBarButton for custom interactions

MenuBarExtra works great for standard click-to-open behavior. If you need finer control — right-click actions, drag-and-drop targets, hover states, or custom click handling — you can work with NSStatusBarButton through NSViewRepresentable.

The typical approach creates a custom status bar item via AppKit, then bridges it to SwiftUI:

struct StatusBarController: NSViewRepresentable {
    typealias NSViewType = NSView

    func makeNSView(context: Context) -> NSView {
        let view = NSView()
        let statusItem = NSStatusBar.system.statusItem(
            withLength: NSStatusItem.variableLength
        )
        context.coordinator.statusItem = statusItem
        return view
    }

    func updateNSView(_ nsView: NSView, context: Context) { }

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    class Coordinator {
        var statusItem: NSStatusItem?
    }
}

You can then configure the button in makeNSView with a target-action for different click types, or attach a custom NSMenu. In practice, reserve this for cases where MenuBarExtra is genuinely insufficient.

Managing windows from the menu bar

Many menu bar apps need to open a regular window — for settings, larger views, or secondary workflows.

Opening a settings window

On macOS 14 and newer, the standard approach uses SettingsLink inside the menu:

MenuBarExtra("App", systemImage: "gearshape") {
    SettingsLink {
        Text("Settings")
    }
    .keyboardShortcut(",", modifiers: .command)
    Divider()
    Button("Quit") { NSApplication.shared.terminate(nil) }
}

This opens the Settings scene if one is registered in your App:

@main
struct MyApp: App {
    var body: some Scene {
        MenuBarExtra(/*...*/)
        Settings {
            SettingsView()
        }
    }
}

Opening an arbitrary window programmatically

To open a specific window (not just settings), add a Window scene with an identifier, then call openWindow(id:) from a view in the menu bar content:

@main
struct MyApp: App {
    var body: some Scene {
        MenuBarExtra("App", systemImage: "clock") {
            HistoryButton()
        }

        Window("History", id: "history") {
            HistoryView()
        }
        .defaultPosition(.center)
    }
}

struct HistoryButton: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button("History") {
            openWindow(id: "history")
        }
    }
}

openWindow(id:) targets the Window scene by its identifier. This keeps window presentation in SwiftUI without dipping into AppKit’s NSWindowController.

Menu bar items that do work

A menu bar app that shows live data — clipboard listener, network monitor, calendar feed — needs something to drive background updates.

SwiftUI menus are not a place for long-lived work. Keep the menu population lightweight and push background work to a task or a dedicated actor.

A typical macOS 14+ pattern uses observable state managed by the top-level App:

@main
struct TimerBarApp: App {
    @State private var timer = TimerModel()

    var body: some Scene {
        MenuBarExtra("Timer", systemImage: "timer") {
            Text(timer.elapsed)
            Divider()
            Button(timer.isRunning ? "Pause" : "Start") {
                timer.toggle()
            }
            Button("Reset") { timer.reset() }
        }
    }
}

@MainActor
@Observable
final class TimerModel {
    private(set) var elapsed = "0s"
    private(set) var isRunning = false
    private var startDate: Date?
    private var task: Task<Void, Never>?

    func toggle() {
        isRunning ? stop() : start()
    }

    private func start() {
        isRunning = true
        startDate = .now
        task?.cancel()
        task = Task {
            while isRunning {
                if let start = startDate {
                    elapsed = Duration.milliseconds(
                        Int64(-start.timeIntervalSinceNow * 1000)
                    ).formatted(.time(pattern: .minuteSecond))
                }
                do {
                    try await Task.sleep(for: .milliseconds(200))
                } catch {
                    break
                }
            }
        }
    }

    private func stop() {
        isRunning = false
        startDate = nil
        task?.cancel()
        task = nil
    }

    func reset() {
        stop()
        elapsed = "0s"
    }
}

The timer model keeps state and processes events on @MainActor because it feeds the menu bar’s UI. The update loop runs inside an unstructured task that the model owns and cancels when the timer stops.

Avoiding stale state

When your menu bar item shows data from an external source (API, file system, pasteboard), poll or observe for changes rather than recalculating on each menu open. Clipboard apps commonly poll NSPasteboard.general.changeCount and read the pasteboard only when the count changes. For API data, use a refresh timer that feeds into @Observable state.

A common mistake is fetching data only when the menu opens. This adds latency to the open action — the user sees a spinner for every menu click. Instead, keep the data fresh in the background and display whatever is cached when the menu appears.

Running without a dock icon

A true menu bar app does not show in the Dock or the App Switcher. Set this in Info.plist:

<key>LSUIElement</key>
<true/>

Or set it at runtime if needed:

NSApp.setActivationPolicy(.accessory)

With LSUIElement = YES, the app launches as an agent app. No icon appears in the Dock or App Switcher. Whether a window opens on launch still depends on the scenes you register, so a menu-bar-only utility should avoid a default WindowGroup.

Attaching a preferences window

Settings for menu bar apps should follow the standard macOS preferences panel format. Register a Settings scene in your App:

struct MyApp: App {
    var body: some Scene {
        MenuBarExtra("App", systemImage: "app") {
            // menu items
        }

        Settings {
            SettingsView()
        }
    }
}

This gives you the standard Cmd+, shortcut and a properly styled preferences window.

Inside the settings view, use TabView for multiple panes:

struct SettingsView: View {
    var body: some View {
        TabView {
            GeneralSettings()
                .tabItem { Label("General", systemImage: "gearshape") }
            AdvancedSettings()
                .tabItem { Label("Advanced", systemImage: "wrench") }
        }
        .frame(width: 400)
    }
}

macOS will automatically produce a toolbar-style tab switcher in the settings window.

Common gotchas

App name in the menu bar

The visible menu bar item depends on the initializer you choose. If you only want an icon, use the label initializer and provide an image:

MenuBarExtra {
    // ...
} label: {
    Image(systemName: "leaf")
}

The Quit button

A menu bar app needs an explicit quit button. Without one, users may have to open Activity Monitor to quit it, especially if the app runs as an agent with LSUIElement. Always include:

Button("Quit") {
    NSApplication.shared.terminate(nil)
}

You can shorten the keyboard shortcut if you want: .keyboardShortcut("q", modifiers: .command) still works because the menu bar app does not have an “app menu” in the standard sense.

Accessibility labels

Menu bar icons with no text need an accessibility label:

MenuBarExtra {
    // ...
} label: {
    Image(systemName: "leaf")
        .accessibilityLabel("Toggle appearance")
}

Without this, VoiceOver users see “untitled” — not helpful.

Window level for floating panels

Some separately opened utility windows should float above regular document windows. On newer macOS versions, SwiftUI exposes .windowLevel(.floating) for window scenes:

Window("History", id: "history") {
    HistoryView()
}
.windowLevel(.floating)

This keeps the panel on top of document windows without blocking the menu bar. If you target older macOS versions, configure the underlying NSWindow or NSPanel from AppKit instead.

Getting started with a template

The fastest way to start a new menu bar app:

  1. Create a new macOS app in Xcode.
  2. Remove the default ContentView and WindowGroup.
  3. Replace the App body with MenuBarExtra.
  4. Add LSUIElement = YES to Info.plist.
  5. Add a Settings scene and, on macOS 14+, a SettingsLink in the menu.

From there, add views, background work, and settings as needed.

Further reading