Skip to content
AstroCraft Docs
On this theme

Motion & Animation

Urengi 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, and a global reduced-motion guard the upstream library does not ship.

There is no GSAP, no Framer Motion, no animation package at all. Three of the motion primitives are dependency-free ports of GSAP recipes; none of them costs a dependency.

The three files

src/styles/motion/
├── index.css            # the entry: value tokens, --animate-* shorthands, modifier utilities,
│                        #   the @supports guard and the reduced-motion guard
├── keyframes.css        # ~80 time-based entrances, ported. Vendored, homogeneous, rarely touched.
└── keyframes-scroll.css # the scroll-driven shapes. OURS, and where new ones land.

index.css is imported once from global.css, after Tailwind and the theme file. The keyframes stay in @theme in their own files so Tailwind can still tree-shake them.

The split is by ownership rather than by size: the ported entrances are stable and you will rarely open them; the scroll-driven shapes — progress, parallax-up, parallax-down, ken-burns, fade-through, wipe-in-* — are the theme’s own and are where a new one belongs.

Using an animation

Every --animate-* token becomes an animate-* utility:

<div class="animate-fade-in-up animate-duration-slow animate-delay-200">…</div>

The class names match the upstream library’s, so its documentation applies one to one.

The modifier utilities are the tunable half:

Modifier Sets
animate-duration-* faster fast normal slow slower, or ms
animate-delay-* 0 through 1000
animate-ease*, animate-bezier-*, animate-linear the timing function
animate-iteration-count-* how many times
animate-fill-mode-*, animate-steps-* fill and stepping
animate-direction-* normal reverse alternate alternate-reverse
animate-play-running, animate-play-paused play state
timeline-view, timeline-*, *-timeline-axis-*, *-timeline-name-* scroll timelines
animate-range-* which slice of view-progress an animation plays over

The delay and duration tokens carry a tw-anim- prefix so they cannot clash with Tailwind’s own transition delay-*.

The Marquee primitive’s --animate-marquee and --animate-marquee-vertical deliberately live in tailwind-theme.css rather than here, next to the --marquee-gap they depend on.

The seven motion primitives

They split by what they animate and what drives them. Reach for the cheapest that fits.

Primitive Animates Driven by Reach for it when
Reveal one box native scroll timeline (zero-JS) the default reveal-on-scroll
CurtainReveal one box native scroll timeline (zero-JS) content should be unveiled behind a moving window
ImageReveal a photograph native scroll timeline (zero-JS) a photo enters oversized and settles inside a fixed frame
StaggerReveal a group of siblings IntersectionObserver + CSS you need a fixed per-item time stagger across a batch
SplitReveal a string IntersectionObserver + CSS words or lines should rise out of a clip mask
CountUp a number IntersectionObserver + rAF a figure should tick up when its strip scrolls in
RollText a string :hover / :focus-visible a per-character roll on a button or link label
<Reveal animation="fade-in-up" range="entry">
  <StatStrip … />
</Reveal>

Two usage rules save real debugging time.

ImageReveal is for photographs, not UI mocks. A scale-settle makes a screenshot’s crisp 1px borders visibly swim, and it scales the frame wider than its own clip, so at rest the mock reads as sliced off rather than as an effect. Sections/Global/DeviceFrame.astro records the measurement.

Never make a StaggerReveal child responsively hidden. The reveal units are the wrapper’s direct children, each revealed when it intersects. A child that is display:none when the batch plays never intersects, so it keeps its armed opacity: 0 — and if a later resize un-hides it, it renders invisible but still clickable. Put the lg:hidden / hidden lg:flex swap on an element inside a reveal unit, so the unit itself is always visible. The Header wraps its desktop pill and mobile hamburger in one always-visible child for exactly this reason; the bug it avoids is a vanishing hamburger on a desktop-to-mobile resize.

SplitReveal and StaggerReveal split their text at build time — the text is a text prop rather than a slot, precisely so the server can split it — and arm behind html.js, the pre-paint marker BaseHead’s inline script sets. That is what stops above-the-fold text painting visible and then flash-hiding when the bundled script arms it. No JS means no .js class, which means the content simply shows.

RollText animates off the nearest Tailwind group, so put group on the wrapping Button or link. Screen readers get the intact label via sr-only; the visual characters are aria-hidden duplicates.

The two switches

They are independent, and conflating them is the mistake to avoid.

siteSettings.useAnimations is a build-time design choice. Off, and the motion primitives become plain pass-through wrappers. Every primitive reads it through one helper:

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

Each primitive also takes an animate prop that wins over the site setting, which is what keeps them usable config-free.

prefers-reduced-motion is a user need, and it is honored regardless of what the flag says. Never gate it behind the design switch.

The reduced-motion guard

Not in the upstream library. Added here because accessibility is not optional:

@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 — a component waiting on one would otherwise hang forever. It also neutralises the scroll-behavior: smooth set on html.

This guard alone is not enough, and the reason is worth understanding. It zeroes time durations. A scroll-driven animation is progressed by scroll position, not time, so zeroing its duration does nothing at all. Every scroll-driven primitive therefore also carries motion-reduce:animate-none — belt and braces only if you have not read the mechanism.

The same applies to the reveal primitives whose resting state is a transform rather than a duration: they each carry their own prefers-reduced-motion reset in their scoped styles, because a zeroed duration on an element armed at opacity: 0 leaves it at opacity: 0.

The scroll-timeline support guard

Native scroll timelines are Chromium and Safari. Where they are unsupported, the animation-timeline declaration is simply dropped and the animation runs once, time-based.

That is fine for entrances — they end at identity, so content ends visible, and it is Reveal’s documented degradation. It is not fine for the scroll-only shapes: a played-once parallax leaves content offset, and a played-once 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 identity end state — a full bar — is exactly what animation: none would render anyway.

The substring match is deliberate, so variant-prefixed uses like md:animate-parallax-up are covered. Add any new scroll-only animation to that list; the ceiling is a false positive on a future class that merely contains one of those names.

For above-the-fold content, prefer a plain animate-* utility over Reveal. There is nothing to scroll into view yet, and the timeline buys you nothing.

Adding an animation

  1. Add the @keyframes to keyframes-scroll.css if it is scroll-driven, or to keyframes.css if it is a time-based entrance.
  2. Add its --animate-<name> shorthand in the same @theme block, so animate-<name> becomes a utility.
  3. If it is scroll-only, add it to the @supports list above.
  4. If it holds a transform at rest, add a prefers-reduced-motion reset for it.
  5. Demonstrate it in the motion panel of /examples/ui.

The cost of the catalog

The shared stylesheet ships 96 @keyframes and 118,821 bytes with the dev catalog in the tree, while the site’s own pages reach for roughly nineteen of the ninety-one animations. Tailwind tree-shakes what nothing references — but it scans the catalog’s markup whether or not the catalog builds a page, so deleting Sections/UiCatalog/ and src/pages/examples/ together is what actually shrinks the sheet. See Deployment.

NEXT STEPSEO & Structured Data