Build macOS Notch Overlays with Notchy: Dynamic Island UI Guide

2026-03-30 · 12 min read · gen:4m 8s · tok:32125
#macos #swift #appkit #notchy #pimcore #intermediate-tutorial #english

Learn to create Dynamic Island-style notch overlays for macOS apps using Notchy framework. Master AppKit screen geometry, animated widgets, and notch detection.

Building Custom Notch Overlays for macOS Apps with Notchy: A Deep Dive into Dynamic Island-Style UI

Apple’s MacBook Pro notch created an interesting design challenge: a hardware element that interrupts the traditional rectangular display. While many developers treat it as dead space, the Dynamic Island on iPhone proved that this “problem area” can become a feature. Notchy brings this concept to macOS, letting you build notch-aware overlays that display build status, system metrics, or media controls—without fighting the hardware.

This tutorial walks you through building a production-ready notch overlay system using AppKit’s screen geometry APIs. You’ll implement notch detection, create animated widgets, and handle the edge cases that trip up most implementations.

Prerequisites

Before diving in, ensure you have:

  • macOS 12.0+ (Monterey or later for notch API support)
  • Xcode 14+ with Swift 5.7
  • A MacBook Pro with notch (14" or 16" models from 2021+), though we’ll handle notch-less Macs
  • Familiarity with AppKit basics (NSWindow, NSView, NSScreen)
  • Understanding of Swift concurrency (async/await)

💡 If you’re testing on a notch-less Mac, the code includes simulation mode for development.

Install the Notchy framework via Swift Package Manager:

1
2
3
4
// Package.swift
dependencies: [
    .package(url: "https://github.com/your-org/notchy.git", from: "1.0.0")
]

Architecture and Key Concepts

Notchy uses a layered architecture that separates screen geometry detection from widget rendering. This separation allows widgets to remain agnostic about display configuration while the positioning layer handles hardware-specific calculations.

flowchart TD
    subgraph Detection["Screen Detection Layer"]
        A[NSScreen Observer] --> B{Has Notch?}
        B -->|Yes| C[NotchGeometry Calculator]
        B -->|No| D[Fallback Menu Bar Position]
        C --> E[Safe Area Insets]
        D --> E
    end
    
    subgraph Positioning["Window Positioning Layer"]
        E --> F[NotchWindowController]
        F --> G[Anchor Point Calculator]
        G --> H[Animation Coordinator]
    end
    
    subgraph Widgets["Widget Layer"]
        H --> I[Widget Container]
        I --> J[Build Status Widget]
        I --> K[System Metrics Widget]
        I --> L[Media Controls Widget]
    end
    
    subgraph EdgeCases["Edge Case Handlers"]
        M[External Display Monitor] --> F
        N[Screen Recording Detector] --> I
        O[Display Configuration Change] --> A
    end

The three core concepts you need to understand:

  1. NotchGeometry: Represents the physical notch dimensions and position relative to screen coordinates
  2. NotchWindow: A borderless, floating window that anchors to the notch area
  3. NotchWidget: A protocol-conforming view that renders content and handles its own update cycle

📝 AppKit’s NSScreen.safeAreaInsets (macOS 12+) provides notch dimensions, but we need additional calculations for pixel-perfect positioning.

Step-by-Step Implementation

Detecting Notch Geometry Across Display Configurations

The foundation of any notch overlay is accurate geometry detection. We need to handle three scenarios: built-in display with notch, built-in display without notch, and external displays.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
// NotchGeometry.swift
import AppKit

public struct NotchGeometry: Equatable, Sendable {
    public let frame: NSRect
    public let screenFrame: NSRect
    public let safeAreaInsets: NSEdgeInsets
    public let isSimulated: Bool
    
    /// The usable width on each side of the notch
    public var leftSideWidth: CGFloat {
        frame.minX - screenFrame.minX
    }
    
    public var rightSideWidth: CGFloat {
        screenFrame.maxX - frame.maxX
    }
    
    /// Center point for anchoring overlays
    public var anchorPoint: NSPoint {
        NSPoint(
            x: frame.midX,
            y: frame.maxY - safeAreaInsets.top
        )
    }
}

public final class NotchDetector: @unchecked Sendable {
    public static let shared = NotchDetector()
    
