Designing Parallax Effects Safe for Vestibular Disorders

Parallax is the single highest-risk pattern in the vestibular motion taxonomy: it deliberately manufactures depth disparity by moving layers at different speeds, and depth disparity is exactly the visual-vestibular conflict that induces dizziness and nausea. That does not mean every parallax effect must be deleted. This page gives a retrofit workflow β€” audit the depth ratios you have, cap the distances, compress the speed differentials, and ship a reduced-motion variant β€” following the safe ranges defined in the vestibular-safe motion design guide and the wider Accessibility & Inclusive Motion Standards framework.

When to use this approach

Choose the retrofit workflow β€” or a different path β€” based on where the parallax sits and what it does:

  • Retrofit (this page) β€” Best when parallax is an established part of the visual identity and stakeholders want to keep it. You compress the effect into safe ranges rather than removing it; most users will not consciously notice the reduction.
  • Remove entirely β€” Correct when the parallax is decorative filler on content-critical pages (documentation, checkout, forms). A static layered composition with subtle shadows reads as depth with zero motion risk.
  • Greenfield build β€” If you are building new, start from the pure-CSS parallax patterns but apply this page’s caps from the first line: single moving plane, ratio β‰₯ 0.85, drift ≀ 8px per viewport of scroll.
  • JS-library parallax (ScrollMagic, Rellax, GSAP-style) β€” The audit in Step 1 still applies; the fixes translate to the library’s speed/ratio options. Consider migrating to scroll-driven parallax without JavaScript during the retrofit, since CSS timelines make the caps enforceable with custom properties.

Whatever path you take, the prefers-reduced-motion override is not optional β€” Step 4 is required even if Steps 2–3 make the default experience gentle.


Layer speed differentials: unsafe versus capped parallax Two panels compare parallax configurations. The unsafe panel shows three layers with speed ratios 0.2, 0.6, and 1.0, producing 160 pixels of relative drift per 200 pixels of scroll. The safe panel shows ratios 0.85, 0.95, and 1.0, producing at most 30 pixels of relative drift. A caption notes that relative drift between layers, not absolute speed, is the vestibular trigger. Unsafe: large differential background β€” ratio 0.2 midground β€” ratio 0.6 foreground β€” ratio 1.0 160px relative drift / 200px scroll Safe: capped differential background β€” ratio 0.85 midground β€” ratio 0.95 foreground β€” ratio 1.0 ≀ 30px relative drift / 200px scroll Relative drift between layers β€” not absolute speed β€” is the vestibular trigger

Implementation

Step 1: Audit existing parallax depth ratios

For each parallax scene, record every layer’s speed ratio: how far the layer moves per unit of scroll, where the content plane is 1.0. In a scroll-driven CSS implementation the ratio is derivable from the keyframes β€” a layer that translates -150px over an animation-range of 0 500px while the content scrolls 500px has an effective ratio of (500 βˆ’ 150) / 500 = 0.7.

You can extract measured ratios at runtime rather than reading code, which also works for legacy JS implementations:

// Run in the console: measure each layer's movement per 200px of scroll
async function auditParallaxRatios(selectors) {
  const layers = selectors.map(s => document.querySelector(s));
  const before = layers.map(el => el.getBoundingClientRect().top);

  window.scrollBy({ top: 200, behavior: 'instant' });
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

  layers.forEach((el, i) => {
    const moved = before[i] - el.getBoundingClientRect().top;
    // ratio 1.0 = locked to scroll; 0.0 = fixed to viewport
    console.log(`${selectors[i]}: ratio ${(moved / 200).toFixed(2)}`);
  });
}

auditParallaxRatios(['.hero__bg', '.hero__mid', '.hero__fg']);

Record three numbers per scene: the minimum ratio (slowest layer), the maximum relative drift between any two layers per 200px of scroll, and the total translateY distance each layer covers over its full range. Flag any scene where the minimum ratio is below 0.85, relative drift exceeds ~30px per 200px of scroll, or any single layer travels more than 100px total.

Step 2: Cap the translateY distance

