Shipping a scroll effect that doesn't hurt anyone

A photograph assembling itself out of a few hundred thousand cubes as you scroll is a large, fast, whole-field motion with real depth cues. For most visitors that is the point. For a minority it is a reason to close the tab, and for some a reason to feel unwell for the rest of the afternoon. This guide is the short list of things the emitted module already does about that, and the shorter list only the host page can do.

The constraint

Vestibular disorders — vestibular migraine, Ménière's disease, benign paroxysmal positional vertigo, the long tail of post-concussion sensitivity — are common enough that any page with traffic has affected visitors among them. The mechanism is simple enough to design around: the inner ear reports that the head is still, the eyes report that the world is moving, and the conflict produces nausea, dizziness, disorientation, and in some people a migraine lasting hours. Depth travel is worse than flat motion because it carries the cues the brain uses to judge self-motion, and large-field is worse than small-field — which makes a fixed, full-viewport canvas dollying toward a grid of cubes almost exactly the shape of the problem.

That is a medical fact about some of your readers, not a taste they hold, and it is cheap to honour: the assembled photograph is the payoff, so it can simply be there from the first frame.

prefers-reduced-motion, and what the module does with it

prefers-reduced-motion is the CSS media feature carrying the operating system's answer. It has two values, no-preference and reduce, and the visitor sets it once, system-wide — on recent versions, macOS under System Settings → Accessibility → Display → Reduce motion, iOS and iPadOS under Settings → Accessibility → Motion → Reduce Motion, Windows under Settings → Accessibility → Visual effects → Animation effects, Android under Settings → Accessibility → Remove animations. Your page never has to ask.

The emitted module reads it and keeps a live subscription:

const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
let pageStill = false;
function still() { return reduced.matches || pageStill; }

When still() is true the camera does not travel. It is placed at its assembled depth outright and progress is published as 1, so anything you have paced against --assembly renders in its resting state instead of waiting for a clock that will never run. The scroll listener is guarded by the same predicate, so scrolling costs no more than on any other page. And because the module subscribes to change on the media query, a visitor who switches the preference on while your page is open gets a still picture immediately — no reload.

A themed page (--theme) layers its own rules on top: it flattens every animation and transition, drops the panels' backdrop blur, hides the decorative scroll dot and the technical voice's assembly readout, and leaves kinetic hero type flat and resolved rather than staggering it in.

The kinetic style is pointer-driven rather than scroll-driven, and the same predicate covers it: under a reduced-motion preference the pointer energy that smears the picture is forced to exactly zero, so the page shows the resolved photograph however much the visitor moves the cursor. Nothing on the page can put that motion back.

The page-level switch

The escape hatch for your own markup is one event:

<button id="motion" aria-pressed="false">Hold the picture still</button>

<script>
(function () {
  var btn = document.getElementById('motion');
  var still = false;
  function apply() {
    window.dispatchEvent(new CustomEvent('pixelScroll:motion', {
      detail: { still: still }
    }));
    btn.textContent = still ? 'Let the picture move' : 'Hold the picture still';
    btn.setAttribute('aria-pressed', String(still));
  }
  btn.addEventListener('click', function () { still = !still; apply(); });
  apply();
  // an inline classic script publishes before a deferred module can listen
  addEventListener('load', apply);
}());
</script>

The two inputs are OR-ed, never swapped: your toggle can add stillness, but the OS preference is the floor and no dispatch can lift it. A seam that replaced the system answer would let any page put whole-field motion back in front of someone who has already asked, system-wide, for less of it.

So why ship a visible control when the OS setting already exists? Because the setting is a blunt instrument plenty of people leave off rather than flatten every animation on every site they use; because some visitors are on a borrowed machine; and because some do not know it exists. Two rules for the control itself: hide it under prefers-reduced-motion, where the picture is already held and the button would be lying, and hide it under data-forge-fallback, where there is no motion to hold. Persist the choice in localStorage; it costs three lines.

The fallback is a design state

When WebGL is unavailable or no three.js host is reachable, the module sets data-forge-fallback on <html>, mounts the photograph as a fixed still framed the way the settled mosaic would have been, and publishes assembly as 1. The still carries the same presentational contract as the canvas it replaces — alt="", aria-hidden="true", z-index: 0, pointer-events: none — so nothing about the accessibility tree changes between the two.

Designing that state properly is an accessibility act, not a courtesy: a visitor without WebGL is often on old or locked-down hardware, a population that overlaps heavily with every other constraint on this page. Open your page once with WebGL disabled and look. One detail to check — an unreachable three.js host, unlike a missing GPU, also mounts a short notice fixed to the bottom of the viewport, which will sit on top of your own fixed furniture.

What assistive technology actually sees

Nothing, where the picture is. The module sets aria-hidden="true" on the renderer's canvas and styles it position: fixed at z-index: 0 with pointer-events: none. It is prepended to <body>, so it is first in the DOM, but a canvas is not focusable without a tabindex and never takes a tab stop. What a screen reader walks is your markup, in your order. That is only correct while the picture carries no information the page does not also state in text; if the image is the content, its description belongs in your markup, not on the canvas.

The layering contract asks your content wrapper for position: relative; z-index: 1. That is usually explained as a painting instruction, and it is one, but it also keeps the page operable. Drop it and your content paints under the canvas while remaining fully present in the accessibility tree: a screen-reader user is served normally, and a sighted keyboard user tabs into controls whose focus ring is behind the picture — a focus-visibility failure produced entirely by a stacking mistake.

One trap specific to the standalone page: the HTML the forge emits gives its .content wrapper pointer-events: none, because the only thing in it is a decorative sticky heading. Paste your own links into that file and they will not click. Themed pages deliberately do the opposite — the wrapper keeps pointer events, and links, buttons, the call to action and the footer are re-armed with pointer-events: auto as belt and braces. They also open with a skip link targeting #main-content, which lands on the first section past the hero rather than the hero itself, with every section inside a real <main> in reading order.

Contrast, by construction

A themed page's colours are derived from your image in OKLCH, then held to fixed ratios measured on sRGB relative luminance — what WCAG 2.x actually defines contrast against. From the generator: body ink at least 4.5:1 against both the background and the panel surface, muted text at least 4.5:1 against the surface, the accent's own ink at least 4.5:1 against the accent, and the accent at least 3:1 against the background. Enforcement is a search on lightness: L is walked in the direction that raises contrast until the ratio clears, with a small margin so that rounding cannot drop a 4.502 below the line afterwards. The one way to break it is to force a background with --bg that neither white nor black can clear; the page then says so in a note rather than shipping a silent failure. Themed pages also answer prefers-contrast: more and forced-colors: active.

None of it covers text over the cubes themselves, which no palette can compute — that is what the scrims and panels are for, and why the landing page guide insists on probe screenshots at 0.5 and 0.7, where legibility fails first.

The criteria, by number

The exact semantics of every seam named here are in the documentation; the mounting contract is the embedding guide; and pacing your own content against the --assembly clock — including what it should do when that clock is held at 1 — is the scroll animation guide.