Skip to content
AstroCraft Docs
On this theme

Motion & Animation

Grafio owns its motion layer. src/styles/motion/ is a dependency-free port of the tailwind-animations catalog — 91 animate-* utilities, a full set of modifier utilities, a scroll-driven extension written for this theme, and a global reduced-motion guard.

Do not install an animation library. The catalog is here, the class names match tailwind-animations one-to-one so its documentation applies, and adding a package would mean two systems to reason about.

The layer splits in two files: motion/index.css holds the utility tokens, the modifiers and the guards; motion/keyframes.css holds the 88 @keyframes.

The ladder

Before shipping JavaScript for motion, work down this list. Each rung is cheaper than the one below it.

  1. Scroll-driven native animation. animation-timeline: view() via the timeline-view utility. Zero JavaScript. <Reveal> is the wrapper.
  2. @starting-style with allow-discrete for entry and exit of top-layer elements — dialogs, popovers. Real animation in both directions with no script. See ui/_overlay.css.
  3. View transitions. transition:name on a shared element gives you near-free morphing across navigations.
  4. Only then a bundled <script>, and only through the shared onReady contract.

The utilities

Ninety-one, grouped by what they do.

Entrancesfade-in, blurred-fade-in, fade-in-up, fade-in-down, fade-in-left, fade-in-right, fade-in-from-left, fade-in-from-right, slide-in-top, slide-in-bottom, slide-in-left, slide-in-right, zoom-in, scale, pop, slide-up-fade, bounce-fade-in, swing-drop-in, pulse-fade-in, flip-in-x, flip-in-y, rotate-in, slide-rotate-in, roll-in, expand-horizontally, expand-vertically.

Exits — the mirrored set: fade-out, fade-out-up, fade-out-down, fade-out-left, fade-out-right, slide-out-top, slide-out-bottom, slide-out-left, slide-out-right, zoom-out, flip-out-x, flip-out-y, rotate-out, slide-rotate-out, roll-out, contract-horizontally, contract-vertically.

Attention seekers and loopsbouncing, swing, wobble, pulsing, shake, tada, jump, hang, float, sink, flash, jiggle, rubber-band, blink, heartbeat, jelly, dancing, horizontal-vibration, rotational-wave, vertical-bounce, horizontal-bounce, sway, tilt, squeeze, impulse-rotation-right, impulse-rotation-left.

Transformsrotate-90, rotate-180, rotate-360, spin-clockwise, spin-counter-clockwise, flip-horizontal, flip-vertical, flip-x, flip-y, skew, skew-right.

The scroll-driven extension — written for this theme, not upstream: progress (a reading bar, pair with timeline-scroll origin-left), parallax-up, parallax-down, ken-burns (pair with timeline-view animate-range-cover), fade-through (in on entry, out on exit), and wipe-in-up / -down / -left / -right clip reveals, which work equally well as time-based entrances.

The glitch pairglitch-text and glitch-line, used by the NavLink glitch variant that gives the header and footer their hover vocabulary.

One naming trap: fade-in-left and fade-in-right are inverted relative to how they read. They are the upstream catalog’s names, kept for compatibility. Prefer fade-in-from-left and fade-in-from-right, which do what they say.

Tailwind’s own animate-pulse, animate-spin, animate-bounce and animate-ping still work — the catalog deliberately omits pulse to avoid shadowing them.

The modifiers

Tune with these, never with an arbitrary [animation:…] value.

  • animate-duration-* — named steps (faster, fast, normal, slow, slower) or a bare integer in milliseconds: animate-duration-450.
  • animate-delay-* — same shape. animate-delay-350.
  • animate-bezier-* — 24 curves: sine, quad, cubic, quart, quint, expo, circ and back, each in -in, -out and -in-out. Plus the plain animate-ease, animate-ease-in, animate-ease-out, animate-ease-in-out and animate-linear.
  • animate-iteration-count-*once, twice, thrice, infinite, or a number.
  • animate-fill-mode-*none, forwards, backwards, both.
  • animate-steps-*retro (8), normal (16), modern (24), or an integer.
  • animate-direction-*normal, reverse, alternate, alternate-reverse.
  • animate-play-*running, paused.

