Motion / Spring transition
The one physical motion for every reshape between states — a dialog window resizing between steps, a button or chip changing width, a list cursor moving. packages/ui/src/lib/spring.ts simulates a damped oscillator (mass 1, stiffness K = 190, damping C = 17, ζ ≈ 0.62) at 60 Hz and samples it into a curve from 0 to 1, then hands that curve to the Web Animations API as linearly-interpolated keyframes. Because the simulation is run once and reused for every distance, a short cursor move and a large window reshape share the same overshoot in percentage terms and the same total duration — only the distance in pixels differs.
It replaces the earlier canonical curve for this kind of motion, 0.29s cubic-bezier(0.32, 0.72, 0, 1) (ACRM-152) — RULES.md §11 updated accordingly. <Dialog morphKey> (the multi-step dialog's window resize) and MorphText (a text layer's width) both call springAnimate directly. Small-element morphs written before this module (ChipMorph, NoteRecChip) still run the old curve pending their own migration.
Numbers, verified in spring.ts: stiffness 190, damping 17, step 1/60 s, total motion SPRING_MS ≈ 750 ms, first reaching 95% of the distance (SPRING_ARRIVE_MS) at ≈ 583 ms, overshoot ≈ 6%. prefersReducedMotion() turns the whole thing off.
When to use
- Any reshape between two states of the same element — a dialog window, a button/chip that widens or narrows, a cursor that moves between rows — where the earlier canonical curve (RULES.md §11) would have been used.
- Distances that vary a lot between call sites (a one-line label swap and a 400px→750px dialog resize) and should still feel like the same physical object moving.
When not to use
- Entrances/exits of an element that is mounted or unmounted — that is a fade/scale, not a spring (see
Dialog's own open/close animation, which is untouched by this module). - Content crossfade — the spring only ever drives a numeric CSS property (width, height,
translateY); content opacity fades separately and is not part of the physics (seedialog-morph.css'sacrmDialogMorphIn,acrmMorphTextIn/Out). - A one-off decorative animation with its own intentional timing (e.g. a toast entrance) — the spring is for state-to-state reshapes, not every animation in the system.
Anatomy
SPRING_MS: number— total duration of one spring motion in ms (same for every distance).SPRING_ARRIVE_MS: number— the first moment the motion has covered 95% of the distance; the rest is overshoot and settle. Anything that belongs to the target of the motion (a hint that only makes sense once the element has basically arrived) should wait for this, not forSPRING_MS, so it never appears ahead of the movement.springValues(from: number, to: number): number[]— the sampled curve mapped onto one numeric range; a plain array of values in even time steps, suitable for any consumer that wants the raw curve instead of a WAAPI animation.springAnimate(el: Element, props: Record<string, [from: number, to: number, unit: string]>): Animation | null— animates one or more numeric properties ofelvia WAAPI in one call, e.g.springAnimate(panel, { width: [400, 750, 'px'], height: [320, 420, 'px'] })or{ transform: [0, 56, 'translateY'] }. Returnsnull(no animation runs) whenprefersReducedMotion()is true or the element has no.animate()— callers apply the end state directly in that branch. Returns the liveAnimationotherwise, so the caller can.cancel()it if the state changes again mid-flight.prefersReducedMotion(): boolean— reads(prefers-reduced-motion: reduce); every consumer of the spring checks this before starting a motion.
Best practices
Do
- Measure the
frombox at the moment of the action (or from the box a handed-off window left behind — seemulti-step-dialog), not from a stale layout observer. - Cancel the running
Animationand start the next spring from the currently visible value when the state changes again before the previous motion finished (Dialog'smorphKeyeffect does this by readinggetBoundingClientRect()in its cleanup). - After
anim.onfinish, drop back to CSS/layout control (no inline style left behind) — the spring only holds the property for the duration of the motion.
Don't
- Don't give one call site its own stiffness/damping —
STIFFNESS/DAMPINGare not exposed as parameters on purpose; a second physical feel would break the "one object" read the whole system relies on. - Don't drive an element's
opacityortransform: scalewith the spring — those stay on the existing fade curves (dialog-morph.css). - Don't call
springAnimatefor a change that repeats on every render (typing, polling) — only for an actual state-to-state change, or the motion restarts constantly and never settles.
Related
dialog/multi-step-dialog—<Dialog morphKey>drives the window's width/height with this module.morph-text— drives a text layer's width with this module.
Source: docs/dialog/spring.usage.md · GitLab