# Reduced Motion Skips the Skull Engine and the Split Titles

> Reduced-motion visitors skip engine loading and SplitText entirely. The smoke transition falls back to plain navigation so nothing breaks.

- Author: Dil Ahdan (https://dil.dev/)
- Published: 2026-10-01
- Updated: 2026-10-01
- Tags: accessibility, gsap, webgl, page-transitions
- Canonical: https://dil.dev/notes/reduced-motion-skips-the-lab-engine/

## Key takeaways

- Check prefers-reduced-motion before initialising the skull engine, not after
- SplitText instances must be skipped, not just paused, for reduced-motion visitors
- The smoke transition in router.js falls back to plain navigation if the media query matches or if any phase fails
- GSAP contexts and SplitText instances need explicit cleanup on every route change, not just on unmount
- A plain navigation fallback is the correct baseline, not a reduced-animation version of the same effect

Reduced-motion visitors on this site skip two things entirely: the skull engine never loads, and SplitText never runs. The [smoke page transition](/notes/smoke-page-transitions-webgl/) 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)').matches` runs 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 `null` from 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: reduce` in 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 reading `router.js` that 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.

## Quick answers

**How do I skip GSAP SplitText for reduced-motion users?**

Check window.matchMedia('(prefers-reduced-motion: reduce)').matches before you call SplitText at all. Do not split the text and then skip the animation. The split itself restructures the DOM and can cause layout shifts even if nothing moves.

**Where in the router should I put the reduced-motion check?**

Put it at the very top of the transition handler in router.js, before any WebGL or GSAP work starts. If the query matches, call your plain navigation function and return early. That way none of the engine code runs at all.

**What counts as a plain navigation fallback?**

A plain navigation swaps the page content without opacity fades, WebGL overlays or staggered reveals. The browser's own scroll-to-top and a single synchronous content swap is enough. No GSAP, no canvas.

**Do I need to clean up GSAP contexts on route changes even for reduced-motion visitors?**

Yes. If a visitor toggles the system setting mid-session, or if the fallback path still initialised any GSAP context earlier in the session, you need to clean up. Call context.revert() and revert any SplitText instances on every route change regardless of the motion preference.

**What happens if the WebGL smoke transition fails partway through?**

The router.js implementation treats any failure in the two-phase smoke sequence as a signal to fall back to plain navigation. The same fallback used for reduced-motion visitors fires, so the user still gets to the next page.

**Is the camera canvas affected by the reduced-motion skip?**

The camera canvas is the one place outside the skull where things move independently. The reduced-motion skip targets the skull engine and the smoke overlay. Whether the camera canvas should also pause depends on what it is showing, but it is outside the scope of the router fallback.
