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-motionis a user need. It is always honoured, never gated behind config. A single global guard at the bottom ofmotion/index.csszeroes animation and transition durations site-wide (to near-zero rather thannone, soanimationend/transitionendlisteners still fire) and forcesscroll-behavior: auto.siteSettings.useAnimationsis 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:
- Scroll-driven timelines (
<Reveal>, the reading progress bar). @starting-styleenter/exit transitions withallow-discrete— the Dialog and Sheet overlays inui/_overlay.css.- View transitions through
<ClientRouter>, gated onuseViewTransitions— add atransition:nameto a shared element for near-free morphing. - A bundled script, re-initialised on
astro:after-swapthrough the shared_client.tscontract — 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, notoverflow-hidden. Ahiddenbox is itself a scroll container, soview()resolves to that frame — which never scrolls — instead of the page, and the animation freezes. Useoverflow-clip. - New scroll-only keyframes must be added to the
@supportsguard. 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 inindex.cssmakes 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.