Hover intent: opening a notch panel after 80 ms without flicker

SwiftUI's onHover is a hint, not a fact. How NotchStar confirms every hover against the real pointer.

By Arin Prajapati 9 min read
White headline on black reading Hover intent without flicker, with the kicker Swift and SwiftUI and the NotchStar logo

TL;DR

  • SwiftUI’s onHover reports an exit and an enter whenever a view resizes under a pointer that has not moved. A panel that grows on hover and shrinks on exit will turn that into an endless open and close loop.
  • NotchStar’s fix, shipped in 1.0.4 on 11 October 2026, treats onHover as a hint only. Opening waits 80 ms and then checks the real pointer position. Closing polls every 120 ms until the pointer is really outside a rectangle computed from screen geometry.
  • The pointer is injected in tests, so the whole behavior is covered by tests that run in a test host with no mouse.

The problem: a panel that moves under the pointer

The notch panel has two sizes. Closed, it is the size of the hardware notch. Open, it is 660 by 296 points. Moving the pointer onto the notch should open it, and moving the pointer away should close it.

The obvious build is a SwiftUI view with .onHover, a state value flipped by the callback, and an animation on the frame. It works until the pointer rests still. When the view grows, the geometry under a stationary pointer changes. SwiftUI answers with a false “exit”, then a fresh “enter”. Each one flips the state. The panel closes, shrinks, reports another enter, opens, and does it again.

That is the symptom recorded in the 1.0.4 changelog (11 October 2026): “resting the pointer on the notch could make it snap shut and open again in a loop.” The comment in the code gives the cause in two sentences:

// SwiftUI's onHover fires spurious exit/enter pairs whenever the panel resizes under a still pointer
// (open grows it, the delayed close shrinks it). Acting on those directly made the notch bounce open and
// shut in a loop. So onHover is only a hint: both directions are confirmed against the real pointer.

The source files are Sources/NotchStar/Notch/NotchController.swift, NotchRootView.swift, NotchReducer.swift and NotchModel.swift. Nothing here was measured on a stopwatch: the timings below are constants from the code, and where behavior was checked it was checked by tests, which are named.

The first version, and why it bounced

The very first controller (commit 522dd70, 6 October 2026) already had an intent delay on the way in. Entering waited 80 ms and then opened, and leaving before the 80 ms were up cancelled the task. The way out, until 1.0.4, was direct:

model.onHover = { [weak self] inside in
    if inside { self?.hoverEntered() } else { self?.send(.hoverExited) }
}

Exit went straight to the state machine. That was the wrong turn. The delay only filtered one direction, and the other direction still trusted whatever SwiftUI said. The view that reports hover is the same view that resizes, so it reports on its own animation.

The fix landed in 58bba04 (Release 1.0.4, 11 October 2026): route exit through a function that checks the pointer, and make the enter check the pointer too, after the delay.

The rule: ask the pointer, not the view

The reducer is a pure function and stays trivial. In hover mode, enter opens and exit closes. In click mode, hover is ignored and a click toggles. Escape closes, and a file drag that reaches the notch opens it in either mode.

case (.escape, _): return .closed
case (.dragEntered, _): return .open
case (.hoverEntered, .hover): return .open
case (.hoverExited, .hover): return .closed
case (.clicked, .click): return s == .open ? .closed : .open
case (.clicked, .hover): return .open

All the care is in the controller, which decides when a hover callback becomes a .hoverEntered or .hoverExited event. Entering:

private func hoverEntered() {
    intentTask?.cancel()
    guard !ShotDragView.isDragging else { return }   // a screenshot dragged across the notch is not a hover
    intentTask = Task { [weak self] in
        try? await Task.sleep(for: .milliseconds(80))
        guard !Task.isCancelled, let self, self.hoverRect.contains(self.pointerLocation()) else { return }
        self.send(.hoverEntered)
    }
}

Three conditions must hold at the end of the 80 ms: the task was not cancelled, the controller still exists, and the real pointer is inside hoverRect. A pointer that only crossed the notch fails the first test, because leaving cancels the task. A false enter from a shrinking panel fails the third, because the pointer is nowhere near the rectangle.

Leaving is the more interesting half:

private func hoverExited() {
    intentTask?.cancel()
    intentTask = Task { [weak self] in
        // Keep checking until the pointer has really left; a spurious exit may not be followed by another.
        while true {
            try? await Task.sleep(for: .milliseconds(120))
            guard !Task.isCancelled, let self, self.model.state == .open else { return }
            if !self.hoverRect.contains(self.pointerLocation()) { self.send(.hoverExited); return }
        }
    }
}

