Keyframes, presets and transitions as props — including the durations that stop on their own when the reader asked for less motion.
Four presets, no registration
animation takes one of four names — the ones Tailwind ships, because everybody already knows what they do. Their @keyframes come with the engine and are written into the stylesheet the first time something asks for one, so an unused preset costs nothing at all.
Every preset's duration is a multiple of --transitionTime, the variable the base stylesheet zeroes under prefers-reduced-motion — so the four above stop on their own, with no opt-in and no media query of yours. The moment you name a duration in milliseconds you have left that default behind, which is the honest trade: the number you wrote wins, and saying when to stop is now your job.
A sequence is declared where component styles are, and its stops hold props rather than CSS — the same ÷4 spacing scale, the same colour tokens, the same composed longhands. Register it once at module scope; the name is what animationName refers to.
animationName, animationDuration, animationDelay, animationIterationCount, animationDirection, animationFillMode, animationPlayState and animationTimingFunction — declared after animation, so a longhand overrides whatever a preset chose. Times are milliseconds, like every other time in this library. The stagger above is three Boxes and one animationDelay; each distinct value is one shared class, so a hundred staggered rows generate a hundred one-line rules and nothing else.
animationPlayState
Hover to pause
JSX
<Flexgap={4}ai="center"p={4}borderRadius={2}theme={{dark:{bgColor:'slate-900'},light:{bgColor:'slate-50'}}}className="docs-marquee"><Iconsize={6}color="amber-500"animation="spin"hoverGroup={{'docs-marquee':{animationPlayState:'paused'}}}><LoaderCircle/></Icon><BoxfontSize={14}>Hover to pause</Box></Flex>
Transitions: what changes, and how long it takes
Every Box transitions all its properties over --transitionTime already, which is why a hover colour fades without being asked. transition narrows that to a group — colors, opacity, shadow, transform, size, filter — and transitionDuration, transitionDelay and transitionTimingFunction say the rest. An easing can be a keyword or a curve: cubic-bezier(), steps() and linear() are values, and a typo in one of them emits no rule rather than a broken declaration.
Transition groups
colours only
transform, overshooting
JSX
<Flexgap={4}flexWrap="wrap"><Boxpx={5}py={3}borderRadius={2}fontSize={14}cursor="pointer"bgColor="slate-200"transition="colors"transitionDuration={300}hover={{bgColor:'indigo-500',color:'white'}}theme={{dark:{bgColor:'slate-800',color:'slate-200'}}}>
colours only
</Box><Boxpx={5}py={3}borderRadius={2}fontSize={14}cursor="pointer"bgColor="slate-200"transition="transform"transitionDuration={300}transitionTimingFunction="cubic-bezier(0.34, 1.56, 0.64, 1)"hover={{scale:1.08}}theme={{dark:{bgColor:'slate-800',color:'slate-200'}}}>
transform, overshooting
</Box></Flex>
Springs, sampled into a curve
A spring is physics, and CSS cannot do physics — but it can follow a curve, so the four presets are a damped oscillator sampled once into a linear() curve, which is a value like any other. spring, spring-gentle, spring-bouncy and spring-snappy are values on transitionTimingFunction and animationTimingFunction; the same four names are values on transitionDuration and animationDuration, because a spring is a curve and the time it takes to settle. Name both and the physics is what the numbers say.
The four curves
spring · 540ms
spring-gentle · 660ms
spring-bouncy · 880ms
spring-snappy · 420ms
JSX
// Both halves of a spring come from the same name.<Boxtransition="transform"transitionTimingFunction="spring-bouncy"transitionDuration="spring-bouncy"hover={{scale:1.1}}/>
The curve is fixed once it is a string, which is the honest limit: a real spring carries its velocity into whatever interrupts it, and this one restarts. Reverse a transition halfway and the shape plays back rather than continuing from where it was — good enough for a hover, a panel or a toggle, not for a drag. That is framer-motion's job, and it composes with Box styling perfectly well. The other limit is browser support: linear() is missing in about one browser in eight, so every curve this library writes carries an ease-out declaration underneath it — the older browser keeps that one, and the animation still happens.
A spring of your own
JSX
// Stiffness, damping, mass and an initial velocity — sampled once, at module scope.const wobble = Box.spring({stiffness:120,damping:8});<Boxtransition="transform"transitionTimingFunction={wobble.easing}transitionDuration={wobble.duration}hover={{translateY:-2}}/>
The transform props compose
translateX, translateY, rotate and scale are the CSS longhands rather than one transform declaration, so setting several of them means several of them happen. The two translate axes used to write the same property and one silently won; each now sets its own custom property and both compose into one translate — which still transitions, because var() is substituted before the browser compares the two states. The exception worth knowing: flip and scale both write scale, so use one or the other.
startingStyle holds the values a property starts from the first time the element is styled — which, for something React has just mounted, is the moment it appears. It nests the way a breakpoint does and takes plain props, and since every Box already transitions, that is the entire entrance: no state, no effect, no library, nothing to unmount. It compiles to @starting-style, one rule shared by every element that starts from the same place, and a browser that has never heard of the at-rule drops that one rule and shows the element finished.
An entrance is easy because the element is new; an exit is hard because React removes the node the instant it stops rendering it. The platform's own answer is transitionBehavior="allow-discrete": it lets display transition, flipping to none at the end rather than the start, so an element that is hidden rather than unmounted animates out as well as in — and startingStyle applies again every time it comes back from display: none. When the node really does have to leave the tree, holding it long enough to animate is a React problem rather than a CSS one, and <Presence> below is the answer to that half.
Hiding is not always an option: a list row, a toast, a route. <Presence> keeps rendering its child with present: false until the child's own CSS says the transition is over, and only then lets React remove it — so the exit is written as ordinary props on ordinary state. It reads the element's computed transition-duration rather than listening for transitionend, which fires once per property with no way to know how many are coming; the useful consequence is that a reader on prefers-reduced-motion measures zero and the node leaves in the same commit, with nothing to configure. Tooltip, the Dropdown popup and the DataGrid's column menu are all built on it, so every layer in the library already animates both ways.
height: auto has never been animatable, which is why every accordion on the web measures its own content in JavaScript. interpolateSize="allow-keywords" opts a subtree into interpolating the size keywords — auto, min-content, fit-content — and it inherits, so it belongs on the container and every size inside it becomes animatable at once. Chromium-only for now, and the degradation is the behaviour you have today: the panel snaps open.
interpolateSize
No measuring, no scrollHeight, no ref: the panel transitions height from 0 to auto because the container said keywords may interpolate.
JSX
<BoxinterpolateSize="allow-keywords"><Boxheight={expanded ?'auto':0}overflow="hidden"transition="size"transitionDuration={300}><Boxp={4}>Content nobody had to measure.</Box></Box></Box>
A sequence can animate anything a prop can set
Because a stop is Box props, a sequence is not limited to the four properties an animation library would give you. This bar grows by animating width from 0 to fit — the size keywords work in a keyframe like they do anywhere else.
A sequence is part of the stylesheet, not of a component's markup, so it travels the way every other rule does: getStyles() returns it for static output, and in element mode it rides the base <style> element every Box carries — which means a Server Component can animate with no client JavaScript anywhere in the page. The loading bar in this site's DataGrid is exactly that: animationName in the grid's component styles, and a sweep that starts before hydration.
Turning the default off
transition: all on every Box is a default, not a law. An engine can be told to narrow it to one group, or to declare nothing at all and leave transitions entirely to the props — useful in an app that owns its own motion system.
Configuring the base transition
JSX
// Before the first render — the base block is written once.
Box.configure({transition:'colors'});// Or nothing at all:
Box.configure({transition:false});