For scroll-driven work: timeline-* (scroll, view, and their axis variants), animate-range-* (cover, contain, entry, exit, plus the tuned gradual, moderate, brisk, rapid), and the named-timeline trio scroll-timeline-name-*, view-timeline-name-* and timeline-scope-* for driving one element from another’s scroll position.

<div class="animate-fade-in-up animate-duration-1000 animate-bezier-back-out">

Never interpolate a class name. animate-${x} is invisible to the Tailwind compiler and emits no CSS at all. Map whole static class strings instead — which is exactly what ui/_motion.ts does for the delay scale.

Reveal

The wrapper you will use most:

---
import Reveal from "@components/ui/reveal";
---

<Reveal>
  <h2 class="h2">Selected works</h2>
</Reveal>

<Reveal trigger="view" animation="fade-in-from-left" delay={200} as="li">
  <ProjectCard project={project} index={i} />
</Reveal>

Props:

  • trigger"scroll" (default), "view" or "load".
  • animation — one of fourteen: fade-in, fade-in-up, fade-in-down, fade-in-from-right, fade-in-from-left, fade-in-left, fade-in-right, zoom-in, blurred-fade-in, slide-up-fade, bounce-fade-in, flip-in-x, flip-in-y, rotate-360. Default fade-in-up.
  • rangeentry (default), cover, contain, gradual, moderate, brisk, rapid.
  • delay — from the shared scale: 0, 50, 100, 150, 200, 250, 300, 350, then 400 to 1000 in hundreds. Off-scale values are type errors.
  • as — the element to render. Default div.
  • animate — a per-call override of the global animation switch.

Choosing a trigger

scroll is zero-JavaScript native scroll-timeline animation. Use it for below-the-fold content that reveals as it arrives. Progress is mapped by range, so delay is inert here — a position-driven animation has no clock to delay.

load is plain time-based, plays once on load, honours delay. Use it for above-the-fold content. This matters: a scroll-timeline element that is already in view when the page loads renders mid-progress, which usually looks like a bug.

view is the same animation as load, but started paused in CSS and released by a script when the element enters the viewport. Use it for a staggered group below the fold — which scroll cannot do, because each element gets its own timeline and a row of three would animate simultaneously.

Degradation

Off when siteSettings.useAnimations is false: the wrapper becomes a plain box with only your class, no data hooks, so the observer never runs either.

Under prefers-reduced-motion, the global guard handles time-based triggers. trigger="scroll" additionally carries motion-reduce:animate-none, which is required, not belt-and-braces — see below.

Where native scroll timelines are unsupported (Firefox at time of writing), the timeline is ignored and the animation plays once on load. Content still ends visible; no JavaScript fallback is shipped.

The display:contents gotcha

<Reveal> must render a real box. display: contents removes the element from the layout tree, and a scroll timeline needs a box to track. Use the as prop to change the tag; never class="contents".

Accessibility

Two switches, completely independent, and confusing them is the most common mistake.

prefers-reduced-motion is a user need. It is always honoured, never gated behind config. A global guard at the bottom of motion/index.css handles it:

@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;
    animation-delay: 0s !important;
    transition-delay: 0s !important;
  }
}

Note the near-zero rather than none: animationend and transitionend listeners still fire, so code waiting on them does not hang. Delays are zeroed too, because a staggered entrance pairs a delay with a both fill — and backwards fill holds the 0% keyframe, so zeroing duration alone would leave a reduced-motion visitor staring at a blank hero followed by a hard pop-in.

The guard cannot stop scroll-driven animations. They are progressed by scroll position, not time, so zeroing duration does nothing. Anything you drive with timeline-* must also carry motion-reduce:animate-none. That is not optional, and it is exactly what Reveal’s scroll trigger does for you.

