Vestibular-Safe Motion Design Patterns

Vestibular disorders affect a large share of adults at some point in their lives, and on-screen motion can trigger real physical symptoms — dizziness, nausea, disorientation, migraine — within seconds of exposure. Scroll-driven animations raise the stakes because they bind motion directly to a gesture the user cannot avoid making. This guide classifies motion characteristics by vestibular risk, defines safe displacement ranges you can encode as CSS custom properties, and shows how to substitute opacity, color, and border effects for movement — complementing the WCAG 2.2 animation compliance criteria and the prefers-reduced-motion implementation guide within the broader Accessibility & Inclusive Motion Standards framework.

Pages in this section


Motion types classified by vestibular risk, each mapped to a safe substitution Four high-risk motion types on the left — large translation, parallax depth disparity, zoom or scale, and spin or rotation — each with an arrow pointing to a safe substitution on the right: opacity fade, single-plane offset, border and shadow emphasis, and color shift. A band at the bottom lists low-risk properties that can be kept as-is: opacity, color, border-color, shadows, and transforms of eight pixels or less. High-risk motion Safe substitution Large translation > 1/3 viewport or > 100px Opacity fade 0 to 1, duration ≤ 300ms Parallax depth disparity layer speed ratio < 0.85 Single moving plane offset ≤ 8px, or none Zoom / scale scale delta > 1.1 Border + shadow emphasis no geometry change Spin / rotation > 15deg or continuous Color / background shift static geometry Low risk — keep as-is opacity, color, border-color, box-shadow, transforms ≤ 8px

Motion triggers and safe ranges

The vestibular system resolves conflicts between what the inner ear reports (the body is stationary) and what the eyes report (the world is moving). On-screen motion that occupies enough of the visual field produces vection — the illusion of self-motion — and vection is what triggers symptoms. Four characteristics determine how strongly an animation induces it:

Motion characteristic Risk driver Threshold heuristic Safe substitution
Translation distance Displacement relative to viewport Keep UI shifts ≤ 24px; decorative shifts ≤ 8px; never > 1/3 of the smaller viewport dimension Opacity fade (0 → 1)
Parallax depth disparity Layers moving at different speeds create false depth Keep layer speed ratios ≥ 0.85 of scroll speed; one moving plane maximum Single-plane offset ≤ 8px
Zoom / scale Optic-flow expansion reads as forward self-motion Clamp scale between 0.97 and 1.03 for feedback; avoid entrance zooms > 1.1 Border and shadow emphasis
Spin / rotation Rotational optic flow is the strongest vection trigger Tilts ≤ 5deg are tolerable; continuous rotation is never safe Color or background shift
Full-viewport movement Motion fills the visual field, maximizing vection Never translate, scale, or rotate the entire viewport contents as one unit Crossfade between states

Three notes on applying these thresholds:

  1. Distance is relative, not absolute. A 100px slide inside a 2560px desktop viewport occupies a small fraction of the visual field; the same 100px on a 360px-wide phone covers more than a quarter of it. Express caps in the smaller of px and viewport-relative terms — min(24px, 3vh) is a practical formulation.
  2. Speed matters as much as distance. A 200px translation over 800ms is gentler than the same distance over 150ms, but scroll-driven animations invert this intuition: the user controls playback speed by flicking the scroll wheel, so a scroll-linked animation must be safe at the fastest plausible scroll velocity, not the average one. That argues for smaller distances, not slower easings.
  3. Simultaneity compounds risk. Two elements each moving a safe 20px in opposite directions produce 40px of relative motion between them. Audit relative displacement between simultaneously animated elements, not just each element in isolation — this is the core problem with parallax effects built on pure CSS, where depth disparity between layers is the entire point of the technique.

None of these heuristics replaces prefers-reduced-motion support. Safe ranges reduce the harm your default experience can do to users who have not set the OS preference — a group that includes everyone with an undiagnosed vestibular condition and everyone on a borrowed or kiosk device. The reduced-motion override remains mandatory on top.

Minimal working example

