Skip to content
AstroCraft Docs
On this theme

Motion & Animation

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

It is owned rather than installed for the same reason the SEO layer is: a keyframe set is content, not build infrastructure. The port is one @import, tree-shakes to nothing when unused, and keeps the upstream API identical, so tailwind-animations.com’s documentation applies to these class names one-to-one.

The catalog

Two files, imported once from global.css right after the theme file.

motion/index.css is the entry and the tunable vocabulary: the value tokens, the --animate-* shorthands, the modifier utilities and the guards. motion/keyframes.css holds the 88 raw @keyframes, kept in @theme there so Tailwind still tree-shakes them.

78 time-based utilities cover entrances, exits, attention-seekers, transforms and continuous loops — animate-fade-in-up, animate-zoom-in, animate-shake, animate-jelly, animate-heartbeat, animate-rubber-band and the rest. Nine scroll-driven utilities make up the owned extension. That is 87 in total, built on Tailwind v4’s @theme--animate-* → utility mechanism, the same mechanism the Marquee’s own keyframes use from tailwind-theme.css.

On top sits a full modifier layer, all Tailwind v4 functional utilities:

Modifier Controls
animate-duration-* animation-duration (named steps or arbitrary)
animate-delay-* animation-delay
animate-bezier-* 24 named easing curves
animate-ease, animate-linear, animate-ease-in-out, … the standard keywords
animate-iteration-count-* repeat count
animate-fill-mode-* animation-fill-mode
animate-steps-* stepped timing
animate-direction-* normal / reverse / alternate
animate-play-running, animate-play-paused play state
timeline-* animation-timeline (timeline-view, timeline-scroll)
animate-range-* animation-range
scroll-timeline-axis-*, view-timeline-axis-* timeline axis
scroll-timeline-name-*, view-timeline-name-*, timeline-scope-* named timelines

The named-timeline trio is the theme’s own addition. It lets you declare a timeline on one element, hoist it to a common ancestor and drive an animation on a different element — a fixed reading bar tracking an article, for instance.

Three deliberate deviations from upstream

--animate-pulse is omitted. It is identical to Tailwind’s built-in animate-pulse, which the Skeleton primitive uses. Likewise animate-spin, animate-bounce and animate-ping stay built-ins. Use them directly.

A reduced-motion guard was added. The upstream ships none, and accessibility is not optional here.

A scroll-driven extension was written. The end of keyframes.css adds keyframes shaped for timeline scrubbing rather than time: progress (a reading bar, with timeline-scroll origin-left), parallax-up / parallax-down and ken-burns (with timeline-view animate-range-cover), fade-through (in on viewport entry, out on exit), and the dual-use wipe-in-up / -down / -left / -right clip reveals.

These are linear on purpose. A scrubbed animation should map scroll progress one-to-one; the easing is the user’s scroll.

Two gotchas that will cost you an afternoon

Clipping frames for parallax and zoom must use overflow-clip, not overflow-hidden. A hidden box is a scroll container, so view() resolves against the frame — which never scrolls — instead of the page scrollport, and the animation freezes at its start value. It looks like the animation is broken. It is not; it is attached to the wrong timeline.

Anything driven by timeline-* must also carry motion-reduce:animate-none. This is the reduced-motion guard’s one structural blind spot, and it is worth understanding rather than memorising. The guard zeroes animation and transition durations. A scroll-driven animation is progressed by scroll position, not by time, so zeroing its duration does nothing at all. The only way to stop it is to remove the animation.

Every reveal primitive in the theme carries that class. Anything you write with timeline-* needs it too.

The reduced-motion guard

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, deliberately: animationend and transitionend listeners still fire, so a component waiting on one does not hang. It also neutralises the unconditional scroll-behavior: smooth set on <html> in global.css.

This is the single global source for reduced-motion resets. Individual primitives keep their own motion-reduce: utilities as well — Marquee, Skeleton, Tooltip, the accordion and dropdown chevrons — which is the house discipline rather than redundancy.

The scroll-timeline support guard

Where native scroll timelines are unsupported (Firefox at time of writing), an animation-timeline declaration is simply dropped and the animation runs once, time-based.

For entrances that is fine — they end at identity, so content ends visible, which is <Reveal>’s documented degradation. For the scroll-only shapes it is not: a played-once parallax leaves content permanently offset, and fade-through ends invisible. So those are made 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;
  }
}

