Motion
The easing set, the duration knob, and how reduced motion is honoured without disabling feedback.
Easings
Three curves for three jobs. A fourth would only give the next component something new to be inconsistent with.
@theme {
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}| Token | Utility | Use |
|---|---|---|
| --ease-smooth | ease-smooth | The default. Hovers, colour changes, anything under 200ms. |
| --ease-emphasized | ease-emphasized | Things entering the page. Decelerates hard so the element settles rather than slides. |
| --ease-bounce | ease-bounce | Overshoots. For a deliberate moment of delight — a toggle, a success mark — and nothing else. |
Presets
Three named animations, each already carrying its duration and curve, so an entrance never has to be hand-timed at a call site.
@theme {
--motion: 1;
--animate-fade-in: fade-in calc(0.3s * var(--motion)) var(--ease-smooth) both;
--animate-fade-up: fade-up calc(0.4s * var(--motion)) var(--ease-emphasized) both;
--animate-scale-in: scale-in calc(0.2s * var(--motion)) var(--ease-smooth) both;
}
/* Used as a utility, like any other theme value. */
<div className="animate-scale-in" />animate-fade-in— opacity only, 300ms. For content swapping in place.animate-fade-up— opacity and an 8px rise, 400ms. For something arriving.animate-scale-in— from 96%, 200ms. For overlays, which should feel like they came from the trigger.- All three use
both, so the element holds its start state before the animation and its end state after. Without it a delayed entrance flashes at full opacity first.
The duration knob
Every preset multiplies its duration by --motion, so the whole library's pace is one variable.
/* Everything at half speed, for a demo or a recording. */
:root { --motion: 2; }
/* Or off, without any layout moving. */
:root { --motion: 0.001; }--shadow-strength works the same way for elevation. Both exist so a global change is an edit in one place rather than a search across every component.
Not a substitute for reduced motion
--motion down is a design choice you make for everyone. Honouring prefers-reduced-motion is a per-user requirement, and it needs the media query below.Reduced motion
Resolve to the end state. Never replay the same animation faster, and never remove the feedback along with the movement.
@media (prefers-reduced-motion: reduce) {
/* Land on the end state — do not replay it faster. */
.js [data-reveal="out"] {
opacity: 1;
transform: none;
}
.js [data-reveal] {
transition: none;
}
}- Movement goes, meaning stays. A menu still opens and a toast still appears — they simply arrive rather than travel. Suppressing the state change too would be a worse bug than the animation.
- Colour and opacity transitions are usually fine to keep. What the setting is about is vestibular triggers: transforms, parallax, large or looping movement.
- Check it in the client too, when JavaScript drives the timing. A CSS media query cannot help a staggered sequence built from timers — read the same query with
matchMediaand skip to the finished state.
When to animate
Motion here is feedback, not decoration. Three questions settle almost every case.
- Did something change state? Animate the change so the eye can follow it. If nothing changed, there is nothing to animate.
- Would you notice it twice? An entrance you see on every page load becomes a delay. It runs once and never re-plays — that is why the scroll reveal here does not re-hide when an element leaves the viewport.
- Is it under 400ms? Past that the interface starts waiting on itself. If a longer duration is the only way the motion reads, the motion is doing too much.