    private var displayConfigurationObserver: Any?
    private let notificationCenter = NotificationCenter.default
    
    // Callback for geometry changes
    public var onGeometryChange: ((NotchGeometry?) -> Void)?
    
    private init() {
        setupDisplayObserver()
    }
    
    /// Detects notch geometry for a given screen
    public func detectNotch(for screen: NSScreen) -> NotchGeometry? {
        let safeAreaInsets = screen.safeAreaInsets
        
        // A notch exists if the top safe area inset is significant
        // MacBook Pro notch creates approximately 38pt inset
        guard safeAreaInsets.top > 0 else {
            return nil
        }
        
        let screenFrame = screen.frame
        
        // Calculate notch frame from safe area insets
        // The notch width is derived from the difference between
        // screen width and the sum of left/right safe areas
        let notchWidth = calculateNotchWidth(screen: screen)
        let notchHeight = safeAreaInsets.top
        
        let notchFrame = NSRect(
            x: screenFrame.midX - (notchWidth / 2),
            y: screenFrame.maxY - notchHeight,
            width: notchWidth,
            height: notchHeight
        )
        
        return NotchGeometry(
            frame: notchFrame,
            screenFrame: screenFrame,
            safeAreaInsets: safeAreaInsets,
            isSimulated: false
        )
    }
    
    /// Finds the primary display with a notch
    public func primaryNotchScreen() -> NSScreen? {
        // The built-in display is typically the one with a notch
        NSScreen.screens.first { screen in
            screen.safeAreaInsets.top > 0 && isBuiltInDisplay(screen)
        }
    }
    
    private func calculateNotchWidth(screen: NSScreen) -> CGFloat {
        // Apple's notch dimensions are consistent across models
        // 14" and 16" MacBook Pro: approximately 180pt wide
        let auxiliaryTopLeftArea = screen.auxiliaryTopLeftArea
        let auxiliaryTopRightArea = screen.auxiliaryTopRightArea
        
        if let leftArea = auxiliaryTopLeftArea,
           let rightArea = auxiliaryTopRightArea {
            // Notch width = screen width - left area - right area
            return screen.frame.width - leftArea.width - rightArea.width
        }
        
        // Fallback for older APIs
        return 180.0
    }
    
    private func isBuiltInDisplay(_ screen: NSScreen) -> Bool {
        // CGDisplayIsBuiltin requires the display ID
        guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else {
            return false
        }
        return CGDisplayIsBuiltin(screenNumber) != 0
    }
    
    private func setupDisplayObserver() {
        displayConfigurationObserver = notificationCenter.addObserver(
            forName: NSApplication.didChangeScreenParametersNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            self?.handleDisplayConfigurationChange()
        }
    }
    
    private func handleDisplayConfigurationChange() {
        let geometry = primaryNotchScreen().flatMap { detectNotch(for: $0) }
        onGeometryChange?(geometry)
    }
    
    deinit {
        if let observer = displayConfigurationObserver {
            notificationCenter.removeObserver(observer)
        }
    }
}

⚠️ auxiliaryTopLeftArea and auxiliaryTopRightArea are available in macOS 12.0+. Always check for their availability when supporting older systems.

Creating the Notch-Anchored Window Controller

With geometry detection in place, we need a window controller that positions a transparent, borderless window precisely at the notch location. This window serves as the container for all widgets.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
// NotchWindowController.swift
import AppKit
import Combine

public final class NotchWindowController: NSWindowController {
    
    private var geometry: NotchGeometry?
    private var cancellables = Set<AnyCancellable>()
    private let animationDuration: TimeInterval = 0.3
    
    // Widget container view
    private lazy var containerView: NotchContainerView = {
        let view = NotchContainerView()
        view.wantsLayer = true
        view.layer?.cornerRadius = 12
        view.layer?.masksToBounds = true
        return view
    }()
    
