Motion showcase

Six pieces of motion, each one a prop. Every effect below is run by the browser — there is no animation library here, and nothing on this page measures a layout or ticks a frame.

Four animations that ship with the engine

Instead of a keyframes block per project, and a media query to stop it.

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.

spin, pulse, bounce, ping
JSX
<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

Instead of a physics loop on the main thread for as long as the motion lasts.

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.

Box.spring()
Stiffness
180
Damping
20
Mass
1
settles in 540ms
180 · 20 · 1
spring
spring-gentle
spring-bouncy
spring-snappy
JSX
const { easing, duration } = Box.spring({ stiffness: 180, damping: 20, mass: 1 });

<Box
  transition="transform"
  transitionTimingFunction={easing}
  transitionDuration={duration}
  translateX={thrown ? 44 : 0}
/>
The four curves on the right are sampled once, on first use, and kept — 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

Instead of an animation library holding the node open until its exit finishes.

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.

startingStyle + allow-discrete
Both directions, one element, no unmount.
JSX
<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

Instead of an IntersectionObserver, a scroll listener and a requestAnimationFrame.

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.

scroll() and view()
Revenue
Sessions
Conversion
Retention
JSX
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>
This is the one kind of motion here that does not stop itself under 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

Instead of measuring both layouts and interpolating the difference by hand (FLIP).

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.

Box.viewTransition()
No transition written
JSX
// 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'} />
A name has to be unique in the document while the transition runs, so the prop is for the handful a layout has. Reduced motion skips the transition and still applies the update — a whole-page cross-fade being exactly the motion the preference is about. This site's own theme toggle is <Box.Theme viewTransition>, which is this call with the flushSync already inside it.

An SVG path that draws itself

Instead of a per-frame write to the dash offset of every path on screen.

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.

Drawing a path
JSX
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" />