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
| Token | Value | Used for |
|---|---|---|
--cb-duration-instant | 80ms | State that must feel immediate: press, focus ring |
--cb-duration-fast | 140ms | Hover, colour changes |
--cb-duration-base | 220ms | Enter and exit of overlays |
--cb-duration-slow | 380ms | Larger movement, drawers |
--cb-duration-deliberate | 620ms | Things 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.
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')} />;}| Prop | Type | Default | Notes |
|---|---|---|---|
| enabled | boolean | true | App-level off switch — a settings toggle, or a test. |
| scale | number | 1 | Multiplier 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.
<Reveal from="below" onView><Statistic {...stat} /></Reveal>
<Stagger step={0.06} onView>{items.map((item) => <Row key={item.id} {...item} />)}</Stagger>| Prop | Type | Default | Notes |
|---|---|---|---|
| from | 'below' | 'above' | 'left' | 'right' | 'none' | 'below' | Direction travelled from. Ignored under reduced motion. |
| distance | number | 12 | Travel in px. Keep it small — large entrances read as sluggish. |
| delay | number | 0 | Seconds before it starts. |
| spring | 'snappy' | 'soft' | 'bouncy' | 'soft' | Spring preset from the motion tokens. |
| onView | boolean | false | Waits until it scrolls into view, and fires once. |
| step | number | 0.06 | Stagger only: seconds between children. |