    public init() {
        // Create a borderless, transparent window
        let window = NotchWindow(
            contentRect: NSRect(x: 0, y: 0, width: 300, height: 44),
            styleMask: [.borderless, .nonactivatingPanel],
            backing: .buffered,
            defer: false
        )
        
        super.init(window: window)
        configureWindow()
        setupGeometryObserver()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func configureWindow() {
        guard let window = window as? NotchWindow else { return }
        
        // Window configuration for overlay behavior
        window.isOpaque = false
        window.backgroundColor = .clear
        window.level = .statusBar + 1  // Above menu bar, below alerts
        window.collectionBehavior = [
            .canJoinAllSpaces,      // Visible on all spaces
            .stationary,            // Doesn't move with space switches
            .ignoresCycle           // Command-Tab ignores it
        ]
        window.hasShadow = true
        window.contentView = containerView
        
        // Ensure the window doesn't become key and steal focus
        window.hidesOnDeactivate = false
    }
    
    private func setupGeometryObserver() {
        NotchDetector.shared.onGeometryChange = { [weak self] geometry in
            self?.updatePosition(with: geometry)
        }
        
        // Initial positioning
        if let screen = NotchDetector.shared.primaryNotchScreen() {
            let initialGeometry = NotchDetector.shared.detectNotch(for: screen)
            updatePosition(with: initialGeometry)
        }
    }
    
    /// Updates window position based on new geometry
    public func updatePosition(with geometry: NotchGeometry?) {
        self.geometry = geometry
        
        guard let window = window,
              let geometry = geometry else {
            // No notch available - hide or use fallback position
            handleNoNotchScenario()
            return
        }
        
        // Calculate window frame anchored below the notch
        let windowSize = calculateWindowSize(for: geometry)
        let windowOrigin = calculateWindowOrigin(for: geometry, size: windowSize)
        
        let targetFrame = NSRect(origin: windowOrigin, size: windowSize)
        
        NSAnimationContext.runAnimationGroup { context in
            context.duration = animationDuration
            context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
            window.animator().setFrame(targetFrame, display: true)
        }
    }
    
    private func calculateWindowSize(for geometry: NotchGeometry) -> NSSize {
        // Window should be slightly wider than the notch for visual balance
        let baseWidth = geometry.frame.width + 40
        let baseHeight: CGFloat = 44
        
        // Adjust based on widget content
        let contentSize = containerView.intrinsicContentSize
        return NSSize(
            width: max(baseWidth, contentSize.width),
            height: max(baseHeight, contentSize.height)
        )
    }
    
    private func calculateWindowOrigin(for geometry: NotchGeometry, size: NSSize) -> NSPoint {
        // Center horizontally relative to the notch
        let x = geometry.anchorPoint.x - (size.width / 2)
        
        // Position just below the notch (accounting for the notch height)
        // Small offset (2pt) prevents visual collision with notch edge
        let y = geometry.screenFrame.maxY - geometry.safeAreaInsets.top - size.height + 2
        
        return NSPoint(x: x, y: y)
    }
    
    private func handleNoNotchScenario() {
        // Fallback: position in center-top of menu bar area
        guard let window = window,
              let screen = NSScreen.main else { return }
        
        let menuBarHeight: CGFloat = NSStatusBar.system.thickness
        let windowSize = NSSize(width: 300, height: 44)
        
        let x = screen.frame.midX - (windowSize.width / 2)
        let y = screen.frame.maxY - menuBarHeight - windowSize.height - 4
        
        window.setFrame(NSRect(origin: NSPoint(x: x, y: y), size: windowSize), display: true)
    }
    
    /// Animates the overlay expansion (Dynamic Island style)
    public func expandOverlay(to size: NSSize, animated: Bool = true) {
        guard let window = window, let geometry = geometry else { return }
        
        let origin = calculateWindowOrigin(for: geometry, size: size)
        let targetFrame = NSRect(origin: origin, size: size)
        
        if animated {
            NSAnimationContext.runAnimationGroup { context in
                context.duration = animationDuration
                context.timingFunction = CAMediaTimingFunction(
                    controlPoints: 0.34, 1.56, 0.64, 1  // Spring-like curve
                )
                window.animator().setFrame(targetFrame, display: true)
            }
        } else {
            window.setFrame(targetFrame, display: true)
        }
    }
    
    /// Collapses overlay back to compact size
    public func collapseOverlay(animated: Bool = true) {
        guard let geometry = geometry else { return }
        let compactSize = NSSize(width: geometry.frame.width + 40, height: 44)
        expandOverlay(to: compactSize, animated: animated)
    }
}

// MARK: - NotchWindow Subclass

private final class NotchWindow: NSPanel {
    
