Skip to content
AstroCraft Docs
On this theme

Motion & Animation

Finly owns its motion layer. src/styles/motion/ is a dependency-free port of tailwind-animations, adapted to this theme’s conventions — 91 animate-* utilities, a full modifier set, an owned scroll-driven extension, an owned text-reveal set, and a global reduced-motion guard the upstream library does not ship.

There is no animation package in package.json, and there should not be one. A keyframe library is content, not infrastructure.

The catalog

Two files, imported once from global.css:

  • motion/index.css — the entry and the tunable vocabulary: value tokens, the 91 --animate-* shorthands, the modifier utilities, the support guards and the reduced-motion guard.
  • motion/keyframes.css — the 91 @keyframes those shorthands reference, kept in @theme so Tailwind still tree-shakes them.

Every --animate-* becomes an animate-* utility. --animate-fade-in-up gives you animate-fade-in-up. The names match tailwind-animations.com one-for-one, so that library’s documentation applies directly to the ported set.

Three groups sit on top of the port, and they are the theme’s own:

Scroll-driven shapesprogress, progress-y, parallax-up, parallax-down, ken-burns, fade-through, and the four wipe-in-* directions. These are linear on purpose: a scrubbed animation should map scroll progress one-to-one, because the easing is the user’s scroll.

Text-reveal shapeschar-rise, line-rise and draw-line, the three gestures the reveal primitives compose. Unlike the scroll-driven block these are time-based and end at identity, so they are safe to play on load. Their easings are the design brief’s GSAP curves spelled as literals, because a @theme value cannot reference another token in the same block.

The marquee pair lives in tailwind-theme.css rather than here, beside the primitive that uses it.

One upstream animation is deliberately omitted: --animate-pulse is identical to Tailwind’s built-in animate-pulse, which the Skeleton primitive already uses.

The modifiers

Any animate-* is tunable without touching the keyframe:

<div class="animate-fade-in-up animate-duration-slow animate-delay-200 animate-bezier-quart-out">
  • animate-duration-* — named (fast, normal, slower) or numeric (animate-duration-700)
  • animate-delay-* — same, prefixed tw-anim- internally so it never clashes with Tailwind’s transition delay-*
  • animate-bezier-* — 24 named curves, sine through back, in and out and in-out
  • animate-ease, -in, -out, -in-out, animate-linear
  • animate-iteration-count-*, animate-fill-mode-*, animate-steps-*
  • animate-direction-*, animate-play-running / -paused
  • animate-range-* and timeline-* for scroll-driven work

Scroll-driven animation

The timeline-* utility sets animation-timeline, which is what makes a zero-JavaScript scroll reveal possible:

<div class="animate-fade-in-up timeline-view animate-range-gradual">

Named timelines are the theme’s own extension, and they let one element be driven by another element’s scroll. Declare on the tracked element with view-timeline-name-[--article], hoist with timeline-scope-[--article] on a common ancestor, and consume anywhere in that scope with timeline-[--article]. The canonical use is the blog post’s reading rail tracking the article.

One trap is documented in the token list itself. timeline-scroll resolves to scroll(), which finds the nearest scroll container in the containing-block chain — and a position: fixed element’s containing block is the viewport, so it has no such ancestor. The timeline is then inactive and the animation freezes at its 0% keyframe, silently, because it still renders and simply never progresses. Anything fixed and driven by page scroll needs timeline-scroll-root (scroll(root)) instead.

The other trap is overflow-hidden. A hidden box is a scroll container, which freezes any scroll timeline inside it. This is why the Section primitive’s clip variant is overflow-clip and never overflow-hidden, and why MaskedImage deliberately has no clip at all — the mask already clips.

Where scroll timelines are unsupported, an animation-timeline declaration is simply dropped and the animation plays once, time-based. That is fine for entrances, which end at identity — content ends visible. It is not fine for the scroll-only shapes: a played-once parallax leaves content offset, and fade-through ends invisible. So those four are made inert by a @supports guard 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; }
}

Add any new scroll-only animation to that list.

The Reveal primitive, and its two triggers

<Reveal> is the wrapper most bands use, and the choice of trigger is not cosmetic.

trigger="scroll" (the default) drives the entrance off the native view() timeline. Zero JavaScript, and the reveal is scrubbed — it advances and rewinds with the wheel. range selects the window of view-progress it plays over.

trigger="sight" plays the same entrance as a real tween, at its authored duration and easing, started when the element first scrolls into view. range does not apply.

The rule of thumb is in the primitive’s own header: scrubbing suits ambient decoration, and a reveal that reads as a gesture — copy, a card, a logo strip — usually wants sight. A scrubbed easing curve is not the curve anyone authored.

The play-on-sight controller

