SwiftUI · macOS 14+ · MIT

Turn the MacBook notch into an interactive Dynamic Island

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.swift
.package(url: "https://github.com/duongductrong/NotchKit.git", from: "1.3.0")

The island above is live — hover it, click it, or

3

The idea

One shape morphs — nothing cross-fades

A cross-fade always reads as a switch: at no instant is there a single object changing form. NotchKit keeps everything to one 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.
Interactive notch island silhouette
3
w 278h 38topR 6.0bottomR 19.0

The entry point

NotchPresenter

The only object you interact with. Create one, install your two views, then drive it with expand / collapse / peek. Keep a strong reference for as long as the island should exist.
ParameterTypeDefaultDescription
configurationNotchConfiguration.standardEverything tunable about the island, in one value type. Mutable at runtime.
motionNotchMotion?.resolved()Animation vocabulary. nil resolves Reduce Motion automatically.
styleNotchStyle.standardInk, hairline, shadow, foreground. Independent of motion and placement.
preferredScreenIDString?nilPin to a display by NSScreen.notch_stableID. nil picks the notched screen, then main.
install(collapsed:expanded:)VoidBuilds the window and shows the collapsed island. Both views are wrapped in the silhouette, hairline, and shadow for you.
expand(reason:)VoidOpens the panel. Only .click takes key status — a hover never steals your keystrokes.
collapse()VoidCloses the panel. The window stays; only interactivity is withdrawn.
toggle()VoidExpands or collapses.
peek()VoidA brief scale bump, then back to collapsed. For “something happened” without taking over the screen.
uninstall()VoidTears 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

NotchConfiguration

Everything tunable about an island, in one value type. Mutable at runtime — anything that changes the window's size repositions it immediately.
ParameterTypeDefaultDescription
expandedSizeCGSize540 × 260Size of the expanded panel's content, excluding shadow insets. Also the window size — pick the largest panel you will show.
collapsedWidthNotchCollapsedWidth.wrapCutout(reserve: 44)How wide the collapsed pill is drawn. Separate from the hit target on purpose.
collapsedHitPaddingCGFloat6Invisible hit-target margin around the collapsed pill. Users aim at the notch, not your pill.
shadowInsetHorizontalCGFloat18Transparent margin reserved inside the window for the SwiftUI-drawn shadow.
shadowInsetBottomCGFloat22Bottom shadow room. AppKit's own shadow would box the window, so the panel draws its own.
expandedTopCornerRadiusCGFloat22Concave top curl of the expanded panel.
expandedBottomCornerRadiusCGFloat22Convex bottom round of the expanded panel.
expandedContentInsetsOverrideEdgeInsets?nilnil derives insets guaranteed to clear the silhouette — including the taper below the top curl.
expandedTopReserveNotchExpandedTopReserve.cutoutOnlyHow much of the panel's top stays clear of the hardware cutout.
expandedContentAlignmentAlignment.topContent pinned to the top does not appear to slide while the surface is still growing.
expandsOnHoverBooltrueOpen on hover, not just click.
hoverOpenDelayTimeInterval0.15Filters pointers merely transiting to the menu bar.
hoverCancelGraceTimeInterval0.10Hysteresis for pointer jitter at the cutout edge. What separates solid from haunted.
collapsesOnPointerExitBooltrueHover-opened islands close when the pointer leaves. Click-opened ones ignore this.
collapsesOnOutsideClickBooltrueClose on outside click, and forward that click to whatever was underneath.
hapticOnHoverOpenBooltrueLight tap on Force Touch trackpads when a hover opens the island. No-op elsewhere.
pointerSampleIntervalTimeInterval0.0520Hz 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 cutout

Layout

NotchCutoutLayout

Lays content on either side of the physical cutout. The dead zone is a fixed-width Color.clear, not a Spacer — a flexible spacer lets content creep under the cutout, and that only shows up on real notched hardware.
ParameterTypeDefaultDescription
cutoutWidthCGFloatWidth of the hardware cutout to reserve. Pass 0 on plain displays — it becomes an ordinary bar.
gutterWidthCGFloatUsable width on each side of the cutout. Pass presenter.collapsedGutterWidth rather than recomputing.
pillHeightCGFloatHeight of the pill. Used to derive a provably safe edgeInset.
edgeInsetCGFloat?nilnil derives pillHeight / 2 — the smallest inset that is safe for content of any height.
alignmentVerticalAlignment.center.firstTextBaseline when the two sides hold text at different sizes.
leading / trailing@ViewBuilderContent either side of the cutout. The middle sits behind hardware — anything there is invisible.
leading
cutout
trailing
← gutterWidth →← gutterWidth →

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

NotchMotion