    override var canBecomeKey: Bool { false }
    override var canBecomeMain: Bool { false }
    
    override func accessibilityRole() -> NSAccessibility.Role? {
        .popover
    }
}

💡 Using NSPanel instead of NSWindow gives you better control over activation behavior. The nonactivatingPanel style mask prevents the overlay from stealing focus from other apps.

Building the Modular Widget Architecture

The widget system needs to be modular enough that developers can create custom widgets while maintaining consistent update patterns and lifecycle management. We’ll use a protocol-based approach with async update streams.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
// NotchWidget.swift
import AppKit
import Combine

/// Defines the contract for notch-compatible widgets
public protocol NotchWidget: NSView {
    /// Unique identifier for this widget type
    static var widgetIdentifier: String { get }
    
    /// Preferred size when displayed in compact mode
    var compactSize: NSSize { get }
    
    /// Preferred size when expanded
    var expandedSize: NSSize { get }
    
    /// Current expansion state
    var isExpanded: Bool { get set }
    
    /// Called when the widget becomes visible
    func activate()
    
    /// Called when the widget is hidden or removed
    func deactivate()
    
    /// Publisher for widget state changes that require container updates
    var stateDidChange: AnyPublisher<WidgetStateChange, Never> { get }
}

public enum WidgetStateChange {
    case sizeChanged(compact: NSSize, expanded: NSSize)
    case requestExpansion
    case requestCollapse
    case contentUpdated
}

// MARK: - Widget Container

public final class NotchContainerView: NSView {
    
    private var activeWidgets: [any NotchWidget] = []
    private var cancellables = Set<AnyCancellable>()
    private let stackView: NSStackView
    
    public var onExpansionRequested: ((Bool) -> Void)?
    
    public override init(frame frameRect: NSRect) {
        stackView = NSStackView()
        stackView.orientation = .horizontal
        stackView.spacing = 8
        stackView.alignment = .centerY
        stackView.distribution = .fillProportionally
        
        super.init(frame: frameRect)
        
        setupStackView()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupStackView() {
        stackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(stackView)
        
        NSLayoutConstraint.activate([
            stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
            stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
            stackView.topAnchor.constraint(equalTo: topAnchor, constant: 6),
            stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6)
        ])
    }
    
    /// Adds a widget to the container
    public func addWidget(_ widget: some NotchWidget) {
        activeWidgets.append(widget)
        stackView.addArrangedSubview(widget)
        
        // Subscribe to widget state changes
        widget.stateDidChange
            .receive(on: DispatchQueue.main)
            .sink { [weak self] change in
                self?.handleWidgetStateChange(change, from: widget)
            }
            .store(in: &cancellables)
        
        widget.activate()
        invalidateIntrinsicContentSize()
    }
    
    /// Removes a widget from the container
    public func removeWidget(_ widget: some NotchWidget) {
        widget.deactivate()
        stackView.removeArrangedSubview(widget)
        widget.removeFromSuperview()
        
        activeWidgets.removeAll { $0 === widget }
        invalidateIntrinsicContentSize()
    }
    
    private func handleWidgetStateChange(_ change: WidgetStateChange, from widget: some NotchWidget) {
        switch change {
        case .requestExpansion:
            onExpansionRequested?(true)
        case .requestCollapse:
            onExpansionRequested?(false)
        case .sizeChanged, .contentUpdated:
            invalidateIntrinsicContentSize()
        }
    }
    
    public override var intrinsicContentSize: NSSize {
        // Calculate total size based on active widgets
        let isExpanded = activeWidgets.contains { $0.isExpanded }
        
        let widgetSizes = activeWidgets.map { widget in
            isExpanded ? widget.expandedSize : widget.compactSize
        }
        
        let totalWidth = widgetSizes.reduce(0) { $0 + $1.width }
            + CGFloat(max(0, widgetSizes.count - 1)) * stackView.spacing
            + 24  // Horizontal padding
        
        let maxHeight = widgetSizes.map(\.height).max() ?? 32
        
        return NSSize(width: totalWidth, height: maxHeight + 12)
    }
}

// MARK: - Build Status Widget Implementation

public final class BuildStatusWidget: NSView, NotchWidget {
    