A self-contained scroll-driven reveal that stays inside the safe ranges: opacity carries the effect, displacement is capped at a token value, and the reduced-motion override collapses displacement to zero without touching the fade.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
  :root {
    /* Vestibular-safe motion tokens */
    --max-shift: 16px;      /* hard cap on any decorative displacement */
    --motion-scale: 1;      /* 0–1 multiplier, zeroed under reduced motion */
  }

  .card {
    opacity: 0;
    /* Displacement is a multiple of the cap — never an ad-hoc value */
    transform: translateY(calc(var(--max-shift) * var(--motion-scale)));
    animation: safe-reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 60%;
  }

  @keyframes safe-reveal {
    to { opacity: 1; transform: translateY(0); }
  }

  @media (prefers-reduced-motion: reduce) {
    :root { --motion-scale: 0; }   /* displacement gone; fade may remain */

    .card {
      animation: none;
      animation-timeline: auto;    /* detach the scroll binding entirely */
      opacity: 1;
      transform: none;
    }
  }
</style>
</head>
<body>
  <div style="height: 200vh; padding-top: 70vh;">
    <div class="card">Revealed with a 16px capped shift</div>
  </div>
</body>
</html>

The important structural decision is that translateY is computed from --max-shift, not hard-coded. When every displacement in the codebase routes through the token, a design-system-wide cap change — or a per-page tightening — is a one-line edit, and code review can flag any transform that bypasses the token as a policy violation.

Capping displacement with custom properties

The token pattern above scales to a full system. Define the caps once, derive every animated distance from them, and make the multiplier the single point of control:

:root {
  --max-shift: 24px;            /* interactive UI movement cap */
  --max-shift-decorative: 8px;  /* ambient/decorative movement cap */
  --max-scale-delta: 0.03;      /* scale stays within 1 ± this value */
  --motion-scale: 1;
}

/* Derived, clamped values — safe even if a component requests more */
.reveal-panel {
  --requested-shift: 40px;      /* component asks for 40px… */
  transform: translateY(calc(
    min(var(--requested-shift), var(--max-shift)) * var(--motion-scale)
  ));                           /* …and receives 24px */
}

.hero-zoom {
  transform: scale(calc(1 + var(--max-scale-delta) * var(--motion-scale)));
}

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

min() inside calc() is the enforcement mechanism: components can request any distance, but the computed value never exceeds the cap. This matters in large codebases where individual teams ship components independently — the cap lives at :root and wins by construction, not by review discipline.

For scroll-driven animations, pair the cap with tight animation-range values. A capped 16px shift spread across entry 0% cover 100% still produces sustained, slow drift for the entire time the element is on screen; the same shift scoped to entry 0% entry 50% completes quickly and then holds still. Sustained ambient motion is its own vestibular trigger independent of distance, so prefer short ranges that finish and settle:

.section-reveal {
  animation: safe-reveal linear both;
  animation-timeline: view();
  /* Completes in the first half of entry — no sustained drift */
  animation-range: entry 0% entry 50%;
}

The multiplier also supports non-binary policies. A --motion-scale: 0.4 gives users of an in-page motion toggle a proportionally gentler experience without a second set of keyframes — the same architecture the motion scaling guide develops in full.

Substituting opacity, color, and border for displacement

When a state change currently communicates through movement, one of three static-geometry channels can usually carry the same information:

/* BEFORE: 120px slide-in — high vestibular risk */
.notification--unsafe {
  transform: translateX(120px);
  transition: transform 400ms ease-out;
}

/* AFTER: fade + border emphasis — zero displacement */
.notification--safe {
  opacity: 0;
  border-inline-start: 3px solid transparent;
  transition: opacity 250ms ease-out, border-color 250ms ease-out;
}
.notification--safe.is-visible {
  opacity: 1;
  border-inline-start-color: var(--accent);
}

