Animations run on motion (motion/react) for springs and layout, and on CSS transitions for colour and opacity. Every animation has a reduced-motion branch.
The three springs#
| Spring | Feel | Used for |
|---|---|---|
snappy |
quick, a small settle (visual duration 0.26s, bounce 0.12) | presses, toggles, switch thumbs, selection indicators |
smooth |
calm, never overshoots (0.40s, bounce 0) | panels, heights, anything shrinking |
morph |
a little give (0.42s, bounce 0.16) | shapes growing, widths following their content |
The rule: growing uses morph, shrinking uses smooth. A narrowing width must never dip below its target and bounce back, so shrinking never overshoots. resizeSpring(previous, next) picks the right one.
The springs are defined by stiffness and damping, not a fixed duration. If a value changes again mid-flight (a label changes twice quickly), the animation keeps its velocity and heads for the new target instead of restarting.
import { motionTokens, resizeSpring } from "@/components/pixilui/motion-tokens";
<motion.div animate={{ scale: pressed ? 0.97 : 1 }} transition={motionTokens.spring.snappy} />Durations and easings#
For transitions that are not springs:
| Duration | ms | For |
|---|---|---|
instant |
120 | hover tints, an old face fading out |
quick |
150 | press, close, text swap, tooltip appear |
fast |
250 | open, icon swap, tabs |
standard |
240 | the default UI transition |
medium |
350 | panel close, toast close |
slow |
400 | panel open, content reveal |
considered |
480 | transitions that carry meaning |
verySlow |
500 | emphasis, a success check, an entrance |
| Easing | For |
|---|---|
enter |
things arriving |
smoothOut |
surfaces opening, closing, sliding, resizing |
exit |
things leaving |
move |
travel across the screen |
swap |
icon and label swaps; never bounces |
linear |
progress, shimmer, spinners |
bounce |
entrances only |
The same values exist as CSS variables and Tailwind utilities: duration-fast, ease-enter, ease-swap.
Staggers keep a sequence short: 16ms between characters, 35ms between items, 40ms between words, 80ms between lines, and the whole sequence under about 400ms.
The rules#
These are the behaviours you will see across the library, and the ones to keep if you change an item.
- One material. When something changes shape (a button whose label changes, a card folding into a receipt), width, height and radius animate together. The content never scales. The old content fades out quickly with a 2px blur, and the new content arrives in reading order, 35ms apart.
- Numbers roll, they do not swap. Digits are keyed by place and roll in the direction the value moved. A new digit column opens its width on the morph spring.
- Only changed words move. When text updates, the words that changed rise 0.3em out of a 4px blur; the rest stay still.
- Limits push back. Dragging past an end stretches at most 10px and returns. A note rises from the side it guards.
- Results do not snap in. Anything that took work shows a short sequence first.
- Agent runs read at human speed. Steps advance every 0.7 to 1s, text streams at about 50ms per word, and rows stagger 150 to 200ms.
- Quiet success. A check scales in. No glow, no confetti, no looping decoration.
- Things that move between containers travel. They animate from where they were to where they land, instead of disappearing and reappearing.
Reduced motion#
When the user asks for reduced motion, items swap movement for a short fade (reducedTransition, 150ms linear) or an instant change (instantTransition). Nothing travels, scales or bounces, and spinners hold still. Items read the setting with motion's useReducedMotion(); you do not need to wire anything.
Using the presets in your own code#
Import from the installed file and stay inside the presets, so your additions feel like the rest of the app:
import { motion, useReducedMotion } from "motion/react";
import { motionTokens, reducedTransition } from "@/components/pixilui/motion-tokens";
export function Reveal({ children }: { children: React.ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: reduce ? 0 : motionTokens.distance.base }}
animate={{ opacity: 1, y: 0 }}
transition={reduce ? reducedTransition : motionTokens.spring.smooth}
>
{children}
</motion.div>
);
}