SwiftUI · macOS 14+ · MIT
An island rests as a pill against the screen edge and morphs open on hover or click — one shape whose width, height, and corner radii animate together. You write two SwiftUI views. NotchKit owns the window, the silhouette, hit testing, pointer hysteresis, and the motion.
.package(url: "https://github.com/duongductrong/NotchKit.git", from: "1.3.0")The island above is live — hover it, click it, or
The idea
NotchShape — the collapsed pill is that same shape at a small top corner radius, flaring into the bezel just like the open panel, only less. Hover the elements to see where each one lives on the shape, and tune the knobs to feel the parameters.The entry point
| Parameter | Type | Default | Description |
|---|---|---|---|
| configuration | NotchConfiguration | .standard | Everything tunable about the island, in one value type. Mutable at runtime. |
| motion | NotchMotion? | .resolved() | Animation vocabulary. nil resolves Reduce Motion automatically. |
| style | NotchStyle | .standard | Ink, hairline, shadow, foreground. Independent of motion and placement. |
| preferredScreenID | String? | nil | Pin to a display by NSScreen.notch_stableID. nil picks the notched screen, then main. |
| install(collapsed:expanded:) | Void | — | Builds the window and shows the collapsed island. Both views are wrapped in the silhouette, hairline, and shadow for you. |
| expand(reason:) | Void | — | Opens the panel. Only .click takes key status — a hover never steals your keystrokes. |
| collapse() | Void | — | Closes the panel. The window stays; only interactivity is withdrawn. |
| toggle() | Void | — | Expands or collapses. |
| peek() | Void | — | A brief scale bump, then back to collapsed. For “something happened” without taking over the screen. |
| uninstall() | Void | — | Tears down the window and all monitors. |
let presenter = NotchPresenter(
configuration: .standard,
motion: .resolved(),
style: .standard
)
presenter.install {
// collapsed pill content
} expanded: {
// panel content
}
presenter.peek()Configuration
| Parameter | Type | Default | Description |
|---|---|---|---|
| expandedSize | CGSize | 540 × 260 | Size of the expanded panel's content, excluding shadow insets. Also the window size — pick the largest panel you will show. |
| collapsedWidth | NotchCollapsedWidth | .wrapCutout(reserve: 44) | How wide the collapsed pill is drawn. Separate from the hit target on purpose. |
| collapsedHitPadding | CGFloat | 6 | Invisible hit-target margin around the collapsed pill. Users aim at the notch, not your pill. |
| shadowInsetHorizontal | CGFloat | 18 | Transparent margin reserved inside the window for the SwiftUI-drawn shadow. |
| shadowInsetBottom | CGFloat | 22 | Bottom shadow room. AppKit's own shadow would box the window, so the panel draws its own. |
| expandedTopCornerRadius | CGFloat | 22 | Concave top curl of the expanded panel. |
| expandedBottomCornerRadius | CGFloat | 22 | Convex bottom round of the expanded panel. |
| expandedContentInsetsOverride | EdgeInsets? | nil | nil derives insets guaranteed to clear the silhouette — including the taper below the top curl. |
| expandedTopReserve | NotchExpandedTopReserve | .cutoutOnly | How much of the panel's top stays clear of the hardware cutout. |
| expandedContentAlignment | Alignment | .top | Content pinned to the top does not appear to slide while the surface is still growing. |
| expandsOnHover | Bool | true | Open on hover, not just click. |
| hoverOpenDelay | TimeInterval | 0.15 | Filters pointers merely transiting to the menu bar. |
| hoverCancelGrace | TimeInterval | 0.10 | Hysteresis for pointer jitter at the cutout edge. What separates solid from haunted. |
| collapsesOnPointerExit | Bool | true | Hover-opened islands close when the pointer leaves. Click-opened ones ignore this. |
| collapsesOnOutsideClick | Bool | true | Close on outside click, and forward that click to whatever was underneath. |
| hapticOnHoverOpen | Bool | true | Light tap on Force Touch trackpads when a hover opens the island. No-op elsewhere. |
| pointerSampleInterval | TimeInterval | 0.05 | 20Hz pointer sampling — imperceptible for hit testing, invisible in Activity Monitor. |
// Presets
.standard // hover-to-open, medium panel
.clickOnly // controls users must not open by accident
.statusOnly // thin strip; peek and collapsed only
.canvas // full-bleed content, no padding
.standalone(pillWidth: 220) // no hardware cutoutLayout
| Parameter | Type | Default | Description |
|---|---|---|---|
| cutoutWidth | CGFloat | — | Width of the hardware cutout to reserve. Pass 0 on plain displays — it becomes an ordinary bar. |
| gutterWidth | CGFloat | — | Usable width on each side of the cutout. Pass presenter.collapsedGutterWidth rather than recomputing. |
| pillHeight | CGFloat | — | Height of the pill. Used to derive a provably safe edgeInset. |
| edgeInset | CGFloat? | nil | nil derives pillHeight / 2 — the smallest inset that is safe for content of any height. |
| alignment | VerticalAlignment | .center | .firstTextBaseline when the two sides hold text at different sizes. |
| leading / trailing | @ViewBuilder | — | Content either side of the cutout. The middle sits behind hardware — anything there is invisible. |
The middle of a pill sits behind the hardware — invisible only on notched Macs, which is how it survives development on an external monitor.
NotchCutoutLayout(
cutoutWidth: presenter.geometry.hasPhysicalNotch
? presenter.geometry.notchWidth : 0,
gutterWidth: presenter.collapsedGutterWidth,
pillHeight: presenter.geometry.collapsedHeight
) {
NotchBars(.wave())
} trailing: {
Text("3").monospacedDigit()
}Motion
| Parameter | Type | Default | Description |
|---|---|---|---|
| expand | Animation | spring(0.42, 0.80) | Collapsed → expanded. A spring: the panel is arriving, a touch of overshoot feels physical. |
| collapse | Animation | smooth(0.30) | Monotonic ease. A spring on the way out reads as the UI arguing. |
| peek | Animation | spring(0.30, 0.50) | The attention bump. Bouncy by design. |
| hover | Animation | spring(0.38, 0.80) | Hover scale on the collapsed pill. |
| contentMorph | Animation | timingCurve(0.45) | Content changing inside an open panel. Content should not spring — overshoot on text is hard to read. |
| highlight | Animation | easeInOut(0.15) | Small state flips: selection, checkmarks. |
| contentRevealDuration | TimeInterval | 0.22 | How long incoming content takes to fade up. |
| contentRevealDelay | TimeInterval | 0.08 | Head start given to the shape before content appears, so text never renders squeezed into a sliver. |
| contentHideDuration | TimeInterval | 0.12 | Quicker than the reveal: content must be gone before the shape closes over it. |
| expandedUnmountDelay | TimeInterval | 0.36 | Must outlast collapse, or the panel flashes empty mid-morph. |
| hoverScale | CGFloat | 1.028 | Tiny for a reason: past ~1.05 the pill visibly clips against the screen edge. |
| peekScale | CGFloat | 1.04 | Scale at the top of a peek. |
| peekDuration | TimeInterval | 0.30 | How long a peek holds before returning to collapsed. |
// Presets
.standard // the tuned defaults
.crisp // faster, flatter, no overshoot
.playful // looser and springier
.reduced // cross-fades only — Reduce Motion
let motion = NotchMotion.resolved()Style
| Parameter | Type | Default | Description |
|---|---|---|---|
| ink | Color | .black | The island body. Pure black is the only value that merges with the hardware — the cutout emits no light. |
| hairline | Color | white 8% | Inner hairline along the silhouette. Invisible on light backgrounds, rescues the edge on dark ones. |
| hairlineWidth | CGFloat | 1 | Width of the inner hairline. |
| shadowColor | Color | black 45% | Drawn in SwiftUI so it follows the concave path instead of boxing the window. Suppressed while collapsed. |
| shadowRadius | CGFloat | 14 | Shadow blur radius. |
| shadowOffsetY | CGFloat | 8 | Shadow vertical offset. |
| foreground | Color | white 96% | Tint for content drawn on the ink. |
| colorScheme | ColorScheme? | .dark | Forced on your content. nil inherits the system — correct only for deliberately light islands. |
.standard
Merges with the hardware.
.warmPaper
Reads as its own object beside the cutout.
.contrast
Stronger edge for busy wallpapers.
.translucent
Great over wallpaper, worse over video.
var style = NotchStyle.standard
style.hairline = Color.white.opacity(0.12)
let paper = NotchStyle.warmPaperIndicators
| Parameter | Type | Default | Description |
|---|---|---|---|
| levels | [CGFloat] | — | Resting height of each bar as a fraction of height, 0...1. The bar count is levels.count — nothing to keep in sync. |
| peaks | [CGFloat]? | nil | Height each bar animates toward. nil leaves the bar static — a resting indicator costs zero animation. |
| barWidth | CGFloat | 2.5 | Width of each bar. |
| spacing | CGFloat | 3 | Gap between bars. |
| cornerRadius | CGFloat? | nil | nil gives fully rounded capsule ends. |
| height | CGFloat | 14 | Height of a bar at level 1, and the view's own height. |
| period | TimeInterval | 0.9 | One full level → peak → level cycle. |
| stagger | TimeInterval | 0.15 | Extra delay per bar. 0 throbs as one object; a small value turns it into a wave. |
| curve | Curve | .easeInOut | .linear, .easeIn, .easeOut, or .easeInOut. |
| tint | Color | .white | Bar color. |
| label | String? | nil | VoiceOver label. nil marks the view purely decorative. |
NotchBars(.steady([0.3, 0.6, 1, 0.45]))
NotchBars(.wave(count: 3, low: 0.35, high: 1))Value types
NotchCollapsedWidth
Wrap the cutout on notched hardware; a fixed width sized to content everywhere else.
NotchExpandedTopReserve
How much of the panel's top stays clear of the cutout. A policy, not a number.
NotchShape / NotchPillShape
The island silhouette. Concave top corners curl inward; the bezel appears to flow into the panel.
NotchGeometry
Where the island lives on one screen. Plain data, recomputed on display changes.
Presets
Examples/NotchDemo is the same NotchPresenter with different configuration and content. Run swift run NotchDemo and move the pointer to the notch.Vibe Code
VibeCodeIsland.swift
AI coding agent with interactive permission prompt cards, subagent status list, and walking pixel cat indicator.
Now playing
NowPlayingIsland.swift
Native macOS Apple Music style player with album artwork, transport controls, and dynamic scrubber.
Morph inspector
MorphInspectorIsland.swift
Interactive geometry morph inspector with live cutout inset, corner radii, and motion tokens visualization.
Quick start
Add the package
File → Add Package Dependencies… in Xcode, or the Package.swift line above.
Install two views
What the pill shows and what the panel shows. Silhouette, hairline, and shadow come free.
Drive it
expand(), collapse(), toggle(), peek() — or just let hover policy do its thing.
import AppKit
import SwiftUI
import NotchKit
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
// Hold this strongly — the presenter owns the window.
private var presenter: NotchPresenter?
func applicationDidFinishLaunching(_ note: Notification) {
let presenter = NotchPresenter()
self.presenter = presenter
presenter.install(
collapsed: {
NotchCutoutLayout(
cutoutWidth: presenter.geometry.hasPhysicalNotch
? presenter.geometry.notchWidth : 0,
gutterWidth: presenter.collapsedGutterWidth,
pillHeight: presenter.geometry.collapsedHeight
) {
Image(systemName: "waveform")
} trailing: {
Text("3").monospacedDigit()
}
},
expanded: {
VStack(alignment: .leading) {
Text("Panel content")
}
}
)
}
}