Motion showcase
Four animations that ship with the engine
animation takes one of four names, and their @keyframes come with the engine — written into the stylesheet the first time something asks for one, so the three you did not use cost nothing. Each preset's duration is a multiple of --transitionTime, the variable the base stylesheet zeroes under prefers-reduced-motion, so all four stop themselves for a reader who asked for less motion. There is no opt-in to forget.
<Flex
gap={10}
ai="center"
jc="center"
flexWrap="wrap"
py={6}
>
<Icon size={10} color="sky-500" animation="spin">
<LoaderCircle />
</Icon>
<Box
width={14}
height={14}
borderRadius={3}
bgColor="violet-500"
animation="pulse"
/>
<Box
width={14}
height={14}
borderRadius={7}
bgColor="emerald-500"
animation="bounce"
/>
<Box
width={14}
height={14}
borderRadius={7}
bgColor="rose-500"
animation="ping"
/>
</Flex>Spring physics, tuned live, running as CSS
A spring is normally a loop: a physics step every frame, on the main thread, for as long as the motion lasts. Box.spring() samples the same oscillator once into a linear() curve — which both timing-function props already take — so what reaches the browser is a value, and the browser runs it on the compositor. Drag the dials and throw the card: the curve under them is the one the CSS receives.
180201540msconst { easing, duration } = Box.spring({ stiffness: 180, damping: 20, mass: 1 });
<Box
transition="transform"
transitionTimingFunction={easing}
transitionDuration={duration}
translateX={thrown ? 44 : 0}
/>transitionTimingFunction="spring-bouncy" with transitionDuration="spring-bouncy" is the whole of it, and a spring is a curve and a settling time, which is why the name goes on both props. Because the curve is a value, two elements given the same spring share one class.A panel that animates both ways without unmounting
startingStyle is what a just-mounted element starts from, and transitionBehavior="allow-discrete" is what flips display at the end of the transition rather than the beginning — so the panel below animates in and out without ever leaving the DOM, and without a library holding it open. <Dialog> and <Popover> are this same pair, already wired.
<Box
display={open ? 'block' : 'none'}
opacity={open ? 1 : 0}
translateY={open ? 0 : -2}
transition="all"
transitionBehavior="allow-discrete"
startingStyle={{ opacity: 0, translateY: -2 }}
>
Both directions, one element.
</Box>A chart that draws itself as it arrives
animationTimeline takes an animation's progress off a scroll position instead of a clock: scroll() is the nearest scrollport's progress, view() this element's own pass across it. The bar at the top of the panel and the four cards below it are one declaration each — no IntersectionObserver, no scroll listener, no requestAnimationFrame and no state. Scroll inside the panel.
Box.keyframes({
progress: { from: { scale: 0 }, to: { scale: 1 } },
reveal: { from: { opacity: 0, translateY: 5 }, to: { opacity: 1, translateY: 0 } },
});
<Box height={64} overflow="auto">
<Box position="sticky" top={0} height={1} bgColor="sky-500" css={{ transformOrigin: 'left' }}
animationName="progress" animationTimeline="scroll()" animationFillMode="both"
motionReduce={{ animation: 'none' }} />
<Box animationName="reveal" animationTimeline="view()" animationRange="entry 0% entry 60%"
animationFillMode="both" motionReduce={{ animation: 'none' }}>
<Sparkline data={trail} variant="area" />
</Box>
</Box>prefers-reduced-motion: a scroll-driven animation has no duration, so the variable that zeroes every other one cannot reach it. motionReduce={{ animation: 'none' }} is on every element above, and it is not optional. Where the browser has no timelines the declaration is dropped rather than the animation, so end the sequence where the element belongs and the degradation is "already arrived".A layout change the browser animates between
A view transition screenshots the page, runs your update, screenshots again and animates between the two. Nothing below declares a transition: the card carries a viewTransitionName, and that is enough for the browser to move it from where it was to where it is. The alternative is FLIP — measure both layouts, compute the difference, animate it back — which is the code this prop replaces.
// flushSync is the point: the browser screenshots the page the moment the
// callback returns, and a setState has not rendered by then.
<Button onClick={() => Box.viewTransition(() => flushSync(() => setWide((on) => !on)))}>
Rearrange
</Button>
<Box viewTransitionName="showcase-card" width={wide ? '2/3' : '1/3'} /><Box.Theme viewTransition>, which is this call with the flushSync already inside it.An SVG path that draws itself
A keyframe's steps are Box props, so a sequence can animate anything a prop can set — including the SVG lengths. A path draws itself by animating strokeDashoffset from its own strokeDasharray down to zero: one dash longer than the line, slid out of the way. Nothing calls getTotalLength() — a dash with room to spare covers any path shorter than it, which is why the ring beside it takes the very same two numbers.
Box.keyframes({ draw: { from: { strokeDashoffset: 320 }, to: { strokeDashoffset: 0 } } });
<Path d="M4,44 C24,44 28,8 48,8 …" fill="none" stroke="sky-500" strokeWidth={3}
strokeDasharray={320} animationName="draw" animationDuration={1600}
animationTimingFunction="ease-in-out" animationFillMode="both" />