    public static let widgetIdentifier = "com.notchy.widget.buildStatus"
    
    public var compactSize: NSSize { NSSize(width: 120, height: 32) }
    public var expandedSize: NSSize { NSSize(width: 280, height: 80) }
    
    public var isExpanded: Bool = false {
        didSet {
            updateLayout()
        }
    }
    
    private let stateSubject = PassthroughSubject<WidgetStateChange, Never>()
    public var stateDidChange: AnyPublisher<WidgetStateChange, Never> {
        stateSubject.eraseToAnyPublisher()
    }
    
    // UI Components
    private let statusIndicator = NSView()
    private let statusLabel = NSTextField(labelWithString: "")
    private let detailLabel = NSTextField(labelWithString: "")
    private let progressIndicator = NSProgressIndicator()
    
    // State
    private var currentStatus: BuildStatus = .idle
    
    public enum BuildStatus: Equatable {
        case idle
        case building(progress: Double, target: String)
        case success(duration: TimeInterval)
        case failed(error: String)
    }
    
    public override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupUI() {
        wantsLayer = true
        layer?.cornerRadius = 8
        layer?.backgroundColor = NSColor.black.withAlphaComponent(0.8).cgColor
        
        // Status indicator (colored dot)
        statusIndicator.wantsLayer = true
        statusIndicator.layer?.cornerRadius = 5
        statusIndicator.translatesAutoresizingMaskIntoConstraints = false
        
        // Labels
        statusLabel.font = .systemFont(ofSize: 12, weight: .medium)
        statusLabel.textColor = .white
        statusLabel.translatesAutoresizingMaskIntoConstraints = false
        
        detailLabel.font = .systemFont(ofSize: 10)
        detailLabel.textColor = .secondaryLabelColor
        detailLabel.translatesAutoresizingMaskIntoConstraints = false
        detailLabel.isHidden = true
        
        // Progress indicator
        progressIndicator.style = .bar
        progressIndicator.isIndeterminate = false
        progressIndicator.translatesAutoresizingMaskIntoConstraints = false
        progressIndicator.isHidden = true
        
        addSubview(statusIndicator)
        addSubview(statusLabel)
        addSubview(detailLabel)
        addSubview(progressIndicator)
        
        setupConstraints()
        updateStatus(.idle)
    }
    
    private func setupConstraints() {
        NSLayoutConstraint.activate([
            statusIndicator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
            statusIndicator.centerYAnchor.constraint(equalTo: centerYAnchor),
            statusIndicator.widthAnchor.constraint(equalToConstant: 10),
            statusIndicator.heightAnchor.constraint(equalToConstant: 10),
            
            statusLabel.leadingAnchor.constraint(equalTo: statusIndicator.trailingAnchor, constant: 6),
            statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8),
            statusLabel.centerYAnchor.constraint(equalTo: centerYAnchor)
        ])
    }
    
    private func updateLayout() {
        detailLabel.isHidden = !isExpanded
        progressIndicator.isHidden = !isExpanded || !currentStatus.isBuilding
        
        stateSubject.send(.contentUpdated)
    }
    
    // MARK: - Public API
    
    func updateStatus(_ status: BuildStatus) {
        currentStatus = status
        statusIndicator.layer?.backgroundColor = status.tintColor.cgColor
        statusLabel.stringValue = status.title
        detailLabel.stringValue = status.detail ?? ""
        
        if status.isBuilding {
            progressIndicator.doubleValue = status.progress * 100
        }
        
        updateLayout()
    }
    
    func setExpanded(_ expanded: Bool, animated: Bool = true) {
        guard expanded != isExpanded else { return }
        isExpanded = expanded
        
        NSAnimationContext.runAnimationGroup { context in
            context.duration = animated ? 0.22 : 0
            context.timingFunction = CAMediaTimingFunction(name: .easeOut)
            updateLayout()
            layoutSubtreeIfNeeded()
        }
    }
    
