Skip to content
Component installs need the registry setup
VegaStack Design

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 (a linear() 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".

AnimationWhat it looks likeWhere it's usedHow to see it
motion-pop-inA tiny springy "pop" — scales up from 90% while fading in, with a slight bouncecopy-button ✓-swap, auto-save status icons, notification-bell countClick any Copy button and watch the check arrive
motion-enter-upFades in while rising 4px — "slides up into place"FieldError messages, content arrivalsSubmit an empty required Field; the error slides up
motion-shakeA quick decaying left-right wiggle (±4px)Input, Checkbox, RadioGroup, InputOTP — fires automatically on becoming invalidEnter a wrong OTP in the demo; the field shakes once
motion-flashA 2px semantic accent ring expands and settles around changed contentLive updates that need a brief, non-status highlightUpdate a live value and apply --vs-flash-color for its semantic accent
motion-dock-in / motion-dock-outA docked control slides in from its edge and fades — and leaves faster than it arrived, without scalingActionBarSelect a row in the Action Bar demo, then clear the selection
--ease-springThe bouncy arrival curve (CSS linear() spring) powering the popThe easing inside motion-pop-in; available to any transition-* pairIt's the personality inside the pop, not a separate effect
AnimatedNumberNumbers count smoothly up/down to their new value, locale/currency awareits own component pageChange the value in the Animated Number demo
Keyed presenceA changed icon/label is replaced by a freshly-animated one instead of mutating in placecopy-button, auto-save-input, editable-cellThe mechanism behind the pop-in sightings above
Animated iconsPer-icon micro-animations with an imperative startAnimation() handle (e.g. the check that draws its stroke)All 467 icon-* mirror itemsHover icons in the icon gallery
ShimmerA moving highlight over loading placeholdersSkeleton, the shimmer text utilityAny skeleton demo
Overlay transitionsFade/scale/slide on open and close, reversible mid-flightEvery dialog, sheet, popover, menu, select, tooltipOpen 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:

MechanismWhat it's forHow it works
Base UI lifecycleOverlays & disclosures — dialog, sheet, popover, menus, select, tooltip, hover-card, accordion, collapsible, tabs indicatorPlain 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 presenceIcon/text swaps & arrivals — copy-button ✓, auto-save status, notification-bell badgeThe 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 APIsEffects that must re-run on demand — error shake, success check draw, number ticksuseAnimationReplay / 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 presenceA control that stays mounted at a viewport edge and flips data-active — ActionBarThe 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) and animate-pulse (Skeleton) are platform defaults, allowed as-is.
  • Animated icons — the registry/ui/icons/* mirrors carry their own Motion-driven variants (including the check pathLength draw); 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.

On this page