Convert every layer’s travel distance to a token-derived value with a hard cap, using the custom-property capping pattern from the parent guide. The min() wrapper means over-ambitious per-layer requests are clamped at computed-value time:

:root {
  --parallax-max-drift: 24px;  /* total travel cap per layer */
  --motion-scale: 1;
}

.hero__bg {
  animation: bg-drift linear both;
  animation-timeline: scroll(root);
  animation-range: 0 100vh;
}

@keyframes bg-drift {
  to {
    /* Layer requests 60px; the cap grants 24px */
    transform: translateY(calc(
      -1 * min(60px, var(--parallax-max-drift)) * var(--motion-scale)
    ));
  }
}

Two details matter. First, cap total travel over the full range, not speed β€” a slow layer that ultimately travels 300px is still a 300px displacement in the user’s visual memory of the scene. Second, keep the animation-range bounded (0 100vh, not the whole document): an unbounded range means the drift accumulates for the entire page height and the cap is applied per-keyframe-cycle, not per-scene.

Step 3: Reduce layer speed differentials

Depth perception is logarithmic β€” the difference between ratios 0.85 and 1.0 still reads clearly as depth, while the difference between 0.2 and 1.0 reads as the world tearing apart. Compress your ratios toward 1.0 rather than spacing them evenly:

:root {
  /* Compressed depth stack: was 0.2 / 0.6 / 1.0 */
  --ratio-bg: 0.85;
  --ratio-mid: 0.95;
  /* foreground stays in normal flow at 1.0 */
  --scene-height: 100vh;
}

/* Drift = (1 - ratio) Γ— scene height, pre-computed per layer */
@keyframes drift-bg {
  to { transform: translateY(calc(-0.15 * var(--scene-height) * var(--motion-scale))); }
}
@keyframes drift-mid {
  to { transform: translateY(calc(-0.05 * var(--scene-height) * var(--motion-scale))); }
}

.scene__bg  { animation: drift-bg linear both;  animation-timeline: view(); animation-range: cover 0% cover 100%; }
.scene__mid { animation: drift-mid linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; }

Note the interaction with Step 2: at --scene-height: 100vh, a 0.15 differential is ~110px of drift on a tall desktop viewport β€” over cap. Reconcile by shrinking the differential (ratio 0.9+), shortening the range, or letting the min() cap from Step 2 truncate the computed value. When the compressed stack still needs more perceived depth, add it statically: blur, desaturation, and scale-down on background layers create depth cues with zero motion.

If the audit found more than two moving planes, cut to one. A single background layer at ratio 0.9 against a normal-flow foreground preserves the parallax impression while eliminating mid-layer crossing motion β€” the configuration most consistently reported as tolerable.

Step 4: Provide a reduced-motion variant

The compressed default is for users who have not expressed a preference. Users who set the OS toggle get zero parallax, via both the token multiplier and an explicit timeline detach β€” the double reset covers keyframes that reference raw values as well as tokenized ones. Apply the same two-property reset the reduced-motion reference documents: animation: none alone does not clear animation-timeline.

@media (prefers-reduced-motion: reduce) {
  :root { --motion-scale: 0; }

  .scene__bg,
  .scene__mid {
    animation: none;
    animation-timeline: auto;  /* detach from scroll β€” the shorthand misses this */
    transform: none;
  }
}

Design the static fallback deliberately rather than letting layers freeze at their keyframe start positions: with animation: none, each layer renders at its stylesheet-specified transform, so make sure that resting composition is the one you want (usually transform: none with the layers stacked as laid out). Verify the scene still communicates hierarchy without motion β€” if it does not, add the static depth cues (shadow, blur, scale) unconditionally so both variants share them.

