Motion contract

Animation is on by default here, which is why the timings are tokens: a component reads --cb-duration-base or asks the provider for a spring, and never writes its own numbers. Without that rule twenty components drift into five different rhythms.

Tokens

TokenValueUsed for
--cb-duration-instant80msState that must feel immediate: press, focus ring
--cb-duration-fast140msHover, colour changes
--cb-duration-base220msEnter and exit of overlays
--cb-duration-slow380msLarger movement, drawers
--cb-duration-deliberate620msThings meant to be watched: a ring filling

Springs are named too — snappy for controls, soft for surfaces, bouncy for celebratory moments.

MotionProvider

The stage footer of every example on this site reports what the provider currently resolves to.

tsx · motion on
import { MotionProvider, useMotionSettings } from '@ceebee/ui/client';
<MotionProvider scale={0.5}>       {/* everything at half speed */}<App /></MotionProvider>
function Card() {const { enabled, duration, spring } = useMotionSettings();return <motion.div animate={{ opacity: 1 }} transition={spring('soft')} />;}
PropTypeDefaultNotes
enabledbooleantrueApp-level off switch — a settings toggle, or a test.
scalenumber1Multiplier on every duration. 0 is equivalent to disabled.

Reduced motion

prefers-reduced-motion: reduce is treated as a requirement, not a hint. It resolves enabled to false, collapses every duration to zero, and switches CSS animations off through --cb-motion-scale.

Do

Drop transforms and keep opacity, so the state change is still visible without movement.

Don't

Remove the feedback entirely — a dialog that appears with no transition at all is worse than one that fades.

Every animated component also takes motion={false} for a single opt-out that does not touch the provider.

Reveal and Stagger

Two primitives for content that arrives rather than pops. Both collapse to a plain fade under reduced motion — the transform is what goes, not the feedback.

Reveal animates one element on mount or on scroll into view; Stagger wraps each child in a Reveal with an increasing delay, so a list assembles instead of appearing all at once.

tsx · motion on
<Reveal from="below" onView><Statistic {...stat} /></Reveal>
<Stagger step={0.06} onView>{items.map((item) => <Row key={item.id} {...item} />)}</Stagger>
PropTypeDefaultNotes
from'below' | 'above' | 'left' | 'right' | 'none''below'Direction travelled from. Ignored under reduced motion.
distancenumber12Travel in px. Keep it small — large entrances read as sluggish.
delaynumber0Seconds before it starts.
spring'snappy' | 'soft' | 'bouncy''soft'Spring preset from the motion tokens.
onViewbooleanfalseWaits until it scrolls into view, and fires once.
stepnumber0.06Stagger only: seconds between children.