Motion & Animation
src/styles/motion/ holds 89 animate-* utilities built on 90 @keyframes, and it is a file in the theme rather than a package. It is a port of tailwind-animations (MIT, by Miguel Ángel Durán and community), adapted to Tailwind v4’s CSS-first shape.
The stance is the theme’s usual one: a keyframe library is content, not infrastructure. Class names match the upstream site exactly, so its documentation applies 1:1 — you get the reference material without the dependency.
The two files
index.css is the entry and the tunable vocabulary: value tokens, the --animate-* shorthands, the modifier @utility declarations, the support guard and the reduced-motion guard. keyframes.css holds the 90 @keyframes themselves, kept in @theme so Tailwind still tree-shakes them.
Every --animate-* token becomes an animate-* utility:
<div class="animate-fade-in-up animate-duration-500 animate-delay-200">
What is in the catalog
The bulk is the upstream vocabulary — fades, slides in and out on four sides, zooms, rotations, flips, and the attention set (bouncing, swing, wobble, shake, tada, jiggle, rubber-band, flash, pop).
Three deliberate departures from upstream:
--animate-pulse is dropped, because it is identical to Tailwind’s own built-in animate-pulse, which Skeleton uses.
A global prefers-reduced-motion guard was added. The upstream ships none, and the theme treats that as non-negotiable.
An owned scroll-driven extension. Nine keyframes shaped for timeline scrubbing rather than time — progress, parallax-up, parallax-down, ken-burns, fade-through, and wipe-in- on four sides — plus named-timeline utilities and a @supports guard. Two more, split-rise and split-lift, back the SplitText primitive.
The modifiers
Twenty-seven @utility declarations tune any animation. The value tokens use a tw-anim- prefix specifically to avoid clashing with Tailwind’s own transition delay-* scale.
| Modifier | Tunes |
|---|---|
animate-duration-* |
animation-duration — named steps or a bare integer as ms |
animate-delay-* |
animation-delay, same shape |
animate-ease*, animate-linear, animate-bezier-* |
the timing function |
animate-steps-* |
steps() timing |
animate-iteration-count-* |
repeat count |
animate-fill-mode-* |
fill mode |
animate-direction-* |
normal, reverse, alternate, alternate-reverse |
animate-play-running / -paused |
play state |
timeline-*, scroll-timeline-*, view-timeline-*, timeline-scope-* |
scroll timelines |
animate-range-* |
animation-range |
animate-stagger
The one modifier that is not a straight port, and the most useful:
<li class="animate-fade-in-up animate-stagger" style="--i:3">
@utility animate-stagger {
animation-delay: calc(var(--stagger-delay, 0s) + var(--i, 0) * var(--stagger-step, 0.06s));
}
It reads an inline --i index off each item, because animation-delay is the one animation property Tailwind cannot express per-element without a class per index — and a computed class name is invisible to the compiler, which is exactly the pattern the theme’s Tailwind rules forbid.
Two things about its placement are load-bearing. It lives in the catalog rather than in either component that uses it (SplitText and PagesDropup), so the ramp has one definition. And it is declared after the theme utilities, because it has to out-order the animation shorthand that an animate-* token emits.
The two off switches
They are independent, and the difference matters.
prefers-reduced-motion is honoured globally and always, regardless of any theme setting:
@media (prefers-reduced-motion: reduce) {
*, ::before, ::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Note it uses near-zero durations rather than animation: none. That is so animationend and transitionend listeners still fire — killing the animation outright would strand any code waiting on one. It also neutralizes the scroll-behavior: smooth set on <html> in global.css, which is a real reduced-motion concern that most implementations miss.
siteSettings.useAnimations is the author’s switch, turning the decorative layer off site-wide. It is read by components — Reveal degrades to a plain pass-through wrapper when it is false — rather than by the CSS.
The support guard, and the distinction it draws
This is the most interesting part of the catalog and the bit worth copying into your own work.
Where scroll timelines are unsupported (Firefox, at time of writing), an animation-timeline declaration is simply dropped — and the animation runs once, time-based, instead.
For an entrance that is fine: it ends at identity, so content ends up visible. That is Reveal’s documented degradation. But for a scroll-only shape it is broken: a played-once parallax leaves content offset, and fade-through ends invisible. So those are made inert rather than allowed to play:
@supports not (animation-timeline: view()) {
[class*="animate-parallax-up"],
[class*="animate-parallax-down"],
[class*="animate-ken-burns"],
[class*="animate-fade-through"] {
animation: none !important;
}
}
progress is deliberately exempt — its identity end state is a full bar, which is exactly what animation: none would render anyway.
The substring match is there so variant-prefixed uses like md:animate-parallax-up are covered, and the source marks its ceiling honestly: a false positive on any future class containing one of these names. If you add a scroll-only animation, add it to this list — an entrance does not need it, a scroll-only shape does.
Reveal, in practice
ui/reveal/ is the primitive that composes all of this, and it is zero-JS — the entrance is driven by the native scroll timeline (timeline-view = animation-timeline: view()).
It follows useAnimations (off ⇒ pass-through), takes a per-call animate override, and carries motion-reduce:animate-none of its own. That last one is not redundant with the global guard, and the source explains why: the global guard zeroes durations, but it cannot stop a scroll-driven animation, whose progress is tied to scroll position rather than time. A scroll-driven animation with a 0.01ms duration still scrubs.
That is the single most useful thing to take from this chapter if you write scroll-driven CSS anywhere: prefers-reduced-motion duration resets do not stop scroll timelines. You need an explicit motion-reduce:animate-none.
Adding an animation
Add the keyframe to keyframes.css, the --animate-* shorthand to index.css, and — if it is scroll-only — its name to the @supports guard. The utility appears automatically; there is nothing to register.
The Marquee primitive’s --animate-marquee and --animate-marquee-vertical deliberately live in tailwind-theme.css instead, beside the --marquee-gap and --marquee-duration knobs they depend on.