Components

Components

Motion

Every duration, distance and easing below comes from a token. Nothing here hard-codes a number, which is why one change to theme.ts retimes a whole site.

Turn on reduced motion in your OS and reload: everything still arrives, and nothing travels. That is the rule, and it is not configurable.

Reveal

Scroll reveal

Scroll this panel ↓

Card 1

Slides in from the right, once. Jump down the panel and all three enter together — the stagger is what stops that reading as one block.

Card 2

Slides in from the right, once. Jump down the panel and all three enter together — the stagger is what stops that reading as one block.

Card 3

Slides in from the right, once. Jump down the panel and all three enter together — the stagger is what stops that reading as one block.

astro-demo6-starter

One-shot, and the trigger line matters more than the animation. A bare observer fires on the first pixel, so the reveal has finished before the element is legible — `rootMargin: '0 0 -30% 0'` shrinks the observation area from the bottom so a card travels properly into view first. Percentages resolve against the ROOT, so a value tuned for the viewport cannot be copied into a scroll container unchanged. Travelling on X rather than Y needs `overflow-x: hidden` on the container, or the offset widens the scroll area and adds a horizontal scrollbar.

  • --scroll-reveal-duration
  • --scroll-reveal-easing

reveal-system.md

Load-in reveal

Load-in reveal

Rises 20px and fades as the page becomes visible.

perth.local

For content already on screen when the page paints. Travels further and slower than a scroll reveal because nothing is competing for the eye yet. Gated on `document.prerendering` — with speculative prerendering a page can render in an invisible background tab, and a plain load animation would finish before the visitor ever sees it.

  • --reveal-distance
  • --reveal-duration
  • --reveal-easing

reveal-system.md

Staggered reveal

astro-demo6-starter

Delay is `index x --stagger-step`, never a hand-typed number. Stagger sparingly: four cards reads as intentional, every paragraph reads as the site being slow.

  • --stagger-step
  • --reveal-delay
Typing

npm run build

sjgwebdev

CSS-only, no per-character spans and no JS timer. The line is clipped by an animating `width` stepped through the character count, so it types at a genuinely even rate. It needs a MONOSPACE face — in a proportional face `40ch` is not 40 characters wide, so the reveal drifts out of step with the glyphs and stops mid-character. The caret is a separate border with its own blink.

  • --typeface-mono
  • --accent
  • --ease
Split heading

Wordsriseintoplace

astro-demo4-starter

Split at BUILD time, not in the browser. Runtime splitters must wait for webfonts, re-run on resize, and leave the heading unsplit or invisible if the script fails. Two nested spans are load-bearing: the outer is the window that clips, the inner is what travels — an element cannot clip its own transform. The word gap is drawn with margin, never a source space, because Astro's compressHTML strips whitespace between tags.

  • --reveal-easing
  • --stagger-step

split-heading.md

Heading emphasis

Chip wipe

Emphasis is a filled chip that settles.

astro-demo6-starter

The fill is a ::before animating scaleX, not the chip's own width — transform runs on the compositor, and animating width would reflow the heading so the words after it visibly shuffle. The ink flips on its own short delay as the fill passes underneath: it cannot start as accent-ink, which would be near-invisible against the page before the fill arrives.

  • --accent
  • --accent-ink
  • --rounding-chip
  • --ease

chip-wipe.md

Sticker press
Click and hold

astro-demo8-starter

A hard offset shadow with zero blur that the button presses INTO — the element moves by exactly the shadow offset, so the two meet. The shadow is a token the band reassigns, not a hard-coded colour: on an inverted band the ground IS ink, so the same component must draw its shadow in the light colour.

  • --ink
  • --accent
  • --accent-ink
  • --rounding-button

sticker-press.md

Hover lift

Hover lift

Travels 2px. More reads as the card coming loose.

astro-demo6-starter

Gated on `@media (hover: hover)` — on touch, :hover sticks after a tap and the card stays lifted until you tap elsewhere. Transition the named properties, never `transition: all`, which picks up every property a later rule adds and turns a 2px lift into a per-frame reflow.

  • --transition-base
  • --ease
  • --line
  • --accent

micro-interactions.md

Accordion
First question

The panel snaps open. Only the marker rotates.

Second question

The panel snaps open. Only the marker rotates.

astro-demo6-starter

