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@keyframesthose shorthands reference, kept in@themeso 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 shapes — progress, 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 shapes — char-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, prefixedtw-anim-internally so it never clashes with Tailwind’s transitiondelay-*animate-bezier-*— 24 named curves, sine through back, in and out and in-outanimate-ease,-in,-out,-in-out,animate-linearanimate-iteration-count-*,animate-fill-mode-*,animate-steps-*animate-direction-*,animate-play-running/-pausedanimate-range-*andtimeline-*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
- Add the
@keyframestomotion/keyframes.css. - Add its
--animate-<name>shorthand to the predefined block inmotion/index.css. The utility appears automatically. - If it is scroll-only — anything whose end state is not identity — add it to the
@supportsguard, or it will play once and leave content stranded in browsers without scroll timelines. - If it will be driven by a
timeline-*, make sure the call site carriesmotion-reduce:animate-none. - 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.