Motion
Indexa’s motion layer is source, not a package. src/styles/motion/ is a dependency-free port of tailwind-animations (MIT, © Miguel Ángel Durán and community), adapted to Tailwind v4’s CSS-first shape and extended with a scroll-driven set the upstream does not have.
Class names match the upstream website, so its documentation applies one-to-one.
What is in it
Two files. motion/index.css is the entry and the tunable vocabulary: the value tokens, the --animate-* shorthands and the modifier utilities. motion/keyframes.css holds the 88 @keyframes those shorthands reference, kept in @theme so Tailwind still tree-shakes them.
87 --animate-* entries in the catalog — 78 ported from upstream plus 9 scroll-driven ones — and 2 more for the Marquee primitive in tailwind-theme.css, where they sit beside the gap variable their maths depends on. Every one becomes a utility: animate-fade-in-up, animate-blurred-fade-in, animate-rubber-band, and so on.
24 modifier utilities tune any of them without a custom keyframe: animate-duration-*, animate-delay-*, animate-bezier-* (24 named easings), animate-iteration-count-*, animate-fill-mode-*, animate-steps-*, animate-direction-*, animate-play-*, animate-range-*, plus the timeline set below. So animate-fade-in-up animate-duration-500 animate-bezier-quint-out is a tuned entrance with nothing added to the stylesheet.
One upstream entry is deliberately missing: --animate-pulse, because it is identical to Tailwind’s own animate-pulse, which the Skeleton primitive uses. Use the built-in.
Scroll-driven motion
The extension is the interesting half. Nine animations are shaped for scrubbing rather than playing — progress, parallax-up, parallax-down, ken-burns, fade-through and four wipe-in-* — and the timeline-* utilities attach them to a native scroll or view timeline:
<div class="animate-parallax-up timeline-view motion-reduce:animate-none"></div>
They are linear on purpose. A scrubbed animation should map scroll progress one to one; the easing is the user’s scroll. The duration in the shorthand is inert once a timeline is attached and only matters for the time-based fallback.
There are also named timelines, which the upstream does not have: declare view-timeline-name-[--article] on the element to track, hoist it with timeline-scope-[--article] on a common ancestor, and consume it anywhere in that scope with timeline-[--article]. The canonical use is a fixed reading-progress bar following an article it does not contain.
All of it is zero JavaScript. <Reveal> — the one primitive that is about motion — is an entrance animation plus timeline-view, nothing more.
The three guards
prefers-reduced-motion. A global @media block at the bottom of motion/index.css reduces every animation and transition to 0.01ms and neutralizes scroll-behavior: smooth. Near-zero rather than none is deliberate: animationend and transitionend listeners still fire, so a script waiting on one does not hang. The upstream library ships no such guard; this one is non-negotiable here.
That guard zeroes time, which cannot stop a scroll-progressed animation — so anything driven by timeline-* must also carry motion-reduce:animate-none. <Reveal> does. If you write a scroll-driven element by hand, that class is not optional.
The @supports fallback. Where scroll timelines are unsupported, the browser drops the animation-timeline declaration and the animation simply plays once. That is fine for an entrance — it ends at identity, so content ends visible, which is <Reveal>’s documented degradation. It is wrong for the scroll-only shapes: a played-once parallax leaves content offset and fade-through ends invisible. So a @supports not (animation-timeline: view()) block makes exactly those four inert. progress is exempt, because a full bar is what animation: none renders anyway.
siteSettings.useAnimations. The master switch for decorative motion. Off makes <Reveal> a plain pass-through wrapper and drops the header’s and sections’ load-in classes. It is a site-owner preference, not an accessibility feature — the reduced-motion guard is honoured either way, which is why they are two separate things.
The load-in vocabulary
Above-the-fold content cannot use a scroll timeline, because there is no scroll yet. src/components/ui/reveal/loadIn.ts holds the two helpers those sections share: rise() returns the house entrance stack (animate-fade-in-up animate-bezier-quint-out, plus any extras you pass) and stagger(i) returns an inline animation-delay of i × 40ms for the ith item in a group, with the step overridable. Both respect useAnimations, and loadIn.test.ts asserts the exact class strings — a check that would catch a silent change to the house entrance.
Adding an animation
Add the @keyframes to keyframes.css and the --animate-<name> shorthand to index.css, and the utility exists. If it is scroll-only — if it does not end at identity — add it to the @supports guard’s list as well. The guard matches on a class substring so variant-prefixed uses like md:animate-parallax-up are covered; the stated ceiling is a false positive on any future class name that contains one of those words.
Do not pnpm add an animation library. That is the house rule, and the reason is in the file: a keyframe catalog is content, not infrastructure.