progress is exempt, because its end state is a full bar — exactly what animation: none would render anyway.

If you add a scroll-only keyframe, add it to that list. The guard is a substring match, so variant-prefixed uses like md:animate-parallax-up are covered; the stated ceiling is a false positive on any future class name containing one of these strings.

Two independent switches

Motion answers to two orthogonal controls, and conflating them is the mistake to avoid.

prefers-reduced-motion is a user need. It is always honored through the global guard and is never gated behind config.

siteSettings.useAnimations is a brand and design choice, read at build time. It is the master switch for the decorative motion layer — the scroll reveals, the ambient loops, the panel parallax. It does not govern intentional micro-interactions: a rotating dropdown chevron is UX, not decoration, and neither is the CTA button’s text roll.

Turn useAnimations off and the reveal primitives become plain pass-through wrappers. Content is still there, still visible, just static.

The four reveal primitives

Reveal wraps content and plays an entrance animate-* driven by the native scroll timeline (timeline-view is animation-timeline: view()), with progress mapped to animate-range-*. It is zero-JS. Eleven animations and seven ranges are available as variants, defaulting to fade-in-up over entry.

Use it for below-the-fold content. For anything above the fold, prefer a plain time-based animate-* — an element already in view when the page loads renders mid-progress, which looks like a rendering bug.

Its ceiling is stated in the file: native scroll timelines are Chromium and Safari; where unsupported the animation plays once on load instead of on scroll. Content still ends visible, and no JavaScript fallback ships.

SplitReveal is the text version. It splits a text prop into words at build time (the same trick RollText uses, which is why the text is a prop and not a slot), wraps each in an overflow: clip mask with a rising inner span, and plays a staggered rise once on IntersectionObserver entry. split="words" staggers per word; split="lines" groups the word masks into visual lines by their settled offsetTop at reveal time, so it responds to actual wrapping and a line rises as one unit. It is a dependency-free port of the GSAP SplitText recipes — no GSAP.

StaggerReveal is the batched version for a group of siblings. Its slotted direct children start hidden and lifted, and children crossing into view together form one batch cascaded by staggerMs — a dependency-free take on GSAP’s ScrollTrigger.batch. Reach for it when you need a consistent time-based stagger across items, which the scroll-progress-driven Reveal cannot hold. It is the house card-reveal recipe: the blog grid, the FAQ, the testimonials and the integration cards all use it.

GrainyPanel is not strictly a reveal but belongs here. It is the fixed-dark decorative panel — a base-950 rounded box layered with a purple perspective-grid floor, CSS blur-glows and a feTurbulence grain overlay. The grid carries an eased mouse parallax: a small script lerps its translate3d toward the pointer. Pointer-only, gated on useAnimations, skipped under prefers-reduced-motion, static with no JavaScript. The glow and grain stay still so the floor always covers the bottom edge.

All four degrade to visible static content with no JavaScript, and all four honor reduced motion.

The other native mechanisms

Motion is not only the catalog. Four native mechanisms are proven in the codebase, and the rule of thumb is to climb that ladder before reaching for a script:

  1. Scroll-driven animations (timeline-*) — the reveals, the reading progress bar.
  2. @starting-style + allow-discrete — the Dialog and Sheet enter/exit transitions in ui/_overlay.css, including the backdrop scrim and the modal scroll lock. Real transitions on a native <dialog>, honoring reduced motion.
  3. View transitions via <ClientRouter>, gated on siteSettings.useViewTransitions. Add transition:name to a shared element for near-free morphing between pages.
  4. A bundled script, through the shared onReady contract in _client.ts. Last resort.

The Marquee sits slightly apart: its keyframes live in tailwind-theme.css beside the tokens they read, because --marquee-duration and --marquee-gap are per-instance knobs rather than catalog entries.

Checking your work

/examples/ui has a Motion section rendering every catalog animation — hover a tile to replay it — plus modifier composition and live <Reveal> demos. Look at it in light and dark, and with your OS “reduce motion” setting on.

One thing that looks like a bug and is not: keyframes only emit when a utility is actually used, so a class that seems inert is usually just tree-shaken because nothing referenced it at build time. Confirm with pnpm build and grep dist/ for the @keyframes name.

Do not pnpm add an animation library. The catalog is the vocabulary, it is yours, and adding one puts two sources of truth in the same stylesheet.

NEXT STEPSEO & Structured Data