An exit hint starts a loop that wakes every 120 ms. If the pointer is still inside the rectangle, the exit was spurious and the loop simply keeps watching. The comment explains why it must keep watching and not check once: after a false exit, the pointer may later leave for real without SwiftUI sending another exit, because it already sent one. The loop ends when the notch closes, when a newer hover task cancels it, or when the pointer leaves.

 pointer rests on the closed notch
   enter hint -> wait 80 ms -> pointer inside rect? yes -> open
   panel grows under the pointer
   exit hint (false) -> poll every 120 ms -> pointer inside rect? yes -> keep watching
   enter hint (false) -> cancels the poll, starts 80 ms check -> inside, already open, no change
   pointer leaves for good
   next poll -> pointer outside rect -> close

The rectangle

hoverRect is computed from the screen, not read from the panel:

var hoverRect: CGRect {
    guard let screen = notchScreen else { return .zero }
    // Closed, only the notch counts: the screenshot lip hanging below it never opens the panel.
    let size = model.state == .open ? NotchModel.openSize : model.metrics.closedSize
    let slackBelow = model.showsScreenshotStrip ? 0 : Self.hoverSlack
    // While the island shows, its wings count as the notch too.
    let width = model.showsIsland ? IslandMetrics.totalWidth(notch: size.width) : size.width
    return CGRect(x: screen.frame.midX - width / 2 - Self.hoverSlack, y: screen.frame.maxY - size.height - slackBelow,
                  width: width + 2 * Self.hoverSlack, height: size.height + slackBelow + Self.hoverSlack)
}

The doc comment on the property gives the reason for not using the panel frame: the frame lags the state by the close delay. When the notch closes, the state flips at once and the window shrinks 420 ms later, so panel.frame is the wrong answer for most of that interval. Screen geometry has no such lag.

hoverSlack is 6 points. Applied to each side, it makes the rectangle 12 points wider than the shape, and it is the margin that stops the edge from flickering when the pointer sits on the boundary. The numbers, derived from the code:

StateWidthHeightNote
Open672308660 by 296, plus 6 on the sides and 6 under, and no strip is showing
Closed, no stripnotch width plus 12notch height plus 126 below, 6 slack at the top edge
Closed, strip showingnotch width plus 12notch height plus 6no extra slack below, so the screenshot strip never opens the panel
Closed, island showingisland width plus 12notch height plus 12the wings count as the notch

Closed, the rectangle ignores the screenshot strip that hangs below the notch. That is deliberate and written in the comment: the strip is a separate window and pointing at it must not open the panel.

Everything else that touches the same state

Hover shares the controller with other inputs, and each had to be made safe against it.

  • Pinning. send returns early for .hoverExited and .escape when the panel is pinned, so a pinned notch ignores both.
  • Screenshots in motion. hoverEntered returns at once while ShotDragView.isDragging is true. A screenshot thumbnail dragged out of the strip passes over the notch, and that is not a request to open it.
  • File drags. A drag toward the notch opens the Shelf tool. It uses a separate global monitor and the drag pasteboard’s change count, not hover.
  • Closing. After .closed, the window shrinks after 420 ms. If a new enter arrives inside that window, closeTask is cancelled and the panel never shrinks. A test covers it.
  • Clicks. In click mode, hover events are ignored by the reducer, and an outside click closes the notch.

What happens after a hint is accepted

Once a hover survives the checks, it becomes an event and goes through send. That function is where the timing constants meet the window:

func send(_ event: NotchEvent) {
    if event == .hoverExited { intentTask?.cancel() }
    if model.isPinned && (event == .hoverExited || event == .escape) { return }
    let next = NotchReducer.reduce(model.state, event, mode: mode)
    guard next != model.state else { return }
    closeTask?.cancel()
    shadowTask?.cancel()
    model.shadowVisible = false          // never visible while the shape is moving
    model.state = next
    updateKeyboard()
    // opening: haptic, layout(), shadow after 400 ms. closing: layout() after 420 ms.
}

Three behaviors come out of those lines. A repeated event that does not change the state returns early, so a second enter on an open notch is free. Any state change cancels the pending shadow and shrink tasks, which is what lets a pointer that comes back during the close animation keep the panel open. And the state flips immediately in both directions: the animation is SwiftUI’s job, driven by model.state, while the window frame follows later (immediately on open, after 420 ms on close) so the shape has room to animate inside a window that is already big enough.

The open mode itself is a setting with two values, hover and click, read through the injected modeProvider. In click mode the root view’s tap gesture opens the notch, and the global mouse monitor closes it when a click lands outside shapeRect. In hover mode the same tap gesture opens it too, which is why a click on a closed notch always works. While open, the gesture is switched off with including: open ? .none : .all, so taps reach the tools inside.

