Skip to content
AstroCraft Docs
On this theme

Motion & Animation

8-BitQuest ships an owned, dependency-free motion vocabulary and a set of native-first patterns for putting it on the page. The through-line matches the rest of the theme: prefer the platform, own the catalog rather than vendor it, and never trade away accessibility. The house rules are in .claude/rules/motion.md; this page is the map.

The catalog

The heart is src/styles/motion/index.css (the value tokens, the --animate-* shorthands, the modifier utilities and the guards) plus keyframes.css (the raw @keyframes). It is a CSS port of tailwind-animations (© midudev, MIT), adapted to the theme’s conventions and imported once from global.css right after the theme file. It is owned for the same reason the theme owns its icons and SEO: a keyframe set is content, not build infrastructure, and the API is identical to the upstream, so tailwind-animations.com’s docs apply directly.

It provides 87 utilities in total: 78 time-based animate-* classes across entrances, exits, attention-seekers, transforms and continuous loops (animate-fade-in-up, animate-zoom-in, animate-shake, animate-jelly, and so on), plus a 9-utility scroll-driven extension. On top of the animations sits a full modifier layer, all Tailwind v4 functional utilities:

  • animate-duration-*, animate-delay-*, animate-bezier-* (24 easing curves)
  • animate-iteration-count-*, animate-fill-mode-*, animate-steps-*, animate-direction-*, animate-play-*
  • the native scroll-driven helpers — timeline-* (animation-timeline), animate-range-* (animation-range), the axis and named-timeline utilities for driving one element’s animation from another’s scroll

They compose the way Tailwind utilities do: animate-fade-in-up animate-duration-500 animate-delay-200.

Three things differ from upstream on purpose. --animate-pulse is omitted — it is identical to Tailwind’s built-in animate-pulse, which the Skeleton primitive uses (likewise animate-spin/bounce/ping stay built-ins). A reduced-motion guard is added (the upstream ships none). And an owned scroll-driven extension adds keyframes shaped for timeline scrubbing rather than time: progress (a reading bar), parallax-up/parallax-down, ken-burns, fade-through, and the wipe-in-* clip reveals.

The two switches

Motion answers to two orthogonal controls, and keeping them separate is the whole accessibility story:

  • prefers-reduced-motion is a user need. It is always honoured, never gated behind config. A single global guard at the bottom of motion/index.css zeroes animation and transition durations site-wide (to near-zero rather than none, so animationend/transitionend listeners still fire) and forces scroll-behavior: auto.
  • siteSettings.useAnimations is a brand/design choice. It is the master switch for the decorative motion layer — scroll reveals, ambient loops — read at build time. It does not govern intentional micro-interactions; a rotating accordion chevron is UX, not decoration, and stays on.

The one blind spot follows from how scroll animations work: they are progressed by scroll position, not time, so zeroing durations does not stop them. Anything driven by a scroll timeline must also carry motion-reduce:animate-none — which is exactly how the <Reveal> primitive stays accessible.

The <Reveal> primitive

src/components/ui/reveal/Reveal.astro is the reveal-on-scroll wrapper and the one motion primitive. It composes the catalog — an entrance animate-* driven by the native scroll timeline (animation-timeline: view()) — so it is zero-JavaScript. It follows siteSettings.useAnimations by default (off means it becomes a plain pass-through wrapper), takes a per-call animate prop to override that, and carries motion-reduce:animate-none so reduced motion degrades to static content.

---
import { Reveal } from "@components/ui/reveal";
---

<Reveal animation="fade-in-up">
  <ProjectCard />
</Reveal>

Its ceiling, noted in the file: native scroll timelines are Chromium and Safari. Where they are unsupported (Firefox), the animation plays once on load instead of on scroll — content still ends visible, and no JavaScript fallback is shipped. Use <Reveal> for below-the-fold content; for something above the fold, prefer a plain time-based animate-*, because a scroll element already in view renders mid-progress.

The rest of the subsystem

Motion is more than the catalog. Four native mechanisms are used across the theme, and the rule of thumb is to climb this ladder before reaching for JavaScript:

  1. Scroll-driven timelines (<Reveal>, the reading progress bar).
  2. @starting-style enter/exit transitions with allow-discrete — the Dialog and Sheet overlays in ui/_overlay.css.
  3. View transitions through <ClientRouter>, gated on useViewTransitions — add a transition:name to a shared element for near-free morphing.
  4. A bundled script, re-initialised on astro:after-swap through the shared _client.ts contract — the last resort, for the rare case native cannot cover.

The pure-CSS Marquee primitive (its keyframes live in tailwind-theme.css) is the seamless-scroll example, and it pauses under motion-reduce.

The two gotchas

Two things here will cost you an afternoon if you hit them cold:

  • Clip frames for parallax and zoom must use overflow-clip, not overflow-hidden. A hidden box is itself a scroll container, so view() resolves to that frame — which never scrolls — instead of the page, and the animation freezes. Use overflow-clip.
  • New scroll-only keyframes must be added to the @supports guard. The scroll-only shapes (parallax, fade-through) end away from their identity state — a played-once parallax would leave content offset, a fade-through would end invisible — so a @supports not (animation-timeline: view()) guard in index.css makes them inert where scroll timelines are unsupported. Add any new scroll-only keyframe to that guard’s list, or it will misbehave on Firefox.

Verifying

/examples/ui has a Motion section that renders every catalog animation (hover a tile to replay), the modifier composition, and live <Reveal> demos. Check it in both themes, and with the OS “reduce motion” setting on. Keyframes emit only when a utility is actually used, so a class that seems inert has usually just been tree-shaken — confirm with pnpm build and grep dist for the @keyframes.

NEXT STEPSEO & Structured Data