Motion & Animation
Develi owns its motion layer. src/styles/motion/ is a dependency-free port of tailwind-animations, adapted to this theme’s conventions — 87 animate-* utilities, a full modifier set, a scroll-driven extension, and a global reduced-motion guard the upstream library does not ship.
Do not install an animation library. The catalog is content, not infrastructure, and the same stance produces the owned SEO layer and the owned icon registry. Class names match tailwind-animations.com 1:1, so its documentation applies directly.
The catalog
Two files. motion/index.css is the entry — value tokens, the --animate-* shorthands, the modifier utilities and the reduced-motion guard. motion/keyframes.css holds the 88 @keyframes, kept in @theme there so Tailwind still tree-shakes them. global.css imports the entry once.
<div class="animate-fade-in-up animate-duration-1000 animate-bezier-back-out">
The modifiers are utilities, not arbitrary values: animate-duration-*, animate-delay-*, animate-bezier-*, animate-iteration-count-*, animate-fill-mode-*, animate-steps-*, animate-direction-*, animate-play-*. Tune with those rather than writing [animation:fade-in-up_1s_…].
Never interpolate a class name. animate-${x} is invisible to the Tailwind compiler and produces no CSS. Map whole static classes inside a tv() variant instead.
Do not redefine Tailwind’s built-ins. animate-pulse, animate-spin, animate-bounce and animate-ping ship with Tailwind — Skeleton uses animate-pulse and Spinner uses animate-spin. The port deliberately drops pulse for this reason.
The scroll-driven extension
On top of the upstream port sits an owned set of keyframes shaped for timeline scrubbing rather than one-shot playback:
animate-progress— a reading bar. Pair withtimeline-scroll origin-left.animate-parallax-up/animate-parallax-downandanimate-ken-burns— pair withtimeline-view animate-range-cover.animate-fade-through— in on entry, out on exit.animate-wipe-in-*— clip reveals, usable either way.
Plus the native timeline helpers: timeline-view, timeline-scroll, animate-range-*, scroll-timeline-name-*, view-timeline-name-*, timeline-scope-*.
Clip parallax and zoom frames with overflow-clip, never overflow-hidden. A hidden box is a scroll container, so view() tracks the frame — which never scrolls — instead of the page, and the animation freezes at its start.
Parallax has one knob, --parallax-travel, defaulting to 15%. Set it per instance:
<div class="animate-parallax-up [--parallax-travel:6%]">
The travel is a share of the frame’s own height, so one fixed value means very different absolute distances: 15% is generous on a tall hero and a visibly sliding background on a 500px band. Range is not a substitute for tuning it — cover is already the widest window the catalog offers, and gradual through rapid are all narrower. Distance is the only lever for a calmer drift.
The motion ladder
Reach down this list before shipping JavaScript. It is the same cheapest-first discipline the theme applies to hydration.
1. Native scroll-driven animation — timeline-view is animation-timeline: view(), zero JS. <Reveal> is the wrapper.
2. @starting-style + allow-discrete for enter and exit of top-layer elements — <dialog>, popovers. Real entry and exit with no JS. That is what ui/_overlay.css does.
3. View transitions — <ClientRouter>, gated on siteSettings.useViewTransitions. Add transition:name to a shared element for near-free morphing across navigations.
4. Only then a bundled script, and only through the shared onReady contract in ui/_client.ts.
<Reveal> — the zero-JS default
<Reveal animation="fade-in-up" range="entry">
<Card>…</Card>
</Reveal>
Eleven animation values (fade-in, fade-in-up, fade-in-down, fade-in-left, fade-in-right, zoom-in, blurred-fade-in, slide-up-fade, bounce-fade-in, flip-in-x, flip-in-y) and seven range values (entry, cover, contain, gradual, moderate, brisk, rapid). Defaults are fade-in-up and entry.
Three things about it are worth knowing before you use it:
It is a real box. display: contents breaks the timeline, so the wrapper must render an element. Use as to pick which.
Use it below the fold only. A scroll-timeline element already in view on load renders mid-progress. For above-the-fold content use a plain time-based animate-* utility, which plays once on load.
Where scroll timelines are unsupported — Firefox at time of writing — the animation-timeline declaration is dropped and the animation plays once, time-based. Entrances end at identity, so content still ends visible. That is the documented degradation, and there is no JS fallback.
Because the scroll-only shapes do not end at identity — a played-once parallax leaves content offset, and fade-through ends invisible — a @supports guard in motion/index.css makes them inert instead:
@supports not (animation-timeline: view()) {
[class*="animate-parallax-up"],
[class*="animate-parallax-down"],
[class*="animate-ken-burns"],
[class*="animate-fade-through"] { animation: none !important; }
}
Add any new scroll-only keyframe to that list, or it will strand content off-screen in Firefox.
The three play-once primitives
<Reveal> scrubs against scroll position, which means it rewinds as the reader scrolls back up, and it treats every element the same so a grid row pops as one slab. Where the design needs a one-shot tween with its own duration, easing and stagger, three primitives go one rung further:
TextReveal — a line of copy that reveals itself once. split="words" rises each word out of its own clipped window; split="lines" rises each rendered line and fades it in, from the end, so the bottom line arrives first.
StaggerReveal — a row, grid or list whose children deal themselves out once, --sr-stagger apart in DOM order (tight 0.06s / normal 0.09s / loose 0.14s). Wrap anything with children; it never touches their markup.
CountUp — a number that counts from zero once, 1.6s on a cubic-out curve.
All three share one trigger, _reveal-once.ts: arm the root, flip to data-state="in" on first intersection, disconnect. The first two also share _reveal-once.css — one keyframe, one armed rule, one play rule, one guard, all parameterised by --rv-* custom properties. What is left in each primitive’s folder is a set of values:
| Tier | travel | fades | stagger |
|---|---|---|---|
TextReveal words |
120% |
no | 0.1s |
TextReveal lines |
30px |
yes | 0.14s |
| StaggerReveal | 40px |
yes | 0.09s |
The script owns the trigger; the sidecar CSS owns every duration, curve and delay. Restyling the motion never means reading JavaScript.
CountUp imports no sidecar — it marks no reveal items, so every rule there would be dead. It shares the trigger, not the keyframe, because a text node tween is the one thing a keyframe cannot express.
Accessibility is not optional
A global reduced-motion guard sits at the bottom of motion/index.css:
@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;
}
}
Near-zero rather than none, so animationend and transitionend listeners still fire. It also neutralizes the scroll-behavior: smooth set on <html>. This is the single global source for reduced-motion resets.
The guard cannot stop scroll-driven animations, because they are progressed by scroll position rather than by time. Any element you drive with timeline-* must also carry motion-reduce:animate-none. That is how <Reveal> degrades to static content, and every animated primitive in the theme keeps its own motion-reduce: for the same reason.
Two hard-won details about the guard in a sidecar.
It needs
!important. The armed rule is more specific than the guard, so without it the guard loses the cascade and a reduced-motion reader keeps the hidden start state — text that never appears. This was a live defect in_text-reveal.cssuntil it was measured in-browser.It must be scoped to
[data-reveal]. An unscoped guard on a wrapper whose children come from a<slot>reaches into markup the primitive does not own and clobbers those children’s own transforms with!important— even with the primitive switched off. Gate-scoping is what makes “off means pass-through wrapper” actually true.
Two independent switches
prefers-reduced-motion is a user need. Always honored, via the guard above. Never gate it behind config.
siteSettings.useAnimations is a brand and design choice. It is the build-time master switch for the decorative motion layer — scroll reveals, ambient loops. Gate decorative motion on it; do not gate intentional micro-interactions. A dropdown chevron rotating is UX, not decoration.
<Reveal> follows it by default with a per-call animate override, so a primitive stays usable config-free:
<Reveal animate={false}>…</Reveal>
Each of the three play-once primitives degrades three ways, all to “the content is simply there”: no JS means never armed, so the start state never applies; reduced motion drops the mechanism; useAnimations off means no data-reveal, so the selector cannot match.
CountUp is worth calling out because it got this right in a way that is easy to get wrong: the final value is server-rendered, and the script replaces it with the start value only once it arms. Every degradation path shows the real number rather than a zero.
Adding a keyframe
If a motion is genuinely reusable and not in the catalog, add it the way the Marquee added its own: a --animate-* token in motion/index.css and its @keyframes in motion/keyframes.css, each inside an @theme block. Tailwind resolves the name across blocks and tree-shakes it.
Do not hand-roll a keyframe for a one-off, and do not reach for an arbitrary [animation:…] value.
The check
Open /examples/ui and scroll to the Motion panel — a dev-only catalog demoing nearly the whole library. Eyeball it in light and dark, with and without the OS “reduce motion” setting.
Keyframes are emitted only when a utility is actually used, so if a class seems inert, confirm it in the built CSS: pnpm build, then grep dist for the @keyframes.
“Used” means “appears in a scanned source file”, not “rendered by a page.” The catalog demos nearly the whole library, and Tailwind scans its
.astrofiles even though/examples/builds no HTML in production — so the catalog’s demo classes ship in the one stylesheet every real page loads.Measured on this theme: removing the catalog takes the shared stylesheet from 103,249 to 84,802 bytes (−17.9%) and the
@keyframescount from 93 to 2. Eighty-eight of the ninety-three keyframes in the production bundle are referenced by no built page.Do not fix this with
@source not. That directive is not build-mode conditional, so it strips the demo rules inastro devtoo and the catalog silently stops animating — removing the exact check this section prescribes. The remedy is deletingsrc/components/Sections/UiCatalog/andsrc/pages/examples/before launch, which is step 7 in Deployment.