Choosing the right substitute:

  • Opacity replaces entrance and exit translations. It is also the only substitute that stays fully on the compositor thread, so it is the default choice for scroll-linked reveals.
  • Border and box-shadow emphasis replace scale-based “lift” effects on hover and focus. A shadow spreading from 2px to 12px reads as elevation without any geometry change. Note that box-shadow animates on the main thread; keep these transitions short (≤ 250ms) and off scroll timelines.
  • Color and background shifts replace attention-directing motion — wiggles, bounces, pulsing translations. background-color transitions trigger paint, so the same main-thread caveat applies: fine for short discrete transitions, wrong for continuous scroll-linked effects.

There is a genuine tension here: the most vestibular-safe properties (border-color, background-color, box-shadow) are not the most render-cheap ones, and the render-cheap transform is the highest-risk channel. Resolve it by role — scroll-linked continuous effects get opacity (safe on both axes), while discrete short transitions may use paint-triggering properties because a 200ms one-shot paint is imperceptible in a frame budget. The compositor-safe property analysis in the prefers-reduced-motion guide tabulates which properties stay off the main thread.

Common implementation patterns

Pattern 1: Opacity-first reveal with displacement as garnish

Structure every reveal so that opacity alone communicates the state change and displacement is an optional layer on top. Removing the displacement (via --motion-scale: 0) must never leave the effect looking broken:

.reveal {
  opacity: 0;                                   /* the actual signal */
  transform: translateY(calc(8px * var(--motion-scale)));  /* garnish */
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 50%;
}

@keyframes reveal {
  to { opacity: 1; transform: translateY(0); }
}

Pattern 2: Capped single-plane parallax

If a design requires parallax, collapse it to one moving plane with a token-capped offset, keeping content and all other layers locked to scroll speed. The dedicated parallax safety walkthrough covers auditing and retrofitting existing multi-layer implementations:

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

@keyframes drift {
  /* Total relative drift over a full viewport of scroll: 8px */
  to { transform: translateY(calc(-1 * var(--max-shift-decorative) * var(--motion-scale))); }
}

Pattern 3: Scale clamp for zoom effects

Entrance zooms are common in view transitions and hero sections. Clamping the delta keeps the optic-flow expansion below the vection threshold; for route changes, the reduced-motion view transition walkthrough replaces scale animations entirely with a crossfade:

.hero__image {
  animation: settle linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}

@keyframes settle {
  from { transform: scale(calc(1 + var(--max-scale-delta) * var(--motion-scale))); }
  to   { transform: scale(1); }
}

Pattern 4: Design-token motion budget

Publish the caps as named tokens in the design system so that designers and engineers share one vocabulary. A component spec that says “entrance: --shift-sm, 200ms” is auditable; one that says “slides in nicely” is not:

:root {
  --shift-sm: 8px;    /* decorative, ambient        */
  --shift-md: 16px;   /* content reveals            */
  --shift-lg: 24px;   /* modal/sheet entrances only */
  --motion-scale: 1;
}

Design-review checklist

Run every proposed animation through this list before implementation. A “no” on any item means redesign, not sign-off with a note:

  1. Distance — Is every translation ≤ 24px (interactive) or ≤ 8px (decorative), and ≤ 1/3 of the smaller viewport dimension on the smallest supported screen?
  2. Planes — Does at most one plane move independently of scroll? Multi-layer depth disparity is the defining parallax risk.
  3. Scale — Do all scale animations stay within 0.97–1.03? Is there no entrance zoom above 1.1?
  4. Rotation — Is there no continuous rotation anywhere, and no tilt beyond 5deg?
  5. Viewport coverage — Does no single animation move content covering more than a third of the viewport as one unit?
  6. Duration and settling — Do scroll-linked effects complete within a short animation-range and then hold still, rather than drifting for the element’s entire on-screen life?
  7. Substitution audit — For each remaining movement: could opacity, border, shadow, or color communicate the same change? If yes, why was movement chosen?
  8. Reduced-motion variant — Does prefers-reduced-motion: reduce zero all displacement, detach all scroll timelines, and still leave the interface fully legible?
  9. Interaction essentialness — Is any motion triggered by interaction essential to the operation (the narrow exemption WCAG allows)? Document the justification; the WCAG 2.2 compliance criteria guide defines what qualifies.
  10. Autoplay — Does anything move without a user gesture for more than 5 seconds without a pause control?

