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
- Designing Parallax Effects Safe for Vestibular Disorders
- Building Reduced-Motion Variants of View Transitions
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:
- 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
pxand viewport-relative terms —min(24px, 3vh)is a practical formulation. - 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.
- 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
2pxto12pxreads as elevation without any geometry change. Note thatbox-shadowanimates 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-colortransitions 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:
- Distance — Is every translation ≤ 24px (interactive) or ≤ 8px (decorative), and ≤ 1/3 of the smaller viewport dimension on the smallest supported screen?
- Planes — Does at most one plane move independently of scroll? Multi-layer depth disparity is the defining parallax risk.
- Scale — Do all scale animations stay within 0.97–1.03? Is there no entrance zoom above 1.1?
- Rotation — Is there no continuous rotation anywhere, and no tilt beyond 5deg?
- Viewport coverage — Does no single animation move content covering more than a third of the viewport as one unit?
- Duration and settling — Do scroll-linked effects complete within a short
animation-rangeand then hold still, rather than drifting for the element’s entire on-screen life? - Substitution audit — For each remaining movement: could opacity, border, shadow, or color communicate the same change? If yes, why was movement chosen?
- Reduced-motion variant — Does
prefers-reduced-motion: reducezero all displacement, detach all scroll timelines, and still leave the interface fully legible? - 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.
- 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
-
Caps applied to keyframes but not to starting states. A
@keyframesblock animatingto { transform: none; }inherits its from state from the element’s statictransform. If a component setstransform: translateY(80px)outside the token system, the capped keyframes still travel the full 80px. Audit static transforms on animated elements, not just the keyframes. -
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.
-
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 inpxviamin()so viewport-relative requests get clamped. -
Smooth scrolling stacking with scroll-linked motion.
scroll-behavior: smoothon 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 underprefers-reduced-motionand keep scroll-linked effects short-range. -
Assuming
prefers-reduced-motioncoverage 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. -
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-shifttokens so caps are enforced bymin()in computed values, not by convention. - Keep scroll-linked animations on
opacityand cappedtransformonly — 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-scaleand resetanimation-timeline: autounderprefers-reduced-motion: reduce; verify withdocument.getAnimations()in the console. - Release
will-changeon 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.
Related
- How to Respect prefers-reduced-motion in CSS Animations — the override mechanics these safe defaults sit on top of, including cascade-layer and framework patterns
- Motion Preference Support Matrix: Browsers & Operating Systems — where the OS toggles live and which engines honor them
- Smooth Parallax Scrolling Without JavaScript — the implementation technique the parallax safety limits in this guide apply to
- WCAG 2.3.3 Animation from Interactions: A Compliance Checklist — the success criterion that formalizes the interaction-triggered motion items in the design review