SwiftUI Floating Panel: NSPanel Patterns for macOS Apps
SwiftUI Floating Panel: NSPanel Patterns for macOS Apps
A floating panel is a window that stays above the main application window, used for inspectors, tool palettes, HUDs, and reference content. On macOS, the correct primitive is NSPanel, not NSWindow. SwiftUI does not expose floating panel behavior natively, so you bridge to AppKit, host your SwiftUI views inside NSHostingView, and configure the panel's style mask, window level, and activation policy to get the behavior right.
This guide covers the full pattern: creating the panel, hosting SwiftUI content, handling focus and activation, responding to resizes, and choosing the right configuration for your use case.
Why NSPanel Instead of NSWindow
NSPanel is a subclass of NSWindow designed specifically for auxiliary windows. It provides three behaviors that NSWindow does not:
- Non-activating interaction. With
.nonactivatingPanelin the style mask, clicking the panel does not bring your app to the front. The user stays in whatever app they were using. - Key-only-if-needed focus. Setting
becomesKeyOnlyIfNeeded = truemeans the panel only takes keyboard focus when the user clicks a text field or control that needs it. - Automatic level management. The
.floatinglevel keeps the panel above regular windows without any manual z-ordering.
A regular NSWindow configured with .floating level gets close, but misses the activation and focus policies. Those details matter when the panel should feel like a tool that assists the user rather than a window that competes for attention.
Panel Configuration Reference
Every floating panel starts with the same core setup. The differences between panel types come down to which flags you set.
| Configuration | Inspector Panel | HUD / Overlay | Auxiliary Input Panel |
|---|---|---|---|
| styleMask | .nonactivatingPanel, .titled, .closable, .resizable, .fullSizeContentView | .nonactivatingPanel, .hudWindow, .utilityWindow, .fullSizeContentView | .nonactivatingPanel, .titled, .fullSizeContentView |
| level | .floating | .statusBar | .floating |
| becomesKeyOnlyIfNeeded | true | true | false (needs keyboard) |
| hidesOnDeactivate | false | true | false |
| collectionBehavior | .canJoinAllSpaces, .fullScreenAuxiliary | .canJoinAllSpaces, .stationary | .canJoinAllSpaces, .fullScreenAuxiliary |
| hasShadow | true | false | true |
| titlebarAppearsTransparent | true | true | true |
| Use case | Xcode-style property inspector | Translucent status display | Search bar, quick input |
Creating the Panel
The base pattern for any floating panel:
import AppKit
import SwiftUI
final class FloatingPanel: NSPanel {
init(contentView: some View) {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 400),
styleMask: [.nonactivatingPanel, .titled, .closable,
.resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
titlebarAppearsTransparent = true
titleVisibility = .hidden
isFloatingPanel = true
level = .floating
hidesOnDeactivate = false
isMovableByWindowBackground = true
becomesKeyOnlyIfNeeded = true
animationBehavior = .utilityWindow
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
self.contentView = NSHostingView(rootView: contentView)
}
}
That gives you a panel that floats above other windows, does not steal focus, follows the user across Spaces, and hosts any SwiftUI view.
SwiftUI Integration With NSHostingView
The connection between SwiftUI and the panel is NSHostingView. It takes any SwiftUI View and renders it as an AppKit view that can be set as the panel's contentView.
struct InspectorView: View {
@State private var opacity: Double = 1.0
@State private var selectedTab = 0
var body: some View {
VStack(spacing: 0) {
Picker("Section", selection: $selectedTab) {
Text("Style").tag(0)
Text("Layout").tag(1)
Text("Data").tag(2)
}
.pickerStyle(.segmented)
.padding()
Divider()
ScrollView {
// Inspector content here
}
}
.frame(minWidth: 280, idealWidth: 320, maxWidth: 400)
.frame(minHeight: 300)
}
}
The frame modifiers on the root view control the panel's resize constraints. NSHostingView communicates these back to the window, so minWidth and minHeight become the panel's minimum size.
Lifecycle Architecture
The panel lifecycle has four states. Here is how they connect:
- Hidden: panel is allocated but not on screen. Call
orderFrontRegardless()to show it. - Visible: panel is on screen but not key. The user can see it and click controls, but keyboard input goes to the frontmost app.
- Key: panel is key window. Text fields and keyboard shortcuts work. Entered by calling
makeKey()or when the user clicks a control that needs keyboard input. - Disposed: panel is closed and should be deallocated. Listen for
NSWindow.willCloseNotificationto clean up references.
Managing Focus and Activation
The most common mistake with floating panels is getting the activation policy wrong. Here are the rules:
Do not call NSApp.activate(ignoringOtherApps: true) when showing the panel. This activates your entire app, which defeats the purpose of a non-activating panel. The user's current app loses focus.
Call panel.makeKey() only when you need keyboard input. For an inspector panel that the user clicks buttons in, becomesKeyOnlyIfNeeded = true handles this automatically. For a panel with a search field that should be ready to type immediately, call makeKey() after positioning.
Handle the text field focus dance. When a user clicks a TextField in the panel but the panel is not key, the click registers but the text field does not become first responder. Wrap the text field:
struct FocusableTextField: View {
@Binding var text: String
@FocusState private var isFocused: Bool
var body: some View {
TextField("Search...", text: $text)
.focused($isFocused)
.onTapGesture {
// Make the panel key so the text field can receive input
NSApp.keyWindow?.makeKey()
isFocused = true
}
}
}
Responding to Panel Resizes
When the user resizes the panel, the SwiftUI content adapts automatically through NSHostingView. But if you need to persist the frame or constrain the aspect ratio, observe window notifications:
NotificationCenter.default.addObserver(
forName: NSWindow.didEndLiveResizeNotification,
object: panel,
queue: .main
) { _ in
let frame = panel.frame
UserDefaults.standard.set(
NSStringFromRect(frame),
forKey: "floatingPanelFrame"
)
}
Restore the frame on next launch, but only if the saved frame is still within a visible screen:
if let saved = UserDefaults.standard.string(forKey: "floatingPanelFrame") {
let frame = NSRectFromString(saved)
let onScreen = NSScreen.screens.contains { screen in
screen.visibleFrame.intersects(frame)
}
if onScreen {
panel.setFrame(frame, display: false)
}
}
Positioning Strategies
Where the panel appears depends on its purpose:
Anchored to a control. Convert the control's frame to screen coordinates and place the panel relative to it. This is the pattern for menu bar panels and toolbar popovers.
Centered on the active screen. For panels the user invokes with a keyboard shortcut:
func centerOnActiveScreen() {
guard let screen = NSScreen.main ?? NSScreen.screens.first else { return }
let screenFrame = screen.visibleFrame
let x = screenFrame.midX - panel.frame.width / 2
let y = screenFrame.midY - panel.frame.height / 2
panel.setFrameOrigin(NSPoint(x: x, y: y))
}
Restored from last position. Covered in the resize section above.
Click Outside to Dismiss
For panels that should dismiss when the user clicks elsewhere, use a global event monitor:
private var clickMonitor: Any?
func show() {
panel.orderFrontRegardless()
clickMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown]
) { [weak self] _ in
self?.dismiss()
}
}
func dismiss() {
panel.orderOut(nil)
if let monitor = clickMonitor {
NSEvent.removeMonitor(monitor)
clickMonitor = nil
}
}
Not every floating panel needs this. An inspector panel should stay open until the user explicitly closes it. A quick-input panel or HUD should dismiss on outside click.
Troubleshooting Common Issues
Panel appears behind other windows. Verify level is set to .floating or higher, and that isFloatingPanel = true is set. On macOS Sonoma and later, also check that collectionBehavior includes .fullScreenAuxiliary if the user is in full screen mode.
Panel disappears when switching Spaces. Add .canJoinAllSpaces to collectionBehavior. Without it, the panel is bound to the Space it was created on.
Text field does not accept input. The panel is not key. Either set becomesKeyOnlyIfNeeded = false (if the panel always needs keyboard input) or call panel.makeKey() when showing it.
SwiftUI animations stutter during panel resize. NSHostingView recalculates layout on every frame during live resize. Use .fixedSize() on expensive subviews or wrap them in LazyVStack to reduce layout cost.
Panel flashes white on first appearance. Set panel.backgroundColor = .clear and panel.isOpaque = false if you want a custom background. Or set animationBehavior = .utilityWindow for a subtle fade instead of the default animation.
When to Use a Floating Panel
Floating panels work well for:
- Inspectors and property editors that the user references while working in the main window
- Quick input fields like Spotlight-style search or command palettes
- Status displays and HUDs that show real-time information without interrupting workflow
- Tool palettes for drawing, editing, or design apps
- Reference content that the user wants visible alongside their primary work
They are the wrong choice for modal dialogs (use NSAlert or .sheet), preferences (use Settings scene), or content that needs full window management (use a regular NSWindow).
Wrapping Up
The core recipe for a SwiftUI floating panel on macOS: subclass NSPanel with .nonactivatingPanel, set becomesKeyOnlyIfNeeded and .floating level, host SwiftUI via NSHostingView, and configure collectionBehavior for multi-Space support. The rest (focus handling, positioning, dismiss behavior, frame persistence) layers on top of that foundation.
Fazm uses floating panels for its AI agent interface on macOS. Open source on GitHub.