It also cannot reach JavaScript-driven motion, which is why Magnetic, PageTransition and TextReveal each read prefersReducedMotion() live.

siteSettings.useAnimations is a design choice. It is the master switch for the decorative layer — scroll reveals, ambient loops, the WebGL frames, the page-transition curtain. It does not gate intentional micro-interactions: a rotating dropdown chevron is UX, not decoration.

The other motion primitives

PageTransition is the route-change curtain: a 7×13 grid of cells that scale up from the bottom-right corner on a diagonal front, hold, and scale back out while the new page swaps underneath. It is mounted once in BaseLayout with transition:persist, and it needs both useAnimations and useViewTransitions. It wraps the router’s loader so the fetch and the curtain overlap rather than queueing.

TextReveal splits text for a decode effect: scramble (per character, flashing random letters before landing) or words (per word, in random order). It reads textContent and rebuilds the element, so plain text only — markup inside is discarded. Accessibility is handled by putting the original string in aria-label and hiding every generated span.

Magnetic pulls its child toward the pointer within a proximity ring made of padding cancelled by a matching negative margin, so listeners bind to the element itself and die with it. strength defaults to 0.4.

Marquee is a pure-CSS seamless scroller with direction, speed and pauseOnHover props. Its keyframes live in tailwind-theme.css rather than the motion catalog, alongside the --marquee-gap variable that must match the flex gap or the loop seams.

CircularText draws text around a ring using native SVG textPath and owns no motion at all — wrap it in <Reveal animation="rotate-360" range="cover"> to turn it.

Two gotchas that will cost you an afternoon

Use overflow-clip, never overflow-hidden, on a frame containing a scroll-driven child. A hidden box is a scroll container, so view() tracks that box — which never scrolls — instead of the page, and the animation freezes at its start. Every clipping frame in the theme uses overflow-clip for this reason.

One element runs only the animation declared last. The CSS animation shorthand is a single property, so a second animate-* class on the same element wins outright and the first never plays. An element that should both arrive and rotate needs two nested elements — which is exactly why the home page’s About badge is a <Reveal> inside a <Reveal>, and why CircularText ships as a wrapper plus an SVG.

A related trap: Tailwind v4 emits the scale property, not transform: scale(). Animating transform on an element carrying scale-0 multiplies the two and pins it at zero.

Adding a keyframe

Two files, and one conditional third step.

1. Add the @keyframes to src/styles/motion/keyframes.css, inside an @theme inline block — that is what lets Tailwind tree-shake it when unused.

2. Add the token to the @theme inline block in src/styles/motion/index.css:

--animate-my-shape: my-shape 0.6s ease-out both;

Tailwind resolves names across @theme blocks, so the token and the keyframe living in different files is fine.

3. If the shape is scroll-only — its 100% state is not the resting state, so playing it once time-based would leave content wrong — add it to the support guard:

@supports not (animation-timeline: view()) {
  [class*="animate-parallax-up"],
  [class*="animate-my-shape"] {
    animation: none !important;
  }
}

That is what stops Firefox from playing a parallax once and leaving the content offset.

Verifying

Open /examples/ui and scroll to the Motion section — it demos nearly the whole catalog. Check it in light and dark, and with your OS “reduce motion” setting both on and off.

If a class seems inert, it was probably tree-shaken. Keyframes are emitted only when a utility is actually used in a scanned source file, so run pnpm build and grep dist for the @keyframes before assuming the CSS is wrong.

That same mechanism has a cost worth knowing: the dev catalog demos almost every animation, and Tailwind scans its markup even though it builds no production pages. 91 of the 101 keyframes in the production bundle are referenced by no built page. Deleting src/components/Sections/UiCatalog/ and src/pages/examples/ before launch reclaims about 19 KB of CSS. Do not try to fix it with a @source not rule — that was tried and reverted, because the directive is not build-mode conditional and it silently breaks the dev catalog too.

NEXT STEPSEO & Structured Data