What shipped
Three content-agnostic surfaces landed in @paul-portfolio/css and @paul-portfolio/react, plus a shared-hook refactor. Each one wraps arbitrary children and is stable on the server, so they behave like backgrounds you drop around any content.
- TiltCard — a 3D surface that tilts toward the pointer with a cursor-tracking glare highlight. Props:
maxTilt, glare. - GradientBackground — a flowing multi-stop gradient, animated entirely in CSS. Props:
colors, angle, speed, animate; falls back to the brand token palette. - Spotlight — a soft radial glow that follows the cursor across a background. Props:
size, color.
Reduced motion is the default, not a fallback
The rule for all three: the static version is the contract, and the motion is the enhancement layered on top. If the browser or the user says no to movement, what's left still works and still looks intentional.
- TiltCard flattens to a plain static card — no rotation, no glare.
- Spotlight pins its glow to the center and stops tracking the cursor, so it reads as a static vignette.
- GradientBackground is the cleanest case: the animation is a CSS keyframe gated behind
prefers-reduced-motion, so it goes static with no JavaScript running at all.
Pointer, not keyboard
TiltCard and Spotlight react to the pointer, and only the pointer. There's a real accessibility reason for that line: yanking a card's rotation or throwing a glow around on focus would be distracting noise for someone navigating by keyboard, and it'd fight assistive tech for attention.
- Keyboard users are simply unaffected — the surfaces sit still and let the content take focus normally.
- Every component ships with an axe a11y test alongside its unit tests, so the accessible-by-default promise is asserted, not assumed.
One hook, shared across every animation
The obvious trap was three copies of the same reduced-motion listener. Instead the check became a single exported hook, usePrefersReducedMotion, and the existing Ticker was refactored onto it too.
- One source of truth for "should this move?" means a fix to the media-query handling lands everywhere at once.
- It's exported from the package, so anything built on top of the design system gets the same behavior for free.
Verification and housekeeping
- TDD per component: CSS and React tests written first, each cycle going red then green.
packages/react: 146 tests passing (unit + axe for all three). packages/css: 139 tests passing. The React build runs a clean tsc typecheck.- Storybook stories added for each component, and versions bumped:
@paul-portfolio/css 0.4.5→0.4.6, @paul-portfolio/react 0.4.4→0.4.5, with a changelog entry. - Angular ports deferred — the same call made for Select, FilterBar, and Ticker. The React and CSS surfaces come first; Angular follows when there's demand.
The takeaway
- Motion that can't be turned off isn't a feature, it's a liability. Designing the static state first makes reduced-motion trivial instead of an afterthought.
- When three components need the same environmental check, that check wants to be one shared, tested hook — not three quietly-drifting copies.
What I'd do now, and what I did
- Reduced motion as the default rather than a fallback, so the accessible path is the one that runs unless something opts out.
- Motion behind components rather than scattered animation calls, which is what makes a policy like that enforceable at all.
Where it could go further
- Adoption is incomplete, though less than I assumed. The 3D scenes animate in a render loop that never touches a motion component, so they answer the preference through their own hook instead — the world already did, the particle lab did not until it was fixed.
- There is no test that a new animation went through the components rather than around them.
Next on this
- A check that any new animated component answers prefers-reduced-motion somehow — through these components or its own hook — since the gap is not knowing, rather than any one page.