Skip to content
AstroCraft Docs
On this theme

Motion

Medice’s animation catalog is owned rather than installed: 87 animate-* utilities and their keyframes are source files in src/styles/motion/, a dependency-free port of tailwind-animations adapted to Tailwind v4’s CSS-first shape.

It is a port rather than a package for the same reason the SEO layer and the icon set are: a keyframe library is content, not infrastructure. The upstream is tailwind-animations by Miguel Ángel Durán and community, MIT-licensed, and the class names match the upstream site so its documentation applies one-to-one.

Two files

src/styles/motion/index.css is the entry and the tunable vocabulary — the value tokens, the --animate-* shorthands, the modifier utilities, the support guard and the reduced-motion guard.

src/styles/motion/keyframes.css holds the 87 @keyframes those shorthands reference, kept in @theme so Tailwind still tree-shakes them.

Both are imported once, from global.css, after Tailwind and the theme file.

The catalog

Every --animate-* token becomes an animate-* utility. The 87 cover the usual families — fades in eight directions, slides, zooms, rotations, flips, and the expressive set (bouncing, swing, wobble, tada, jelly, heartbeat, rubber-band, jiggle) — plus an owned scroll-driven extension: progress, parallax-up, parallax-down, ken-burns, fade-through and wipe-in-*.

Three changes from upstream are worth knowing. --animate-pulse was dropped because it is identical to Tailwind’s built-in animate-pulse. A global prefers-reduced-motion guard was added, which the upstream ships without. And the scroll-driven extension is this project’s own.

Tuning an animation

Twenty-four modifier utilities shape any animate-*:

<div class="animate-fade-in-up animate-duration-700 animate-delay-200 animate-bezier-out">

The families are animate-duration-*, animate-delay-*, animate-bezier-*, animate-iteration-count-*, animate-range-*, animate-fill-*, animate-direction-*, plus the timeline utilities timeline-view, view-timeline-name-* and timeline-scope-*.

The delay tokens carry a tw-anim- prefix internally so they do not clash with Tailwind’s transition delay-* utilities — a distinction that matters if you are ever reading the generated CSS.

Reveal: scroll animation with no JavaScript

src/components/ui/reveal/Reveal.astro is the primitive most sections use, and it ships zero JavaScript. It composes an entrance animation driven by the native scroll timeline — timeline-view compiles to animation-timeline: view() — so the element animates as it scrolls into view, with progress mapped through animate-range-*.

<Reveal animation="fade-in-up">
  <Card>…</Card>
</Reveal>

Two guards are built into it by design.

motion-reduce:animate-none is required, not belt-and-braces. The global reduced-motion guard zeroes time durations, but a scroll-driven animation is progressed by scroll position rather than by time, so zeroing a duration does nothing to it. It has to be removed explicitly.

siteSettings.useAnimations is the build-time master switch; with it off, Reveal is a plain pass-through wrapper. An animate prop overrides per call site, which keeps the primitive usable without touching config.

One constraint: the wrapper needs a real box, so as renders an element rather than display: contents — a contents box has no geometry for a view timeline to measure.

Where scroll timelines are unsupported

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

For entrances that is fine: they end at identity, so content ends visible. That is Reveal’s documented degradation, and no JavaScript fallback ships.

For the scroll-only shapes it is not fine — a played-once parallax leaves content 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: its identity end state — a full bar — is exactly what animation: none would render anyway.

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

The reduced-motion guard

@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;
  }
}

This is not in the upstream library; it was added here because accessibility is not optional.

Two details are deliberate. The durations are near-zero rather than none, so animationend and transitionend listeners still fire — a script waiting on one of those would hang forever otherwise. And it neutralises scroll-behavior: smooth, which global.css sets on <html>.

This is the single global source for reduced-motion resets. Individual primitives keep their own motion-reduce: classes for clarity, and Reveal needs one for the reason above.

The Marquee’s animation lives elsewhere

--animate-marquee and --animate-marquee-vertical are in tailwind-theme.css rather than in the motion catalog, because they are the Marquee primitive’s own mechanism rather than a general utility.

The maths is worth knowing if you use it: each track shifts one full copy width plus one gap, so with identical copies laid side by side the next copy is already in place when one scrolls out and the loop has no seam. --marquee-gap must match the flex gap between the copies or the arithmetic breaks.

A sidecar stylesheet must not declare a layer

If you add a stylesheet of your own alongside these, do not wrap it in @layer. The declared order is theme, base, components, utilities, and a sidecar that declares its own layer lands in the wrong place relative to the reduced-motion guard — which is one of the few ways to ship an animation that ignores a user’s accessibility setting.

NEXT STEPSEO