Hover intent: tooltips and hover cards wait, then stay warm, app-wide
Summary
Tooltips and hover cards used to show at once on every control or row the pointer passed, so a sweep across a toolbar or down a list left a trail of them. They now follow one hover-intent policy, the same in the app and in its pages, modeled on AppKit. The tooltip itself is now frosted glass.
Tooltips
- The first tooltip waits 450 ms.
- While one is showing, moving to a neighbor shows the neighbor's tooltip at once (the group is warm).
- Warmth cools 500 ms after the last tooltip hides.
- A click, a key or a scroll hides the tooltip at once and cools the group, as AppKit does.
- Motion:
- The first tooltip of a group fades in over the
fadeIntoken (120 ms) and rises 2 pt. - A warm neighbor's tooltip appears at once, with no second fade.
- Leaving fades it out faster, over the
fadeOuttoken (80 ms). - Reduce Motion keeps the fade and drops the rise.
- The first tooltip of a group fades in over the
- Surface (one token, native and pages,
TooltipSurface):- Frosted glass:
NSVisualEffectViewbehind the window natively, andbackdrop-filter: blur(24px)in pages. - The tint is the popup surface at the lowest alpha that keeps the primary text at 4.5:1 or better over pure black and over pure white. The blur can only average what's underneath, so those two are the worst case.
- The rim is one device pixel (
--cmux-hairlinefrom #20481). - The shadow is elevation 1 from #20519:
--elevation-1in pages andPopupStyle.elevation1natively (0 1px 2px, black at 7%, 12% in dark). Base had given the title tooltip--elevation-2; a tooltip is the smallest surface, so it takes step 1. - Reduce Transparency draws the opaque popup surface instead.
- Frosted glass:
- Labeled controls get no tooltip. A control whose visible label already says the tooltip text shows none (#20375's rule, applied at runtime). Natively this covers a titled button or a text field. In pages it covers an element whose own text matches.
Sidebar workspace cards
- They keep the 1 s dwell, and an open card still slides from row to row.
- A pointer heading into the card no longer hands the card to a row it crosses on the way. A move counts as heading into the card when it lands inside the safe triangle and its step toward the card is at least half its step along it.
- A pointer resting on the card keeps it open.
- Leaving the card starts a 200 ms grace before it closes.
Row highlights in pages appear on the frame the pointer reaches the row, with no fade-in, so a fast sweep no longer leaves half-faded tints. The fade on leaving stays, and native sidebar rows keep their cross-fade from #20315.
Why these numbers
AppKit's tooltip waits NSInitialToolTipDelay, about 1 s at the default setting, and after that the next view's tooltip shows at once. Leo asked for 400–500 ms, and 450 ms sits comfortably on both sides of the trade:
- A sweep still shows nothing. A pointer crossing a 24–30 pt toolbar button at sweep speed (300–1000 pt/s) is on it for 30–100 ms, far under the delay.
- A deliberate rest gets its answer in under half a second.
The other values:
- The 500 ms warm time is about the gap a reader leaves between two buttons.
- The card dwell stays at the existing 1 s.
- The 200 ms grace and the 300 ms aim window are in the usual menu-aim range.
| Constant | Value | Native tunable | Web |
|---|---|---|---|
| Tooltip delay | 450 ms | motion.tooltip.delay | HOVER_INTENT.tooltipDelay |
| Tooltip warm (cool-down) | 500 ms | motion.tooltip.warm | HOVER_INTENT.tooltipWarm |
| Card dwell | 1000 ms | motion.hoverCard.dwell (existing) | HOVER_INTENT.cardDwell |
| Card grace on leave | 200 ms | motion.hoverCard.grace | HOVER_INTENT.cardGrace |
| Card aim (pause allowed while heading in) | 300 ms | motion.hoverCard.aim | HOVER_INTENT.cardAim |
| Fade in / out | 120 / 80 ms | MotionFade.fadeIn / .fadeOut | --ui-popup-open / --ui-popup-close |
| Tooltip blur | 24 px | TooltipSurface.blurRadius | --tooltip-blur |
| Least tint alpha | 86% | TooltipSurface.minimumTintAlpha | --tooltip-tint fallback |
| Shadow | elevation 1 (0 1px 2px, 7%) | TooltipSurface.shadow = PopupStyle.elevation1 | --tooltip-shadow: var(--elevation-1) |
Every value is a tunable or a design token, not a user setting.
- The timing lives in Swift (
HoverIntentTiming.standard) and in the web constantHOVER_INTENT.webviews/test/hover-intent-parity.test.tsfails when the two drift. webviews/test/tooltip-surface.test.tsdoes the same for the surface constants.- Pages get the per-theme tint from the host as
--cmux-tooltip-tint(WebTheme).
The primitive
Native, CmuxNextDesign/HoverIntent/HoverIntent.swift:
HoverIntentTimingreads the timing through the tunables.HoverIntent<Target>is a pure reducer that owns the delay and the warm group. Its owner runs the effects on oneDemandTimer, and the clock is injectable.CGRect.isAimedAt(from:to:)is the safe triangle plus the heading test.TooltipPresenterruns onHoverIntent.HoverCardMachineandHoverCardCoordinatoruse the timing and the aim test.TooltipPaneldrawsTooltipSurfaceand runs the fades throughMotion.set, socheck-motionstays green.
Web, webviews/src/ui/hoverIntent.ts:
HoverIntent, plusHoverGroupfor the warm group. Pages share one tooltip group, and cooling the group also drops other intents' pending waits.- The
useHoverIntenthook. aimingAt, the safe triangle.- An injectable
HoverClock. - The surface and fades are CSS in
pages/shared/desktop.css(--tooltip-*,data-starting-style,data-ending-style, anddata-instantfor a warm neighbor).
Timers moved onto it
| Where | Before | Now |
|---|---|---|
TooltipPresenter (every native themedToolTip / .themedHelp) | instant | HoverIntent: 450 ms, then warm |
WorkspaceHoverCardController.delay | motion.hoverCard.dwell read inline | HoverIntentTiming.current.cardDwell |
HoverCardMachine.leaveWindow | 150 ms literal | cardGrace (200 ms), plus aimWindow and a hold while the pointer is on the card |
ui/Tooltip TooltipProvider (Base UI) | delay={0} | delay=450, timeout=500 (Base UI's warm window) |
ui/titleTooltips.ts (every title in pages) | next frame | HoverIntent on the shared tooltip group |
ui/Toolbar.tsx hint | its own 500 ms / 400 ms group | useHoverIntent on the shared group; keyboard focus shows it at once |
| Agent pane thread minimap turn preview | 110 ms POPOVER_DELAY_MS | useHoverIntent (450 ms, then follows ticks at once) |
| Markdown link hover card | 250 ms setTimeout | HoverIntent for the pointer; the caret's card keeps 250 ms |
Agent pane submenu safe triangle (acpmux/useHoverIntent.ts) | its own geometry | aimingAt from ui/hoverIntent.ts; its 150 ms submenu delay is unchanged |
Left as they are:
- Tab-strip hover cards keep their own Chrome-style
TabPreviewPolicydelay. They already run throughHoverCardMachine, so they get the grace and the aim test (with the heading check, so sliding along the strip still hands the card over at once). - The Settings background-tile preview keeps its 150 ms dwell. It's a live preview, not a hover card.
NSMenuItem.toolTipstays AppKit's own.- The group editor bubble (its ⋯ and right-click openers and its show/dismiss path) and group-chip hover are untouched, at Lawrence's chief's request.
- No layout changes. The sidebar is being redone separately.
- Base UI tooltips (
<Tooltip>) keep Base UI's own warm window, which has the same 450/500 ms timing, separate from the title-tooltip group.
A tooltip far from the pointer no longer wins
While recording I found that the toolbar's first tooltip took about 1 s on base, and a warm neighbor's tooltip appeared and then vanished. The cause was the sidebar's bottom "Default" row: AppKit reported its visibleRect as {{-8,-692},{208,720}}, past its 34 × 24 bounds, so its .inVisibleRect tracking area covered the whole sidebar. As the deepest tracker it beat the toggle and New Workspace, and its tooltip was placed off the window until the 500 ms recheck picked the real one. TooltipPresenter now only lets trackers whose view is under the pointer compete (and falls back to all of them if none is). Base has the same bug, which the before clips show.
Overlap with #20375
#20375 (open) also edits webviews/src/ui/titleTooltips.ts and Tooltip.tsx (TOOLTIP_DELAY 1000, TOOLTIP_WARM 400) and adds test/tooltip-rules.ts. Whichever lands second resolves the conflict. This PR's timing is Leo's 450 ms, from HOVER_INTENT.
Testing
Red and green ran the same commands. Each red commit comes before its fix in the branch.
Native (fleet: cmux-ci run --class light --script scripts/ci/run-swift-testing-suites.sh --arg Packages/macOS/CmuxNext)
Red, first round, at 76ccbaeee84d (aws-m4pro-9):
TooltipHoverIntentTestshad 6 issues. A sweep showed["Back", "Forward", "Reload", "Share", "Downloads"], and the first tooltip showed with no delay.SidebarHoverCardDwellTestsfailed: the diagonal path into the card slid it tows-2, and the card closed while the pointer rested on it.
Red, second round, at bdd850384de2 (aws-m4pro-9):
TooltipHoverIntentTestsfailed because the tooltip waited 800 ms instead of 450 ms.TooltipSurfaceTestsfailed: no--cmux-tooltip-tint, and no tooltip after 500 ms.ThemedTooltipTestsfailed: the fill was opaque instead of the frosted tint.
Red for the strengthened review tests, at 13410b2baca7 (a not-for-merge branch: the red commit plus the two tests), on aws-m4pro-2:
movingDownTheListWithDriftSlidesTheCardAtOncefailed: the card stayed on row 0.aLabeledControlInsideATooltipViewShowsNeitherfailed: it showed the container's "Page actions".
Red for the Default-row bug, at 6a3c382d94b0 (the red test on its own) on aws-m4pro-3: aDeeperTooltipAwayFromThePointerDoesNotWin failed with (presenter.shown?.text → "Default") == "Back".
Green:
- At
081bbafon aws-m4pro-7, a display host, so no motion skips: every suite passed, includingTooltipSurface,TooltipHoverIntent,ThemedTooltip,SubtleElevation,SidebarHoverCardDwellandSpareNewTabChatOpensTabs. - At the head
33444f2de1feon aws-m4pro-2: 306 of 306 suites passed, includingaDeeperTooltipAwayFromThePointerDoesNotWin.- An earlier round there on aws-m4pro-3 timed out once each in
BranchStateResourcesTestsandPinnedPageTests(waiting on a pinned tab). Both passed in every other round, and this PR doesn't touch them.
- An earlier round there on aws-m4pro-3 timed out once each in
- Every round exits 1 only on the fleet's same-tree guard (
AcpmuxBundleReplacedTestsskipped for want of a published cmux-tui tree). That's infra. - This PR also fixes a base compile red in
SpareNewTabChatOpensTabsTests, which passedtag:toAgentTabStore(build:).
Web (heavy --mem 4G bun test; browser tests use CMUX_BROWSER_TESTS=1 heavy --mem 6G, Chromium)
Red at ae4b3073af6e:
src/ui/titleTooltips.test.tsfailed 3 cases.test/hover-intent-cards.test.tsxfailed 1 (the minimap preview showed after 110 ms).test/hover-row-sweep.test.tsfailed 2: a sweep sampled 27 half-faded fills.
Red at bdd850384de2:
titleTooltips.test.tsfailed 2: the 450 ms delay, and a key not dropping a waiting toolbar hint.test/tooltip-surface.test.tsfailed 4:- no 120 ms fade or 2 px rise;
- no fade under Reduce Motion;
- a solid
rgb(52, 49, 68)fill instead of the tint, with no blur; - the constants differed from the Swift ones.
Red at aaa5d137f8ea: tooltip-surface.test.ts showed a click leaving the tooltip fading ({hidden: false, ending: true}).
Green:
- PR CI
web / react-apps-checkat33444f2de1feran 270 test files. Every file this PR adds or changes passes:titleTooltips,hover-intent-parity,hover-intent-cards,hover-row-sweep,tooltip-surface,popup-surface,subtle-elevation,chat-header-tooltipsandui-a11y. The one red istheme-colors.test.ts, onagent-session/acpmux/composerStates.css:28, a line that came from #20561 on base and isn't in this branch. - After merging base (#20519), the focused runs passed:
subtle-elevation.test.tsandtooltip-surface.test.ts(6/6, Chromium).
Final head 6248e28573c0: the tip a5f878e69fc7 merged in, with ci-target-graph.json restored to the tip's (the merge had dropped CmuxNextAgentLanes).
- The release compile passed on it (
cmux-ciisolated,check-release-compile.sh). - Run again on it,
subtle-elevation,popup-surface,hover-intent-parityandtitleTooltipspassed 50/50. react-apps-checkthere stopped at lint, on sevencontrol-has-associated-labelwarnings in base'shome-channels/ChiefSheet.tsxandConversationSheets.tsx.
Guards: crash_ratchet.py, check-concurrency.sh, check-system-blue and check-motion all pass.
Clips
Each clip is before (left, feat-cmux-next) and after (right, this PR), both recorded the same way on a capture mini at about 60 fps with the art wallpaper on. Native hovers are driven with debug.pointer_hover. Pages are hovered with a new debug-only debug.agent_pane {action:"hover"}, which fires the page's pointer events, because an inactive capture-slot app gets no real mouse moves. Base was built from feat-cmux-next plus only the three debug commits. Times are from frame onsets.
Fast sweep: sidebar rows, then title-bar and pane-toolbar buttons at sweep speed. Before, the pane toolbar leaves a trail of tooltips (Changes, Terminal, Browser); after, nothing shows.

Slow dwell, native: rest on the sidebar toggle, slide to New Workspace, leave, rest again. After: the first tooltip shows 0.50 s after the rest, New Workspace shows at once (warm), and after cooling the next rest waits again (3.27 to 3.75 s). Before: the first takes about 0.85 s, and New Workspace's tooltip shows then vanishes at the recheck (the Default-row bug above).

Slow dwell, page toolbar: over the art wallpaper. Before: instant (0.33 to 0.35 s). After: 0.31 to 0.83 s, then neighbors at once.

Sidebar cards: rest on a workspace row (1 s card), then slide down. The card follows row to row once open, in both.

Jump to latest, busy wallpaper: after a fling up a real chat. Before: instant. After: 0.50 s, on the frosted glass surface, then fades out over 80 ms.

Jump to latest, light theme (Rose Pine Dawn): the same over a light page; the tint keeps the label readable.

Page toolbar, light theme: the page dwell again under Rose Pine Dawn.

Changelog
Changed: Tooltips and hover cards wait briefly before showing, then show instantly for neighbors, like macOS.
🤖 Generated with Claude Code