The tests

The controller takes two closures so tests never touch real preferences or the real mouse:

init(modeProvider: @escaping @MainActor () -> OpenMode = { AppSettings.shared.openMode },
     pointerLocation: @escaping @MainActor () -> CGPoint = { NSEvent.mouseLocation })

A test sets a fake pointer and calls the same callback SwiftUI would call. The regression for this bug reads almost like the report:

func testSpuriousHoverExitWhilePointerInsideKeepsNotchOpen() async {
    let c = makeController()
    pointer = CGPoint(x: screen.frame.midX, y: screen.frame.maxY - 5)
    c.model.onHover?(true)
    await settle(0.2)
    XCTAssertEqual(c.model.state, .open)
    c.model.onHover?(false)             // spurious: pointer never moved
    await settle()
    XCTAssertEqual(c.model.state, .open)
    pointer = CGPoint(x: -5_000, y: -5_000)   // now really leave, with no further hover event
    await settle(0.3)
    XCTAssertEqual(c.model.state, .closed)
}

Its sibling covers the opposite mistake, a false enter with the pointer far away, which must leave the notch closed. NotchControllerTests.swift has 15 test functions in total (counted with grep -c "func test" on 4 November 2026). These tests were not run for this post, so the claim is that they exist and what they assert. They run as part of scripts/test-all.sh, which the pre push hook calls.

What is still bad

  • The 80 ms is a chosen number. The code does not say how it was chosen and no experiment with other values was recorded, so the post cannot claim it is the best value.
  • The exit loop polls every 120 ms for as long as the notch is open with the pointer inside. The cost of that was not measured. CPU use of the app has not been measured at all, so no figure is given.
  • hoverRect uses the first screen with a notch, or the main screen. Behavior with several displays, a Mac without a notch, full screen apps and Spaces has not been reported by the owner, who has confirmed that all features work properly on their own Mac.
  • Hover is only confirmed on real hardware by tests that launch the app (NotchStarUITests) and by the owner. The synthetic tests prove the logic, not how a particular trackpad feels.
  • Every constant (80, 120, 400, 420, 6) lives inline in the controller, not in a settings file, so changing one means a rebuild.

Try it yourself

  1. Make a SwiftUI view that grows on hover. Give it .onHover { inside in print(inside) } and .animation(.spring, value: isOpen).
  2. Put the pointer on the view and keep it still. Watch the console. A view that resizes under a still pointer can print false then true without any mouse movement.
  3. Replace the callback with a hint. Start a Task that sleeps for the intent time, then compares NSEvent.mouseLocation against a rectangle you compute from NSScreen.main?.frame.
  4. Make the exit path a loop that checks until the pointer is outside, and cancel it whenever a newer hint arrives.
  5. Inject the pointer as a closure, so a test can move it without a mouse.

A starting point for steps 3 and 4:

@MainActor final class HoverGate {
    var pointer: () -> CGPoint = { NSEvent.mouseLocation }
    var rect: () -> CGRect = { .zero }
    private var task: Task<Void, Never>?

    func hint(inside: Bool, set: @escaping @MainActor (Bool) -> Void) {
        task?.cancel()
        task = Task {
            while true {
                try? await Task.sleep(for: .milliseconds(inside ? 80 : 120))
                guard !Task.isCancelled else { return }
                let isInside = rect().contains(pointer())
                if isInside == inside { set(inside); return }
                if inside { return }   // enter hint but pointer is elsewhere: ignore it
            }
        }
    }
}

This sketch is not NotchStar code and was not compiled. It shows the shape: a hint, a wait, a check against the real pointer, and a cancel when a newer hint arrives.

Cheat sheet

SymptomLikely causeFix
A panel opens and closes in a looponHover reports on the view’s own resizeTreat it as a hint and confirm with the pointer
It opens when the pointer only passes overNo intent delayWait before opening (80 ms here) and cancel on exit
It closes while the pointer is still on itA false exitPoll until the pointer is outside the rectangle
It flickers at the edgeA rectangle with no marginAdd slack (6 points here)
The hover rectangle is wrong during the close animationIt was read from the panel frameCompute it from screen geometry
Tests need a real mouseThe pointer is read inside the controllerInject pointerLocation as a closure
  • Hover is a hint, never an event
  • Enter waits, then checks the pointer
  • Exit keeps checking until the pointer has left
  • The rectangle comes from the screen and has slack
  • Pinned and drag states are excluded from hover
  • The pointer and the open mode are injected in tests

Close

NotchStar is the app this controller drives. If you want to try the notch, it is at notchstar.com.

NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.