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, Badge/Marker animateIn (opt-in) | Click any Copy button and watch the check arrive |
motion-enter-up | Fades in while rising 4px — "slides up into place" | FieldError messages, chat Message/Bubble animateIn (opt-in), SkeletonReveal, StaggeredTextReveal | Submit an empty required Field; the error slides up |
motion-shake | A quick decaying left-right wiggle (±4px) | Input, Checkbox, RadioGroup, OTPInput — 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, MessageScrollerButton | 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 | dashboard-01 stat cards, 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, password-input, auto-save-input, split-button | 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, Badge/Marker/Message/Bubble animateIn, SkeletonReveal | 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, MessageScrollerButton | 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. |
Field shakes once when it transitions into invalid, taking every control it wraps with it —
the motion belongs to the field, not to the individual control, so Input, Textarea, Checkbox,
RadioGroup, OTPInput and NumberField all behave identically inside one. Repeated failures
replay via shakeSignal. 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 (from @vegastack/design-tokens/utilities.css) are the
only mount-animation classes, and motion-dock-in / motion-dock-out — from the same file — are the
one sanctioned transition pair, for a docked control that stays mounted and flips data-active;
everything else is a transition-* + token pair. Raw motion values —
animate-[…], cubic-bezier(…), linear(…), duration-[…], ease-[…] — are lint-banned
(raw-motion); every transition-* must carry duration-* and ease-* in the same class
string (transition-pairing).
Toast motion is on the scale
Toasts used to be the one documented exception here, because sonner owned their motion. Since the
Base UI Toast migration they are ours and they are tokenized: the enter, exit, stack and expand
transitions all run duration-base ease-standard, the same pair as the modal family. A toast
travels the same distance a Sheet does — in from beyond an edge — and 150ms reads clipped over that
distance, which is the whole reason it is not duration-fast like the anchored surfaces.
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. No button hover animations anywhere, and no press-motion
either — the former active:translate-y-px nudge was removed; pressed feedback is colour-only
(the active:* background states).
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.