Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
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 count, Badge/Marker animateIn (opt-in)Click any Copy button and watch the check arrive
motion-enter-upFades in while rising 4px — "slides up into place"FieldError messages, chat Message/Bubble animateIn (opt-in), SkeletonReveal, StaggeredTextRevealSubmit an empty required Field; the error slides up
motion-shakeA quick decaying left-right wiggle (±4px)Input, Checkbox, RadioGroup, OTPInput — 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 scalingActionBar, MessageScrollerButtonSelect 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 awaredashboard-01 stat cards, its 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, password-input, auto-save-input, split-buttonThe 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 badge, Badge/Marker/Message/Bubble animateIn, SkeletonRevealThe 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-activeActionBar, MessageScrollerButtonThe 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 loadersanimate-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. 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.

On this page