ui/motion: spring token, sliding indicator, view swap, hover and depth primitives
Summary
Leo (2026-11-03), after sending a short game menu animation: "i thought was cute and cool and i wanted to have more... nice stuff? around the app". The reference shows a chunky pill tab bar whose highlight slides between tabs, panels swapping while it moves, a stylised wipe between views and a slowly moving background layer for depth.
This PR adds that vocabulary as shared, budgeted primitives and puts the first one to use:
- Motion tokens.
schemas/motion-scalegains the one spring (the chat switch'sSWITCH_SPRING) as alinear()easing run over thelargestep (240 ms; visibly settled by about 140 ms). The generator writes--cmux-ease-spring(CSS) andMOTION_SPRING/MOTION_SPRING_STEP(TypeScript); the native copy is unchanged (the app keeps its own spring physics).chatSwitch.tsnow reads the token instead of its own copy. Where the engine has nolinear()easing (WebKit before Safari 17.2, which macOS 14.0 and 14.1 ship),Element.animate()throws on it, so every spring use (these primitives and the chat switch) goes throughspringEasing(), which checksCSS.supportsand falls back to the ease-out. The popup's--ui-enter-ease/--ui-exit-easeare not folded in:cubic-bezier(0.2, 0, 0, 1)and the scale's ease-out draw visibly different curves, so folding them would change every popup's close and the panels' entrance. ui/motion/SlidingIndicator: a selection highlight for tab bars, segmented controls and pickers. The item's selected state changes on the click; the highlight is placed on the new item in the same commit and one finite Web Animation carries it there on transform only (no layout shift), interruptible from its drawn position. First frame is final (no motion on mount or on resize).ui/motion/ViewSwap: an entrance for swapped content (fade, edge slide, a slanted wipe, an accent sweep). The new content is in the DOM and interactive on the frame of the swap; there is no snapshot of the old content.- Hover and press utilities (
.ui-hover-fill,.ui-reveal-host/.ui-reveal): fill over 80 ms, a revealed control fades in over 120 ms and out over 80 ms, stays shown while its menu is open (data-popup-open), and a press changes nothing (no flash). ui/motion/DepthLayer: two soft accent washes behind a surface that lean toward the pointer (event-driven), or drift once.- Applied first to the ACP inspector's Wire / Session view switch (the Session Info event inspector's only tabs in the webviews: Session Info's own panel has no tab bar today). The pressed plate now travels between the two.
Not touched: the model picker (waits on #32980), Home and New Tab (another lane is migrating them right now). The depth layer and view swap are applied to Home and New Tab in a follow-up once that migration lands.
Gallery variants and picks
| Entry | Variants | Picked, and why |
|---|---|---|
ui.motion-sliding-indicator | pill-spring, pill-ease, plate-spring, underline-spring, today | pill-spring: the reference's chunky pill, landing fast with a touch of give. The inspector uses the plate look of the same primitive to match its toolbar. |
ui.motion-view-swap | slide, fade, sweep, wipe, none | slide: says where the view comes from in 8 px and never hides content. Sweep is the stylised wipe kept for surfaces that want a performance; wipe leaves part of the new view unclickable for up to 180 ms. |
ui.motion-hover-feedback | scale, blink, slow, press-dip | scale (80 fill, 120 / 80 reveal, no press): the motion-and-polish rules 6 and 8. |
ui.motion-depth-layer | parallax, drift, drift-parallax, still | parallax: costs nothing while the pointer is still. Drift gives a live background but runs the compositor for 24 s after each show. |
Design references: zenless-zone-zero/live/motion-sunbringer-menu-animation-sheet, claude/live/settings-transcript-controls, cursor/live/hover-thread-row-pin-and-details.
Loops, timers and observers (stop conditions)
- SlidingIndicator: no timer, no frame loop. One finite Web Animation per selection change (240 ms), cancelled by the next change and on unmount. One ResizeObserver on the host and its items (re-places with no motion), disconnected on unmount.
- ViewSwap: no timer, no frame loop. Finite Web Animations per swap (at most 240 ms), cancelled by the next swap and on unmount.
- DepthLayer: drift is one CSS animation with
iteration-count: 1(24 s), paused while the page is hidden (onevisibilitychangelistener); parallax runs only onpointermoveover the surface and settles within 240 ms of the last one. Both listeners are removed on unmount. Motion off, a zero motion token or Reduce Motion: still, and no pointer listener. - Every primitive reads one
motionMode(): motion off or a hidden page places at once; Reduce Motion places the indicator at once and turns every swap look into the fade.
Performance
bench/motion-tabs.bench.ts (new; the model-picker-open bench pattern on the shared bench/stability.ts and PERF_ROW): a real press and release on the next tab, 5 contexts x 6 switches, headless Chromium and WebKit on the Linux pool. "Before" for the gallery is the today variant (each item fills itself at once, the base behavior); "before" for the inspector is the same bench run against the base tree.
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
| inspector switch, press to selected frame p50 / p95 (Chromium) | 15.0 / 15.5 ms | 15.1 / 15.6 ms | +0.1 ms (noise) | before 41630104068, after 41628201530 |
| inspector switch, press to selected frame p50 / p95 (WebKit) | 15.0 / 15.0 ms | 15.0 / 16.0 ms | 0 / +1 ms (one frame quantum) | same |
| inspector shift frames (max, both engines) | 0 | 0 | 0 | same |
| inspector CLS (max, Chromium) | 0.0002 | 0.0002 | 0 (on base already, not this change) | same |
| inspector settle p50 (Chromium / WebKit) | 98 / 96 ms | 265 / 257 ms | +167 / +161 ms: the 240 ms slide, on transform, after the selection already painted | same |
| inspector idle wakeups after settle (both) | 0 | 0 | 0 | same |
| inspector idle renderer CPU, 2 s (Chromium) | 2.3 ms | 1.9 ms | -0.4 ms (noise) | same |
gallery tabs, press to selected frame p50 (Chromium, today to pill-spring) | 15.9 ms | 15.8 ms | -0.1 ms | after run |
| gallery tabs shift frames / CLS (max) | 0 / 0 | 0 / 0 | 0 | after run |
| gallery tabs idle wakeups / CPU (Chromium) | 0 / 1.1 ms | 0 / 1.1 ms | 0 | after run |
The selection paints in the first frame either way; the highlight's travel happens after it and leaves nothing running.
Testing
src/ui/motion/motion.test.tsx(jsdom): first frame places the highlight with no animation; a selection change places it at once and slides on transform only, on the spring; Reduce Motion and motion off do not slide; an engine withoutlinear()easing slides on the ease-out instead of throwing (fails before the fallback); a view swap shows the new content in the same commit and animates paint only; every swap look keys only opacity, transform or clip-path.test/motion-scale.test.ts: the CSS and TS copies carry the spring; the spring starts and ends at rest and settles within its step.bun run checkand the tests above ran on the fleet:bun run checkexit 0, 18 tests pass with the chat switch suite (41636481602).
Observed / Not run
- Observed: the bench numbers above; the unit tests and
bun run checkon the fleet. - Not run: a look in a tagged app build (the inspector is a Debug surface; the gallery variants show every look).
Changelog
Changed: The ACP inspector's Wire / Session switch slides its highlight between the two views.
Agent session: agent session
š¤ Generated with Claude Code