Verification

  1. Re-run the ratio audit from Step 1 after the retrofit. Every scene should report minimum ratio β‰₯ 0.85 and relative drift ≀ ~30px per 200px of scroll.
  2. DevTools β†’ Rendering β†’ Emulate CSS media feature prefers-reduced-motion β†’ reduce. Scroll every parallax scene: nothing may move relative to anything else. Confirm in Elements β†’ Computed that animation-timeline reads auto on each layer.
  3. Console check: document.getAnimations().filter(a => a.playState !== 'idle') while the emulation is active should not list any parallax layer animations.
  4. Smallest supported viewport: repeat the audit at 360px width. Viewport-relative drift values grow and shrink with the viewport; confirm the min() caps hold.
  5. Fast-flick test: scroll a full scene with one aggressive wheel flick or trackpad swipe. Scroll-linked animation plays at whatever speed the user scrolls, so the effect must remain tolerable at maximum velocity β€” if it is not, the distances are still too large.
  6. Automated regression: in Playwright, await page.emulateMedia({ reducedMotion: 'reduce' }), scroll programmatically, and assert layer bounding boxes moved by exactly the scroll delta (ratio 1.0).
// Playwright: assert a layer is scroll-locked under reduced motion
await page.emulateMedia({ reducedMotion: 'reduce' });
const before = await page.locator('.scene__bg').boundingBox();
await page.mouse.wheel(0, 300);
await page.waitForTimeout(100);
const after = await page.locator('.scene__bg').boundingBox();
expect(Math.round(before.y - after.y)).toBe(300); // moved with scroll, no drift

Edge cases and gotchas

Sticky-positioned layers masquerade as parallax. position: sticky backgrounds have an effective ratio of 0 while stuck β€” the strongest possible differential against scrolling foreground content. The audit script catches these because it measures actual movement; code review that only greps for animation-timeline misses them.

background-attachment: fixed is parallax too. The oldest parallax technique moves nothing in the DOM, so JS audits and animation inspection both miss it, yet it produces a full ratio-0 layer. Search stylesheets for it separately and replace with a capped scroll-driven drift or a static image.

Nested scroll containers double-apply drift. A parallax scene inside an inner scroller (overflow-y: auto) driven by scroll(root) moves against two scroll positions at once; effective drift can exceed both caps. Bind the timeline to the nearest scroller, or hoist the timeline deliberately with timeline-scope so exactly one scroll source drives the scene.

Anchor navigation replays the whole scene. A same-page anchor jump with scroll-behavior: smooth sweeps the viewport across every parallax range at high velocity. Pair the retrofit with scroll-behavior: auto under prefers-reduced-motion: reduce, and consider animation-range values that end before mid-page anchor targets.

Zoomed layers overflowing to hide drift edges. Implementations often scale background layers to 120% so drift never exposes gaps. That scale-up is itself a zoom trigger if animated; keep the oversizing static (set outside any keyframe) and only animate the capped translate.

Interaction-triggered movement has a WCAG dimension. Scroll-triggered parallax falls under motion-from-interaction rules; the WCAG 2.2 animation compliance criteria treat a missing reduced-motion path on parallax as a conformance failure, not just a design smell. Step 4 is what makes the retrofit auditable.

Browser-specific notes

Chrome / Edge (115+) Full support for animation-timeline: scroll() and view() means the capped CSS implementation runs as designed. Use the Animations drawer to scrub scenes slowly while checking drift distances, and the Performance panel to confirm the compressed effect still composites without main-thread work.

Safari Scroll-driven animation support is in active development (Technology Preview). In current stable releases, @supports (animation-timeline: scroll()) evaluates false, so guard the whole scene setup and let Safari render the static composition β€” which, after this retrofit, is a designed state rather than an accident. Legacy JS-driven parallax still runs in Safari, so audits must cover both code paths.

Firefox Scroll-driven animations sit behind a preference (Nightly). As with Safari, the @supports guard yields the static scene in stable Firefox. Do not treat this as β€œFirefox users are safe by default” β€” any JS fallback parallax you ship for these engines needs the same ratio caps and reduced-motion variant.

All engines prefers-reduced-motion itself is supported everywhere that matters (Chrome 74+, Firefox 63+, Safari 10.1+), so Step 4 has no compatibility caveats β€” the reduced-motion variant is the one part of this workflow with universal reach. The progressive enhancement patterns show how to layer the @supports and @media guards without duplication.


Up: Vestibular-Safe Motion Design Patterns