Opening is a spring and closing is a monotonic ease, on purpose: a spring on the way out means the shape bounces back after the user already dismissed it. .resolved() swaps in .reduced when the system asks for less motion.
ParameterTypeDefaultDescription
expandAnimationspring(0.42, 0.80)Collapsed → expanded. A spring: the panel is arriving, a touch of overshoot feels physical.
collapseAnimationsmooth(0.30)Monotonic ease. A spring on the way out reads as the UI arguing.
peekAnimationspring(0.30, 0.50)The attention bump. Bouncy by design.
hoverAnimationspring(0.38, 0.80)Hover scale on the collapsed pill.
contentMorphAnimationtimingCurve(0.45)Content changing inside an open panel. Content should not spring — overshoot on text is hard to read.
highlightAnimationeaseInOut(0.15)Small state flips: selection, checkmarks.
contentRevealDurationTimeInterval0.22How long incoming content takes to fade up.
contentRevealDelayTimeInterval0.08Head start given to the shape before content appears, so text never renders squeezed into a sliver.
contentHideDurationTimeInterval0.12Quicker than the reveal: content must be gone before the shape closes over it.
expandedUnmountDelayTimeInterval0.36Must outlast collapse, or the panel flashes empty mid-morph.
hoverScaleCGFloat1.028Tiny for a reason: past ~1.05 the pill visibly clips against the screen edge.
peekScaleCGFloat1.04Scale at the top of a peek.
peekDurationTimeInterval0.30How 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

NotchStyle

How the island looks, independent of how it moves. The ink defaults to pure black — not laziness, but the only value that merges with hardware that emits no light.
ParameterTypeDefaultDescription
inkColor.blackThe island body. Pure black is the only value that merges with the hardware — the cutout emits no light.
hairlineColorwhite 8%Inner hairline along the silhouette. Invisible on light backgrounds, rescues the edge on dark ones.
hairlineWidthCGFloat1Width of the inner hairline.
shadowColorColorblack 45%Drawn in SwiftUI so it follows the concave path instead of boxing the window. Suppressed while collapsed.
shadowRadiusCGFloat14Shadow blur radius.
shadowOffsetYCGFloat8Shadow vertical offset.
foregroundColorwhite 96%Tint for content drawn on the ink.
colorSchemeColorScheme?.darkForced on your content. nil inherits the system — correct only for deliberately light islands.
Aa

.standard

Merges with the hardware.

Aa

.warmPaper

Reads as its own object beside the cutout.

Aa

.contrast

Stronger edge for busy wallpapers.

Aa

.translucent

Great over wallpaper, worse over video.

var style = NotchStyle.standard
style.hairline = Color.white.opacity(0.12)

let paper = NotchStyle.warmPaper

Indicators

NotchBars + NotchBarsStyle

Equalizer bars for the collapsed pill. Every visual decision is a value you can build, store, or ship as your own preset. A bar with no peak has no animation object at all — a resting indicator costs zero.
ParameterTypeDefaultDescription
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]?nilHeight each bar animates toward. nil leaves the bar static — a resting indicator costs zero animation.
barWidthCGFloat2.5Width of each bar.
spacingCGFloat3Gap between bars.
cornerRadiusCGFloat?nilnil gives fully rounded capsule ends.
heightCGFloat14Height of a bar at level 1, and the view's own height.
periodTimeInterval0.9One full level → peak → level cycle.
staggerTimeInterval0.15Extra delay per bar. 0 throbs as one object; a small value turns it into a wave.
curveCurve.easeInOut.linear, .easeIn, .easeOut, or .easeInOut.
tintColor.whiteBar color.
labelString?nilVoiceOver label. nil marks the view purely decorative.
.steady
.wave
NotchBars(.steady([0.3, 0.6, 1, 0.45]))

NotchBars(.wave(count: 3, low: 0.35, high: 1))

Value types

Enums that carry the policy

The decisions that are easy to get wrong — cutout wrapping, top reserve, the silhouette — are modelled as small value types, not booleans.

NotchCollapsedWidth

.wrapCutout(reserve:).fixed(_:)

Wrap the cutout on notched hardware; a fixed width sized to content everywhere else.

NotchExpandedTopReserve

.cutoutOnly.always.fixed(_:).none

How much of the panel's top stays clear of the cutout. A policy, not a number.

NotchShape / NotchPillShape

topCornerRadiusbottomCornerRadius

The island silhouette. Concave top corners curl inward; the bezel appears to flow into the panel.

NotchGeometry

collapsedHeightnotchWidthhasPhysicalNotchnotchRect

Where the island lives on one screen. Plain data, recomputed on display changes.

Presets

Three islands, one presenter

Everything in Examples/NotchDemo is the same NotchPresenter with different configuration and content. Run swift run NotchDemo and move the pointer to the notch.
pixel cat3/4 tasks

Vibe Code

VibeCodeIsland.swift

AI coding agent with interactive permission prompt cards, subagent status list, and walking pixel cat indicator.

artwork2:41

Now playing

NowPlayingIsland.swift

Native macOS Apple Music style player with album artwork, transport controls, and dynamic scrubber.

pixel gridmorph

Morph inspector

MorphInspectorIsland.swift

Interactive geometry morph inspector with live cutout inset, corner radii, and motion tokens visualization.

Quick start

Three steps to an island

Configure, install two views, drive it.
1

Add the package

File → Add Package Dependencies… in Xcode, or the Package.swift line above.

2

Install two views

What the pill shows and what the panel shows. Silhouette, hairline, and shadow come free.

3

Drive it

expand(), collapse(), toggle(), peek() — or just let hover policy do its thing.

AppDelegate.swift
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")
                }
            }
        )
    }
}