Native details/summary. Free keyboard support and correct ARIA — and find-in-page works, because browsers open a closed details when the search term is inside it. No custom accordion does that. Animate the marker, let the panel snap: max-height transitions need a magic number that clips real content.

  • --line
  • --transition-base
  • --ease

micro-interactions.md

Numbers

Counter
0Page views
0+Projects
$0Saved

astro-demo4-starter

`font-variant-numeric: tabular-nums` is not optional. In proportional figures a 1 is narrower than a 0, so every tick changes the string's width and the number visibly jitters and shoves its label around — the single most common way a counter looks broken. Easing is ease-out: a tally decelerates into its final value, and a linear count reads as a loading bar.

  • --typeface-display
  • --ink
  • --muted
Counter, single
0%Lighthouse performance

astro-demo4-starter

Count from 0 only when 0 is meaningful. A figure that starts at 847 and ticks to 852 spends two seconds saying almost nothing, and a percentage animating past values that were never true can actively mislead — set `data-count-from` for those, or do not animate them at all.

  • --accent
  • --typeface-display

Scroll-linked & continuous

Continuous motion tied to the scroll wheel is the strongest trigger for vestibular symptoms there is, so these are gated on the media query directly and stop entirely — no site-wide policy, no per-element opt-out.

Marquee
AstroTailwindCloudflareTypeScriptSupabaseGSAP

astro-demo8-starter

CSS-only and seamless because the track holds the content TWICE and translates exactly -50%. Duplicating the list is what removes the jump; animating to -100% of a single copy snaps back visibly. The duplicate is aria-hidden so a screen reader hears the list once.

  • --line
  • --muted

cartoon-motion.md

Reading progress

Scroll to fill the bar

The bar above tracks how far through this panel you are, and reaches 100% exactly as the last line reaches the bottom.

It is driven by one scroll listener, throttled with requestAnimationFrame so a trackpad flick writes once per frame rather than dozens of times per frame.

Every layout read happens before every write. Interleaving them forces the browser to recompute layout mid-frame, which is what turns a progress bar from free into a jank source.

The travel is the content height minus one viewport height. That subtraction is the part everyone leaves out.

Without it, the bar tops out around 80% on a long document and never fills — which reads as broken rather than as a design choice.

It is clamped at both ends, because rubber-band scrolling on iOS reports a negative position at the top and an over-height one at the bottom.

A ResizeObserver watches the panel too. Height changes without a scroll — images loading, a font swapping, a filter hiding rows — would otherwise leave the bar stale until the next move.

The bar is aria-hidden. It restates what the scrollbar already carries natively and more precisely, so it earns its place visually rather than semantically.

Announcing "thirty-seven percent" to a screen reader would be noise on top of a control that user already has.

On a real page this shares its listener with the scroll-to-top button and anything else that needs scroll position.

Two listeners each doing their own rAF throttle means two callbacks, two layout reads and two chances to force a synchronous reflow on every frame of every scroll.

On the one page type where scrolling smoothly is the entire product, that is not a trade worth making.

Keep scrolling — there is a little more, so the bar has somewhere left to go.

That is the end. The bar should be full.

astro-demo7-starter

Travel is the article's height MINUS one viewport — a document is fully read when its bottom reaches the bottom of the screen, not the top. Without that subtraction the bar tops out around 80% and never fills, which reads as broken. Clamp it too: iOS rubber-banding produces a negative scrollY at the top and an over-height one at the bottom. One scroll listener per page drives this and everything else that needs scroll position, with every layout READ done before every WRITE.

  • --accent
  • --line

reading-progress.md

Scroll-snap carousel
1Snap point
2Snap point
3Snap point
4Snap point
5Snap point
6Snap point
7Snap point
8Snap point

astro-demo4-starter

No slider library. A scroll-snap overflow container is already draggable, flickable, swipeable and keyboard-scrollable with correct per-platform momentum. The script only adds the two buttons and disables them at each end — if it fails, the row is still a perfectly good scroller.

  • --line
  • --surface-raised
  • --rounding-card

scroll-snap-carousel.md

Elsewhere

Worth a look when CSS genuinely will not do it. The bar for adding a dependency is high — demo3 draws a rotating wireframe sphere in about 120 lines of vanilla canvas, against the 331 KB of libraries the design it follows uses for the same effect.