    /// Called by the registry when the widget is removed from the notch stack.
    func teardown() {
        cancellables.removeAll()
        stateSubject.send(completion: .finished)
        removeFromSuperview()
    }
}

The widget exposes a stateSubject publisher; the window controller subscribes to .contentUpdated events and recomputes the notch window’s height so the overlay grows and shrinks with its content:

1
2
3
4
5
6
7
widget.statePublisher
    .receive(on: DispatchQueue.main)
    .sink { [weak self] event in
        guard case .contentUpdated = event else { return }
        self?.resizeWindowToFitContent(animated: true)
    }
    .store(in: &cancellables)

Production Configuration

  • Run as an accessory app. Set LSUIElement to YES in Info.plist so the overlay has no Dock icon and no menu bar — it’s chrome, not an app window.
  • Window level and collection behavior. Keep the notch window at .statusBar level (or .mainMenu + 1) with collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] so it stays put across Spaces and doesn’t appear in Mission Control or `⌘``.
  • Ignore mouse events by default. window.ignoresMouseEvents = true until a widget actually needs interaction, then toggle it per hit-test region — otherwise you steal clicks from whatever is behind the notch.
  • Multi-display. Recompute geometry on NSApplication.didChangeScreenParametersNotification; the built-in notch only exists on the internal display, and external monitors need a synthesized anchor rect.
  • Persist config to ~/Library/Application Support/<App>/config.json, reload on change, and ship sane defaults so a corrupt file can’t brick the overlay.
  • Sign and harden. For distribution outside the App Store, enable the Hardened Runtime and notarize; a background UI process that isn’t notarized will be blocked by Gatekeeper.

Common Mistakes and Troubleshooting

The overlay sits a few pixels off the notch. You used NSScreen.frame instead of safeAreaInsets / auxiliaryTopLeftArea & auxiliaryTopRightArea. The notch width is the gap between those two auxiliary areas, not a hard-coded constant.

Nothing appears on Macs without a notch. That’s expected for the physical notch, but if you want the overlay everywhere, synthesize an anchor rect centered on screen.frame.midX with your design width and fall back to that path.

The window disappears when switching Spaces. Missing .canJoinAllSpaces in collectionBehavior, or the window was created before the screen was available.

Clicks pass through when they shouldn’t (or vice versa). ignoresMouseEvents is global to the window. Use a content view that overrides hitTest(_:) to return nil outside interactive regions.

Animations stutter under load. You’re animating Auto Layout constraints on the main thread while a build floods status updates. Coalesce updates (throttle on the Combine publisher, ~16 ms) and animate a single height constraint, not a dozen.

Overlay is clipped by full-screen apps. Full-screen Spaces raise the window level bar. Use .screenSaver level if the overlay must survive full-screen video, and test with a real full-screen app.

Performance and Scalability

  • Throttle status input. A CI build or file watcher can emit hundreds of events/second; .throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true) keeps the UI at display cadence.
  • Animate one constraint. Drive the expand/collapse from a single heightAnchor constant with NSAnimationContext; layer-backed views make this cheap.
  • Layer-back everything. wantsLayer = true on the container so AppKit composites on the GPU instead of redrawing the view tree.
  • Cap the frame rate. A frameRateLimit (e.g. 120 on ProMotion, 60 otherwise) avoids spinning a CVDisplayLink faster than the panel.
  • One window, many widgets. The registry stacks widgets in a single borderless window; don’t create a window per widget.
  • Idle to zero. When no widget has content, hide the window (orderOut) rather than keeping a transparent 0-height window alive.

Conclusion and Next Steps

You now have the three pieces of a notch overlay: geometry detection that survives multi-display and notch-less Macs, a borderless always-on-top window anchored to the notch, and a modular widget registry whose contents resize the window through a Combine publisher. The design keeps AppKit window management separate from widget content, so adding a new widget is a NotchWidget conformance, not a window rewrite.

Next steps:

  • Add hover-to-expand and a click-through hit-test layer for interactive widgets.
  • Give widgets a priority so the most important one wins the limited notch width.
  • Add a preferences window for per-widget enable/disable and position.
  • Package as a Swift Package so other apps can embed the overlay.
  • Study the real Dynamic Island transitions and approximate the spring curves with CASpringAnimation.

Additional Resources