ui/motion: spring token, sliding indicator, view swap, hover and depth primitives

Merged
TE
3d ago
+1675-11

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-scale gains the one spring (the chat switch's SWITCH_SPRING) as a linear() easing run over the large step (240 ms; visibly settled by about 140 ms). The generator writes --cmux-ease-spring (CSS) and MOTION_SPRING / MOTION_SPRING_STEP (TypeScript); the native copy is unchanged (the app keeps its own spring physics). chatSwitch.ts now reads the token instead of its own copy. Where the engine has no linear() 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 through springEasing(), which checks CSS.supports and falls back to the ease-out. The popup's --ui-enter-ease / --ui-exit-ease are 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

EntryVariantsPicked, and why
ui.motion-sliding-indicatorpill-spring, pill-ease, plate-spring, underline-spring, todaypill-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-swapslide, fade, sweep, wipe, noneslide: 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-feedbackscale, blink, slow, press-dipscale (80 fill, 120 / 80 reveal, no press): the motion-and-polish rules 6 and 8.
ui.motion-depth-layerparallax, drift, drift-parallax, stillparallax: 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 (one visibilitychange listener); parallax runs only on pointermove over 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.

metricbeforeafterdeltaharness or run link
inspector switch, press to selected frame p50 / p95 (Chromium)15.0 / 15.5 ms15.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 ms15.0 / 16.0 ms0 / +1 ms (one frame quantum)same
inspector shift frames (max, both engines)000same
inspector CLS (max, Chromium)0.00020.00020 (on base already, not this change)same
inspector settle p50 (Chromium / WebKit)98 / 96 ms265 / 257 ms+167 / +161 ms: the 240 ms slide, on transform, after the selection already paintedsame
inspector idle wakeups after settle (both)000same
inspector idle renderer CPU, 2 s (Chromium)2.3 ms1.9 ms-0.4 ms (noise)same
gallery tabs, press to selected frame p50 (Chromium, today to pill-spring)15.9 ms15.8 ms-0.1 msafter run
gallery tabs shift frames / CLS (max)0 / 00 / 00after run
gallery tabs idle wakeups / CPU (Chromium)0 / 1.1 ms0 / 1.1 ms0after 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 without linear() 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 check and the tests above ran on the fleet: bun run check exit 0, 18 tests pass with the chat switch suite (41636481602).

Observed / Not run

  • Observed: the bench numbers above; the unit tests and bun run check on 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