Sight mode is _reveal.ts, one IntersectionObserver pass per document, shared with SplitText and SentenceReveal and wired through _client.ts. Its contract is two attributes and one CSS rule, and the design of it is worth understanding because it is what keeps a no-JavaScript visitor from seeing a blank page.

[data-at-rest]:not([data-armed]) { animation-name: none; }
[data-armed]                     { animation-play-state: paused; }

Both rules are unlayered, which is what lets them outrank the animate-* utilities without !important and without any inline style.

Suppressing the animation name is the key move. It means the element renders its natural finished state — so if the script never runs, a bundle fails, or the observer never fires, the content is simply there. Never a 0% keyframe, which for these reveals is opacity: 0 and would silently blank the text.

data-armed closes a window that produced a visible bug. The observer only fires once part of the element is on screen, so releasing it there snapped a fully painted card to its 0% frame and faded it back in — a blink on every card. The controller now arms each at-rest element while it is still out of sight: the real keyframe applies but the clock is held at 0%, so the element is already in its start state before it can be seen. Releasing is dropping both attributes, which un-suppresses and un-pauses in one step.

The text-reveal primitives

Three primitives compose those shapes, and all three split at build time — which is why each takes its text as a prop rather than a slot: the server needs the string in order to split it.

SplitText is the heading reveal. Every character drops in from above out of its own overflow-clip window, staggered by a per-character inline animation-delay. The clip window is load-bearing rather than decorative: the keyframe is a pure translate with no opacity, so the window is the only thing hiding the glyph. Words are flex items with a gap-x rather than inline-blocks separated by spaces, because Astro 7 strips JSX whitespace and the gap has to supply the line-break opportunity structurally. The trade-off is that text-balance no longer applies, so text takes \n for designed line breaks. The intact string is exposed sr-only with the glyphs aria-hidden.

SentenceReveal is the paragraph reveal. Each chunk is two elements — an overflow-clip window and the block that moves inside it. It splits on sentence and clause enders (. ! ? — – : ;, never commas) at build time, which is one deliberate deviation from the GSAP original: GSAP splits visual lines, which only exist after layout, so it measures in the browser and re-splits on resize. Build-time splitting is zero-JavaScript, cannot desync from a reflow, and lands the chunks where a reader pauses.

CountUp is the number reveal, and the one animation here that genuinely needs JavaScript — a keyframe cannot interpolate text content. It is built to the house rules for that: the final value is server-rendered, so no-JS and reduced-motion show the real figure; it counts on first sight rather than on load; and it wires through onReady with a cleanup that disconnects the observer and cancels the frame. formatCount is shared between build and browser so the server’s value and the counting frames cannot disagree, and it carries a runnable check.

Two switches, and only one is yours

siteSettings.useAnimations is the site-wide master switch for decorative motion, read at build time. Off means Reveal becomes a plain pass-through wrapper. A call site can override it per instance with an animate prop.

The precedence is written in exactly one place, _motion.ts, because four primitives were each spelling it out by hand:

export function animationsOn(override?: boolean): boolean {
  return override ?? siteSettings.useAnimations ?? true;
}

prefers-reduced-motion is not that switch, and is never gated behind config. It is a user need, always honored, handled by a global guard at the bottom of motion/index.css:

@media (prefers-reduced-motion: reduce) {
  *, ::before, ::after {
    animation-duration: 0.01ms !important;
    animation-delay: 0ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !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>.

Delays are zeroed too, and that is load-bearing rather than tidiness. A zero duration on a staggered set still leaves every item waiting out its own delay, so a thirty-character title would still pop in one glyph at a time over more than a second — the exact drip the preference asks us not to show. That covers animate-delay-*, Tailwind’s delay-*, and the inline per-item delays the stagger primitives write.

The guard cannot stop a scroll-driven animation, because it zeroes time and a scroll timeline is progressed by position. So any element carrying timeline-* must also carry motion-reduce:animate-none. That is not belt-and-braces; it is the only thing that works. Reveal carries it in its base for exactly this reason.

Adding an animation

  1. Add the @keyframes to motion/keyframes.css.
  2. Add its --animate-<name> shorthand to the predefined block in motion/index.css. The utility appears automatically.
  3. If it is scroll-only — anything whose end state is not identity — add it to the @supports guard, or it will play once and leave content stranded in browsers without scroll timelines.
  4. If it will be driven by a timeline-*, make sure the call site carries motion-reduce:animate-none.
  5. Demo it in the motion panel of /examples/ui/, and check it with reduced motion on.

For above-the-fold content, prefer a plain animate-* utility over either reveal trigger — there is no scroll to drive it and no first sight to wait for.

NEXT STEPSEO & Structured Data