Keep the completed checklist in the pull request description. Reviewers can then diff the claims against the code — particularly item 1, which is mechanically checkable when displacements route through custom-property caps.

Browser support and @supports guard

The capping architecture uses calc(), min(), and custom properties — all supported everywhere the site runs. The scroll-driven pieces are the newer surface:

Feature Chrome / Edge Firefox Safari
animation-timeline: scroll() / view() 115 Behind a pref (Nightly) In active development (Technology Preview)
prefers-reduced-motion 74 / 79 63 10.1
min() inside calc() in transforms 79 75 11.1

Because Firefox stable and current Safari releases do not run scroll-driven timelines, gate the animation setup behind @supports so the baseline state in those browsers is the finished, fully-visible layout — which is automatically vestibular-safe:

/* Baseline: static, visible, zero motion */
.reveal { opacity: 1; transform: none; }

@supports (animation-timeline: view()) {
  .reveal {
    opacity: 0;
    transform: translateY(calc(var(--max-shift) * var(--motion-scale)));
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 50%;
  }

  @media (prefers-reduced-motion: reduce) {
    .reveal {
      animation: none;
      animation-timeline: auto;
      opacity: 1;
      transform: none;
    }
  }
}

Nesting the media query inside the @supports block keeps the override scoped to browsers that actually applied the animation. The progressive enhancement guide develops this layering discipline in detail.

Gotchas and failure modes

  1. Caps applied to keyframes but not to starting states. A @keyframes block animating to { transform: none; } inherits its from state from the element’s static transform. If a component sets transform: translateY(80px) outside the token system, the capped keyframes still travel the full 80px. Audit static transforms on animated elements, not just the keyframes.

  2. Relative motion between “individually safe” elements. Two columns animating 20px toward each other create 40px of converging motion in the user’s visual field. The checklist’s plane-count item exists precisely because per-element review misses this.

  3. vh-based displacement on mobile. translateY(10vh) is 76px on an iPhone SE and 108px on a tall Android viewport — both far beyond cap. Dynamic viewport units (dvh) shift mid-scroll as browser chrome collapses, adding un-requested extra motion. Express caps in px via min() so viewport-relative requests get clamped.

  4. Smooth scrolling stacking with scroll-linked motion. scroll-behavior: smooth on anchor navigation plays every scroll-driven animation along the traversed range at high speed. Combined with parallax layers, a single anchor click can produce several seconds of full-viewport multi-plane motion. Disable smooth scrolling under prefers-reduced-motion and keep scroll-linked effects short-range.

  5. Assuming prefers-reduced-motion coverage makes defaults exempt. OS motion settings are set by a minority of the users who need them. The default experience is the accessibility surface most affected users actually receive; safe ranges are the floor, and the reduced-motion overrides are the ceiling.

  6. Substituting filter: blur() for motion. Blur transitions feel motion-adjacent and tempt designers as a “safe” replacement, but animated blur is a documented discomfort trigger for some users with visual processing conditions, and large-radius blur is expensive to render. Prefer opacity, border, and color — the three channels with no known vestibular interaction.

Performance checklist

  • Route all displacement through --max-shift tokens so caps are enforced by min() in computed values, not by convention.
  • Keep scroll-linked animations on opacity and capped transform only — both composite without main-thread work each frame.
  • Reserve paint-triggering substitutes (border-color, background-color, box-shadow) for short discrete transitions, never for scroll-linked continuous effects.
  • Scope reveals with animation-range: entry 0% entry 50% (or tighter) so elements settle instead of drifting for their full on-screen life.
  • Zero --motion-scale and reset animation-timeline: auto under prefers-reduced-motion: reduce; verify with document.getAnimations() in the console.
  • Release will-change on elements whose animations are disabled by the reduced-motion override.
  • Confirm in the Performance panel that no layout or paint events fire during scroll on pages using these patterns.

Up: Accessibility & Inclusive Motion Standards