Reduced-motion visitors on this site skip two things entirely: the skull engine never loads, and SplitText never runs. The smoke page transition falls back to a plain navigation, and if any phase of that transition fails, the same fallback fires. That is the contract.
Why Skip, Not Slow Down
The instinct is to keep the animation but reduce it. Slower fades, no parallax, that sort of thing. That is the wrong model here.
- SplitText restructures the DOM. Even if you never animate the split characters, the act of splitting wraps every word or character in a span. That changes layout, tab order and screen reader output.
- The skull engine is not cheap to initialise. Loading it and then doing nothing with it wastes parse time and memory for a visitor who will get no benefit.
- The smoke transition is two-phase WebGL. There is no meaningful reduced version of a bloom-from-click, page-swap, drain-to-pointer sequence. The fallback is the right answer. Skip entirely. Do not negotiate.
Where the Check Lives
The check sits at the top of the transition handler in site/src/router.js, before any WebGL or GSAP work starts.
- Match the media query first.
window.matchMedia('(prefers-reduced-motion: reduce)').matchesruns before anything else in the handler. - Return early with plain navigation. If the query matches, call the plain navigation function and return. Nothing else in the handler executes.
- The engine never initialises. Because the check is at the entry point, the skull engine and SplitText are never touched, not paused, not initialised and discarded.
The same early-return path fires if any part of the smoke sequence throws. Reduced-motion and failure share one fallback, which means the fallback gets real exercise on every broken WebGL environment rather than only on devices with the preference set.
The Two-Phase Smoke Sequence
For visitors without the preference set, the transition in site/src/smoke.js driven by site/src/router.js runs like this:
- Phase one, bloom from the click. The overlay expands from the pointer position.
- Page swap in place. Content is replaced while the overlay is covering the viewport.
- Phase two, drain into the pointer. The overlay collapses back toward the pointer position.
- Content staggers in by opacity. Elements reveal sequentially after the drain.
The play overlay fades by opacity separately. The camera canvas is the one place outside the skull where things move independently of this sequence. None of that runs for reduced-motion visitors. They get a synchronous content swap and the browser's own scroll-to-top.
Cleaning Up GSAP Contexts and SplitText on Route Changes
This is the part that bites you later. Cleanup must happen on every route change, not just on component unmount.
- Call
context.revert()on every GSAP context. If you leave contexts alive across routes, tweens from a previous page can fire against DOM nodes that no longer exist. - Revert SplitText instances explicitly. Calling
.revert()on a SplitText instance puts the original text node back. Without this, the split spans accumulate across navigations. - Do not assume reduced-motion visitors never trigger cleanup. A visitor might toggle the system preference mid-session. If any GSAP context was created earlier in that session, it still needs reverting.
The verified behaviour from AGENTS.md (2026-09-25) is explicit on this: clean up GSAP contexts and SplitText instances on route changes. Not on page unload. On route changes.
What Plain Navigation Actually Means
Plain navigation is not a stripped-down animation. It is no animation.
- No opacity fades. Not even a short one. The content appears immediately.
- No canvas overlay. The WebGL smoke element is not rendered.
- No staggered reveals. All content is visible at once after the swap.
- No GSAP. The plain navigation path does not touch GSAP at all.
The browser handles scroll position. The content swap is synchronous. That is the entire implementation.
This matters because "reduced motion" is often interpreted as "motion that is less likely to cause vestibular problems." For some visitors that is correct. For others, any motion is the problem. A plain navigation satisfies both groups. A slow fade does not.
Testing the Fallback Path
The failure fallback and the reduced-motion fallback share code, which is useful for testing.
- Break WebGL deliberately in dev. Returning
nullfrom the context creation call triggers the failure path and exercises the plain navigation fallback without needing a device with the preference set. - Use devtools to emulate the preference. Chrome and Firefox both let you force
prefers-reduced-motion: reducein the rendering panel. Verify the skull engine does not initialise, not just that the animation does not play. - Check the DOM after navigation. If SplitText ran, you will see span wrappers around text nodes. If the skip worked, the text nodes are bare.
- Check memory across several navigations. GSAP context leaks show up as retained listeners. A heap snapshot before and after a few navigations will surface anything that did not get reverted.
What I'd do differently
The failure fallback and the reduced-motion fallback sharing a code path is good, but it happened by accident rather than by design. I would make that explicit from the start: one function, two callers, documented as intentional.
- Name the fallback function clearly. Something like
navigatePlain()rather than an inline early return. That makes it obvious to anyone readingrouter.jsthat there is a deliberate fallback contract. - Add a listener for preference changes.
matchMedia.addEventListener('change', ...)lets you respond if a visitor toggles the setting mid-session. Right now the check only runs at transition time, so a toggle between navigations is handled correctly, but a toggle during a transition is not. - Log when the fallback fires. Not to an analytics service, just to the console in dev. It is easy to miss that the failure path is triggering silently in environments where WebGL is flaky.
The reduced-motion skip is not a nice-to-have. It is the baseline that makes the rest of the animation work defensible.




