Motion
CSS-first motion. Duration + easing tokens, Base UI transitions, reduced-motion baked in.
Last updated
Motion is CSS-first: plain transition-* classes + Base UI's built-in data-starting-style /
data-ending-style transitions for overlays, plus a small set of mount-animation motion-*
utilities for state feedback. The JS motion library appears in exactly one sanctioned place —
the mirrored lucide-animated icons — and is a real dependency there,
not an optional one.
Tokens
- Duration —
--duration-fast(150ms),--duration-base(200ms),--duration-slow(300ms) - Easing —
--ease-standard,--ease-emphasized,--ease-exit,--ease-spring(alinear()spring with a tiny ~1.05 overshoot — the arrival/success curve) — bridged from--motion-ease-*
Duration
--duration-fast
150ms
--duration-base
200ms
--duration-slow
300ms
Easing
--motion-ease-standard
cubic-bezier(0.2, 0, 0, 1)
--motion-ease-emphasized
cubic-bezier(0.3, 0, 0, 1)
--motion-ease-exit
cubic-bezier(0.4, 0, 1, 1)
Motion at a glance
The whole vocabulary in plain language — what each animation looks like, where it lives, and how to see it. Everything is deliberately subtle (150–400ms, state-triggered, never on page load) and disappears entirely under OS "Reduce Motion".
| Animation | What it looks like | Where it's used | How to see it |
|---|---|---|---|
motion-pop-in | A tiny springy "pop" — scales up from 90% while fading in, with a slight bounce | copy-button ✓-swap, auto-save status icons, notification-bell count | Click any Copy button and watch the check arrive |
motion-enter-up | Fades in while rising 4px — "slides up into place" | FieldError messages, content arrivals | Submit an empty required Field; the error slides up |
motion-shake | A quick decaying left-right wiggle (±4px) | Input, Checkbox, RadioGroup, InputOTP — fires automatically on becoming invalid | Enter a wrong OTP in the demo; the field shakes once |
motion-flash | A 2px semantic accent ring expands and settles around changed content | Live updates that need a brief, non-status highlight | Update a live value and apply --vs-flash-color for its semantic accent |
motion-dock-in / motion-dock-out | A docked control slides in from its edge and fades — and leaves faster than it arrived, without scaling | ActionBar | Select a row in the Action Bar demo, then clear the selection |
--ease-spring | The bouncy arrival curve (CSS linear() spring) powering the pop | The easing inside motion-pop-in; available to any transition-* pair | It's the personality inside the pop, not a separate effect |
AnimatedNumber | Numbers count smoothly up/down to their new value, locale/currency aware | its own component page | Change the value in the Animated Number demo |
| Keyed presence | A changed icon/label is replaced by a freshly-animated one instead of mutating in place | copy-button, auto-save-input, editable-cell | The mechanism behind the pop-in sightings above |
| Animated icons | Per-icon micro-animations with an imperative startAnimation() handle (e.g. the check that draws its stroke) | All 467 icon-* mirror items | Hover icons in the icon gallery |
| Shimmer | A moving highlight over loading placeholders | Skeleton, the shimmer text utility | Any skeleton demo |
| Overlay transitions | Fade/scale/slide on open and close, reversible mid-flight | Every dialog, sheet, popover, menu, select, tooltip | Open and quickly re-close any overlay |
Mechanism matrix
Base UI's data-starting-style / data-ending-style attributes can't express everything — four
mechanisms cover the system, each with a defined home:
| Mechanism | What it's for | How it works |
|---|---|---|
| Base UI lifecycle | Overlays & disclosures — dialog, sheet, popover, menus, select, tooltip, hover-card, accordion, collapsible, tabs indicator | Plain CSS transition-* driven by data-[starting-style] / data-[ending-style] (+ --transform-origin, --accordion-panel-height). Interruptible by construction — a transition reverses mid-flight if state flips back. |
| Keyed presence | Icon/text swaps & arrivals — copy-button ✓, auto-save status, notification-bell badge | The swapped element gets a React key tied to its state, so a state change remounts it and its motion-* mount animation plays once: motion-pop-in (scale 0.9→1 + fade, --ease-spring) for swaps/badges, motion-enter-up (fade + 4px rise) for content arrivals. |
| Explicit replay APIs | Effects that must re-run on demand — error shake, success check draw, number ticks | useAnimationReplay / useShakeOnInvalid (class-toggle + animationend cleanup — replays motion-shake without remounting, so a focused input keeps focus/caret); the lucide-animated icons' imperative startAnimation() handle (the check's pathLength draw-in lives there); AnimatedNumber's rAF tween. |
| Docked presence | A control that stays mounted at a viewport edge and flips data-active — ActionBar | The motion-dock-in / motion-dock-out pair: a transition, not a keyed animation, so it reverses mid-flight. 150ms in on emphasized, 100ms out on exit, translate + fade, no scale — an exit is never slower than its enter. The utilities own the timing, the fade and the parked pointer-events: none; the travel distance stays at the call site, because it is per-dock geometry and a translate inside the utility would clobber a centred bar's composed transform. |
The invalid shake is available rather than automatic. useShakeOnInvalid wraps
useAnimationReplay with invalid-transition detection and replays motion-shake without
remounting, so a focused input keeps its focus and caret; repeated failures replay via
shakeSignal. Wire it on the surface that owns the error — upstream's Field does not carry it
for you. FieldError itself stays still: Base UI only renders it while invalid, so it mounts fresh
on every new error.
Sanctioned utilities
motion-pop-in, motion-enter-up, motion-shake, motion-flash and motion-indeterminate (from
@vegastack/design-tokens/utilities.css) are our mount-animation classes, and motion-dock-in /
motion-dock-out — from the same file — are our one transition pair, for a docked control that
stays mounted and flips data-active.
Everything else is upstream's vocabulary, and it is legal: transition-all, transition-colors, a
numeric duration-100, ease-in-out, and the arbitrary [transition:…] an upstream component
writes for a composed transform. The old rules that banned those — the transition-* must-pair-with-
a-token contract, the raw-motion ban, and the mandatory pressed step — were removed with the shadcn
reset. Our duration-fast|base|slow and ease-standard|emphasized|exit|spring tokens remain, and
they are what the utilities above are tuned on.
Documented exceptions
- Indeterminate loaders —
animate-spin(Spinner) andanimate-pulse(Skeleton) are platform defaults, allowed as-is. - Animated icons — the
registry/ui/icons/*mirrors carry their own Motion-driven variants (including the checkpathLengthdraw); their inline values are regenerated by the mirror script, not hand-tuned.
Exclusions (deliberate)
Adapted-from-reference effects we chose not to ship, per the dense dev-tool aesthetic: avatar
hover lift, card 3D tilt, FAB morph. Buttons carry no hover animation, but they do carry upstream's
one-pixel press nudge (active:translate-y-px, suppressed on a popup trigger) alongside the
hover: background step.
Duration utility bridge
The raw duration tokens live under --duration-*, but the @theme inline block in
@vegastack/design-tokens/theme.css also re-exports them into Tailwind's transition-duration namespace:
@theme inline {
--transition-duration-fast: var(--duration-fast);
--transition-duration-base: var(--duration-base);
--transition-duration-slow: var(--duration-slow);
}That bridge is what lets you write the named duration-* utility instead of an arbitrary value —
both compile to the same 150ms:
<div className="transition-opacity ease-standard duration-fast" />Reduced motion
prefers-reduced-motion: reduce is enforced globally in @vegastack/design-tokens/base.css: every
animation-duration / transition-duration collapses to 0.01ms with a single iteration, so no
component opts out by accident. The specimen above also carries motion-reduce:animate-none
defensively.