← all posts

What it takes to make a Mac notch overlay feel native

Apple shipped the Dynamic Island on iPhone in 2022. Four years later, no Mac app has built a notch HUD that actually feels native. Most draw a rounded rectangle near the camera notch and call it a day. The pill floats next to the hardware, not with it, and you can feel the mismatch.

We shipped one for OpenEar that merges with the hardware notch so cleanly you can’t tell where silicon ends and SwiftUI begins. Here’s how.


Five moves that make it feel native

1. Measure the real notch at runtime

The common approach is to hardcode 180pt — the measured width of a 14” MacBook Pro notch from 2021. That breaks on 16” models, on M3 Max, and when Apple eventually changes the cutout. It also breaks on non-notch Macs (Air, Mini, iMac), where the pill floats in dead air.

macOS 12 exposed the notch geometry on NSScreen:

screen.auxiliaryTopLeftArea    // rect of the left "ear" above the menu bar
screen.auxiliaryTopRightArea   // rect of the right "ear"

We read those at startup and on screen changes, compute the notch rect from the gap between them, and scale our overlay to match. On a non-notch Mac, we fabricate a notch-shaped HUD at the exact same menu-bar Y. Same code path.

2. Bézier shoulders, not rounded rectangles

The Mac’s hardware notch isn’t a rounded rectangle. It’s a specific curve with a straight bottom, straight inner walls, and smooth quadratic shoulders where the cutout meets the top edge. A cornerRadius: 12 rectangle looks close and is wrong.

We draw the notch as a Path with explicit quadCurve shoulders at a 10pt offset that mirror the hardware geometry. When dormant, our window is pixel-indistinguishable from the hardware cutout.

We tried cornerRadius first. It took about 30 seconds of staring at the screen to feel the difference — the transition from straight wall to curve was too abrupt, like a cheap phone case that almost fits. The quadratic curve cost us an afternoon of tweaking control points against a screenshot of the real notch. Worth it.

3. Window-level behavior

window.level = .statusBar
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]

.statusBar level floats above every app including fullscreen ones. .stationary + .canJoinAllSpaces pins the window across Mission Control, Spaces, and multi-monitor setups without re-creating it. The common approach is to recreate or re-show the window on space-change events, which causes a flash. With .stationary, the window doesn’t flinch when you swipe between spaces.

One gotcha: .fullScreenAuxiliary is required or the window disappears in fullscreen apps. We missed this initially and spent an hour wondering why the notch vanished in Keynote.

4. Spring physics tuned to iOS feel

Every motion is a SwiftUI spring. We arrived at these values by recording the iOS Dynamic Island in slow motion and matching the curves frame by frame:

Motion Spring
Shape expand response: 0.4, damping: 0.8
Success pop response: 0.2, damping: 0.76
Content fade easeOut(0.2).delay(0.15) (staggered)

The 150ms delay on content fade is the key. Content enters after the shape finishes expanding, so the eye tracks the shape first and the text second. Without the delay, shape and content arrive simultaneously and it feels like a web toast notification.

5. Morphing content, stable shape

The body of the notch walks through four states during a single dictation:

  1. Live waveform (raw audio while recording)
  2. Shimmer placeholder (waiting for the first cleanup token)
  3. Streaming cleaned text (LLM tokens arriving)
  4. Green “Text added” success pulse

The naive approach is to mount and unmount a separate view for each state, which causes the outer pill to jitter as the content resizes. We render the outer shape once and swap contents with opacity inside a single ZStack on a fixed-width frame. The shape never moves. Only the pixels inside change.

This is the single biggest “feels native” lever. Shape stability is what makes the iOS Dynamic Island feel like hardware rather than software. We stole the trick directly.


The small things inside the notch

A few details that don’t deserve their own section but add up:

  • Breathing red dot. A radial-gradient glow whose radius modulates from the live audio meter (shadow(radius: 10 + level*7)). The dot breathes in sync with your voice.
  • Procedural dahlia spinner. During LLM cleanup, we render a 12-petal Bézier dahlia with a bloom animation instead of a system ProgressView.
  • 3D wheel-scroll. When Whisper’s raw output gets replaced by the cleaned version, the old text rolls up and the new text rolls down using rotation3DEffect(perspective: 0.8).

What we got wrong first

The spring table above looks clean. Getting there wasn’t. Our first pass used easeInOut(duration: 0.3) for everything — the macOS default. It looked fine in isolation. Side by side with the iOS Dynamic Island (recorded in slo-mo, played back frame by frame), it looked dead. The shape arrived on time but with no life. No overshoot, no settle, no weight.

The .statusBar window level was our second attempt. We started with .floating, which works until someone opens a system dialog — then the notch drops behind it. .screenSaver was too aggressive (blocks mouse events). .statusBar turned out to be the right layer: above apps, below system alerts, below the actual menu bar.

The 10pt Bézier offset took three iterations. 8pt looked too tight. 12pt looked rounded. 10pt matched the hardware to within a pixel at 2x Retina. We validated by taking a screenshot, setting the window to pure black, and diffing — any visible seam meant the curve was wrong.


Why this matters

The entire notch implementation is ~400 LOC in one file. The APIs aren’t new. The math isn’t hard. But the tuning — matching a physical curve, recording iOS animations in slo-mo, iterating on a 2pt offset until it disappears — takes real time and real care.

OpenEar’s notch isn’t innovation. It’s care.


OpenEar — local-only Mac dictation.