Motion lab

Scroll-world motion & graphics kit

Six small, dependency-free components that let sections melt into one another instead of hard-cutting. Everything here animates transform and opacity only. Content never leaves normal document flow, nothing is position: fixed, nothing is pinned — so a slow bundle or a JS failure costs you the motion, never the page.

Reduced motion is OFF for you right now.

Every component reads prefers-reduced-motion: reduce live. To check the static fallback: macOS → System Settings → Accessibility → Display → Reduce motion; Windows → Settings → Accessibility → Visual effects → Animation effects; or in Chrome DevTools → Rendering → Emulate CSS prefers-reduced-motion. Toggle it and scroll again: the parallax settles at zero, the paths render fully drawn, the number appears at its true value. Nothing vanishes, nothing reflows.

1 · SectionBlend

No more hard cuts

You just crossed a wave blend from white into this grey. The blend is a plain block element between the two sections: its own background is the colour above, the filled shape is the colour below. It holds no content, it is aria-hidden and pointer-events: none, and it needs no JavaScript — so it can never overlap or hide a line of text.

2 · ParallaxLayer

Depth, one loop

Three layers drifting at different rates. All of them — and every other component on this page — share one scroll listener and one animation frame, with all measurements batched before any style write. Off-screen layers unsubscribe entirely.

speed −0.28

Background plate. Moves against the scroll, reads as further away.

speed 0

The reference plane. Exactly where the CSS puts it — this is also what every layer looks like under reduced motion.

speed +0.22

Foreground card. Moves with the scroll, reads as nearer.

3 · Blending into an image

The hard case

An SVG cannot be filled with a photograph, so the trick is to put the blend inside the image section with the side that touches the photo set to transparent. The transparent half lets the image show through; the filled half is the colour of the band that follows. Scroll on to see the arch do exactly that.

4 · DrawOnScroll

The ledger draws itself

stroke-dashoffset driven by the element’s own intersection progress. Three presets ship: the hash chain, the evidence rail, and a sweeping arc. Under reduced motion each renders fully drawn and static — the fallback is always “visible and complete”, never “invisible”.

preset=“chain”

preset=“rail”

preset=“arc”

5 · CountUp

A number that cannot lie

These three figures are fetched from /api/gspc at page load. Nothing on this page is hardcoded. CountUp takes only a value handed to it, renders that true value as its text immediately, and merely overlays an animation on top after mount — so view-source, a screen reader, a copy/paste and an answer engine all read the measured figure, animation or not. If the endpoint is unreachable it shows an em dash and says the number is unmeasured. It never guesses.

Axes on the board

Measured axes

Items behind them

Source: GET /api/gspc · endpoint unreachable in this environment (Unexpected token '<', "<!doctype "... is not valid JSON) — the figures above stay unmeasured rather than inventing a value, which is the guard working.

variant=“wave”

variant=“wave” flip=“y”

variant=“slope”

variant=“slope” flip=“y”

variant=“arch”

variant=“arch” flip=“y”

variant=“fade”

variant=“fade” flip=“y”

Integration note

Nothing here is wired into the live homepage. The kit lives entirely in client/src/components/motion/ and this page is its only consumer, so it can be adopted section by section once the in-flight homepage work lands. Import from @/components/motion.