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.
Gallery
Every blend variant, both flips
Same two colours each time, so the shape is the only variable. Swap from and to for the opposite direction; use flip to mirror on x, y, or xy.
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.