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
// 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)
    }