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
- 1.4.3 Contrast (Minimum) — Level AA. 4.5:1 for body text, 3:1 for large text. The line the generated palette is built to.
- 1.4.11 Non-text Contrast — Level AA. 3:1 for the visual boundary of a control. Your motion toggle's border is in scope.
- 2.4.7 Focus Visible — Level AA. The reason the wrapper's
z-indexis not merely cosmetic. - 2.2.2 Pause, Stop, Hide — Level A. It covers moving content that starts automatically, runs past five seconds, and sits alongside other content. A scroll-driven camera starts only when the visitor scrolls and stops when they stop, so it arguably does not bite — but a toggle satisfies the intent anyway, and autoplaying the video export on the same page does bite.
- 2.3.3 Animation from Interactions — Level AAA, not AA.
Honouring
prefers-reduced-motionis the accepted way to meet it. The level is a fact about the standard, not a reason to skip it; this is the criterion the whole page is about.
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.