Vestibular-Safe Motion Patterns

Part of Accessible Motion Architecture.

Vestibular-safe motion is the discipline of choosing animations that convey state and continuity without triggering the balance-related symptoms — dizziness, nausea, disorientation — that certain movement provokes in people with vestibular disorders. The trigger is not jank or dropped frames; it is vection, the perception of self-motion created when a large region of the visual field sweeps in a consistent direction. That distinction matters because the most performance-optimised animation on the site, a buttery full-viewport parallax running entirely on the GPU, is precisely the kind of motion that makes vestibular users ill. This guide identifies which patterns carry that risk, explains why the risk is perceptual rather than mechanical, and gives compositor-safe alternatives that preserve the design intent.


What actually triggers vestibular symptoms

The vestibular system in the inner ear reports acceleration and orientation. When the eyes report large, coherent motion that the inner ear does not corroborate, the mismatch produces the same symptoms as motion sickness. On the web, four broad categories reliably produce that mismatch:

  • Large translations — content that flies in from off-screen, slides across a substantial fraction of the viewport, or scrolls faster than the user’s input implies.
  • Parallax — foreground and background layers moving at different rates, which is a deliberate depth illusion and therefore an especially strong vection cue.
  • Spin and zoom — continuous rotation or scale changes, which expand or rotate a wide field and are read as the viewer moving toward or around the content.
  • Autoplay — any of the above starting without user action, removing the person’s ability to brace for or avoid it.

None of these is defined by its frame rate. A 12fps parallax and a 120fps parallax are both hazards; the second is arguably worse because the smoothness reinforces the illusion. This is the single most important idea on the page and the reason performance tooling alone can never tell you whether an animation is accessible.

Vestibular risk spectrum for CSS motion Three columns of motion types ordered by increasing vestibular risk. Safe: opacity cross-fades, colour transitions, small translations under ten pixels, static replacement. Use with care: scale or zoom on hover, short user-triggered slide-ins, reduced-distance parallax. Avoid or gate off: full-page parallax, large fly-ins, continuous spin, autoplay carousels. Vestibular risk increases left to right Safe under reduced-motion Use with care Avoid or gate off Opacity cross-fades Colour / background transitions Small translation (< ~10 px) Static replacement Scale / zoom on hover Short user-triggered slide-ins Reduced-distance parallax Full-page parallax Large translations / fly-ins Spin & continuous rotation Autoplay carousels

The remainder of this page treats each column of that spectrum in turn, and returns repeatedly to the mechanism: displacement across the visual field is the hazard, and it is orthogonal to how cheaply the browser renders it.


Execution model: why the GPU cannot save you here

Every other topic on this site is preoccupied with getting animation off the main thread and onto the compositor. That work is still worth doing — a stuttering animation is bad for everyone — but it does nothing to reduce vestibular risk, and it is important to understand why.

When you animate transform: translate or scale, the browser rasterises the element once, uploads it as a GPU texture, and the compositor thread re-composites that texture at a new position each frame. The main thread is untouched; layout and paint are skipped entirely. From the machine’s point of view this is the ideal path. From the viewer’s inner ear, nothing has changed: a texture sweeping 600 pixels across the retina in 400ms produces the same optical flow whether it was composited on the GPU or painted on the main thread. The compositor makes the motion smoother, which strengthens the vection cue rather than weakening it.

The invalidation story is different from performance topics too. There is no reflow to watch for, no UpdateLayerTree spike, no forced synchronous layout. The “cost” of a vestibular-unsafe animation does not appear in a Performance trace at all. It appears in the symptoms of a subset of your users, which is exactly why the discipline has to be applied at design time from the taxonomy above, not discovered later in DevTools.

The practical consequence: compositor-safety and vestibular-safety are independent axes. An animation can be perfectly composited and deeply harmful (full-page parallax), or main-thread-heavy and perfectly safe (a slow colour fade on a large background). You must satisfy both axes, and the reduced-motion query is the mechanism that lets you ship the smooth version to those who want it while serving a safe fallback to those who do not.


Motion property reference by vestibular risk

The table below maps common animated properties to their vection potential and the compositing tier they run on, so you can see at a glance that the two do not correlate.

Property / pattern Typical values Compositing tier Vestibular risk
opacity 01 Composite Minimal — no directional flow
color / background-color any → any Paint Minimal — no displacement
transform: translate (small) translateY(6px) Composite Low when under ~10 px and user-triggered
transform: scale (subtle) scale(1.02) Composite Low at tiny deltas; rises fast with magnitude
transform: translate (large) translateX(60vw) Composite High — wide optical flow
Parallax (multi-layer translate) layers at differing rates Composite High — deliberate depth illusion
transform: rotate (continuous) rotate(360deg) looping Composite High — rotational vection
transform: scale (zoom) scale(1)scale(2) Composite High — expansion reads as approach
Autoplay of any of the above n/a varies High — removes user control

Read down the compositing tier column and then down the vestibular risk column: the two are unrelated. opacity and a large translate both composite, yet one is the safest tool you have and the other is among the most hazardous.


Safe alternatives that preserve intent

The goal is never to strip motion out and leave the interface feeling dead. It is to re-express the communicative purpose of each animation with a mechanism that does not sweep the visual field.

1. Replace a slide-in with an opacity cross-fade

Intent: an element that previously flew up 40 pixels into place now simply fades in, carrying the same “new content has arrived” meaning without directional flow.

/* Full-motion: a short, small rise plus fade for users who accept motion */
@media (prefers-reduced-motion: no-preference) {
  .panel-enter {
    animation: panel-rise 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
  }
  @keyframes panel-rise {
    from { opacity: 0; transform: translateY(8px); }
    to   { opacity: 1; transform: translateY(0); }
  }
}

/* Vestibular-safe: opacity only, no displacement across the field */
@media (prefers-reduced-motion: reduce) {
  .panel-enter {
    animation: panel-fade 200ms ease both;
  }
  @keyframes panel-fade {
    from { opacity: 0; }
    to   { opacity: 1; }
  }
}

Rendering Impact: opacity + tiny transform — composite only; the reduced-motion branch drops the transform entirely so no optical flow remains.

2. Convey depth with contrast, not parallax

Intent: replace a two-layer parallax hero — the classic vestibular hazard — with a static composition whose depth is implied by shadow and scale rather than differential movement. The dedicated parallax replacement guide works through the full decision matrix; the core CSS is a fade with no scroll-coupling.

/* Full-motion: a gentle, reduced-distance parallax bounded to a few pixels */
@media (prefers-reduced-motion: no-preference) {
  .hero-layer {
    transition: transform 120ms linear;
    /* JS sets --shift within a small clamp; never coupled 1:1 to scroll */
    transform: translateY(calc(var(--shift, 0) * 1px));
  }
}

/* Vestibular-safe: the layer is fixed; depth comes from a static shadow */
@media (prefers-reduced-motion: reduce) {
  .hero-layer {
    transition: none;
    transform: none;
    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
  }
}

Rendering Impact: full-motion branch is composite (transform only); reduced-motion branch is static — no animation, box-shadow paints once at load.

3. Turn a spin loader into a non-rotational progress cue

Intent: a continuously rotating spinner is rotational vection. Replace it, under reduced motion, with a pulsing opacity indicator that communicates “working” without any rotation.

/* Full-motion: conventional rotating spinner */
@media (prefers-reduced-motion: no-preference) {
  .spinner { animation: spin 800ms linear infinite; }
  @keyframes spin { to { transform: rotate(360deg); } }
}

/* Vestibular-safe: opacity pulse, no rotation, no displacement */
@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation: pulse 1200ms ease-in-out infinite;
  }
  @keyframes pulse {
    0%, 100% { opacity: 0.4; }
    50%      { opacity: 1; }
  }
}

Rendering Impact: transform: rotate composites but creates rotational vection; the reduced-motion opacity pulse composites and removes the vection cue.

Choosing the duration and distance limits that keep the full-motion branch itself safe is a topic in its own right — see choosing safe animation durations and thresholds for the flash-rate, distance, and velocity ceilings that decide when a “full-motion” branch is still acceptable to serve.


DevTools workflow: what you can and cannot verify

Because the hazard is perceptual, most of the audit is manual. A structured pass still helps.

Chrome DevTools — Rendering tab

  1. Open DevTools, then the Rendering drawer (⋮ → More tools → Rendering).
  2. Set Emulate CSS media feature prefers-reduced-motion to reduce. Reload and walk the page: every large translation, parallax, spin, and autoplay must have collapsed to a fade or a static state. Anything still sweeping the field is an unshipped hazard.
  3. Switch the emulation back to no-preference and confirm the full-motion branch still reads well — the two branches should feel like the same product.

Chrome DevTools — Animations panel

  1. Open the Animations panel (⋮ → More tools → Animations).
  2. Trigger the interface and inspect captured groups. For each, read the animated property and the delta. Flag any translate whose distance exceeds ~10 percent of the viewport, any scale beyond a few percent, and any rotate that loops.
  3. Use the panel’s speed control to slow playback to 25 percent — a large sweep that felt acceptable at full speed is easier to judge for displacement magnitude when slowed.

Manual field-of-view check

No tool measures vection. Sit back to a normal viewing distance, trigger each animation, and ask whether a substantial region of the screen moves coherently in one direction. If yes, it belongs in the “avoid or gate off” column regardless of what the flame chart says.


Failure modes and fixes

Problem: A parallax hero was moved onto the GPU to fix jank, and vestibular complaints persisted. Root cause: Compositing addressed frame rate, not vection. The optical flow across the viewport is identical; the smoother motion may even intensify the illusion. Fix: Gate the parallax behind prefers-reduced-motion: no-preference, and ship a static or fade-only hero to everyone else. Compositing is not a substitute for a reduced-motion branch.


Problem: A modal scales up from scale(0.2) to scale(1), and some users report a lurch. Root cause: A large scale delta is an expansion of the whole element across the field — a zoom, which reads as the content rushing toward the viewer. Fix: Reduce the delta to scale(0.96)scale(1) for full motion, and drop to opacity-only under reduced motion. Small scale changes read as emphasis; large ones read as approach.


Problem: An autoplay carousel advances every four seconds and cannot be paused. Root cause: Autonomous, repeating translation removes the user’s ability to anticipate or avoid the motion, compounding the displacement hazard. Fix: Remove autoplay, or provide a visible pause control and honour reduced-motion by disabling auto-advance. This also intersects WCAG 2.2.2 Pause, Stop, Hide.


Problem: Reduced-motion was implemented by setting animation: none globally, and the interface now snaps with no continuity. Root cause: Removing all animation over-corrects; brief non-directional fades are safe and preserve the sense that state changed. Fix: Replace suppressed motion with a short opacity transition rather than nothing. Less motion, not zero motion.


Accessibility and reduced-motion notes

Treat prefers-reduced-motion: reduce as an explicit request from the person, usually set at the OS level by someone who has learned that web motion makes them unwell. The correct default posture is: design the safe version first, then layer the richer motion on top behind no-preference. Building the other way round — full motion first, reduced-motion as an afterthought — tends to leave hazards unhandled in edge states like error toasts and route transitions.

Remember that the query is a coarse signal. It does not distinguish a person with a mild preference from one with severe vestibular dysfunction, so the reduced branch should be genuinely safe, not merely shorter. Keep fades brief, keep any residual movement tiny and non-directional, and never let anything play automatically. The detailed thresholds — flash rate, duration caps, distance and velocity limits — live in choosing safe animation durations and thresholds, and the reduced-motion architecture itself is covered in prefers-reduced-motion architecture.


Frequently asked questions

Why does a large composited transform still feel disorienting if it runs at 60fps?

Vestibular discomfort is a perceptual response to apparent self-motion, not a performance problem. A large translate or scale sweeps a wide region of the retina, and the brain reads that coherent optical flow as vection — the illusion that the viewer is moving. Running the animation at a smooth 60fps on the compositor actually strengthens the illusion because the motion is continuous, so frame rate is irrelevant to whether the pattern triggers symptoms.

Is opacity a safe substitute for movement for people with vestibular disorders?

Yes. opacity and colour changes produce no directional displacement across the visual field, so they create no vection. A brief cross-fade is the standard vestibular-safe replacement for a slide or fly-in, and because it stays on the compositor thread it costs no more to render than the motion it replaces.

How large can a translation be before it becomes a vestibular hazard?

There is no exact spec threshold, but a workable rule is to keep any movement under roughly 10 percent of the viewport dimension, keep its velocity low, and make it user-triggered rather than autonomous. A few-pixel nudge reads as feedback; a sweep across half the screen reads as self-motion.

Does prefers-reduced-motion mean I must remove all animation?

No. The preference asks for less motion, not none. Brief opacity and colour fades, and tiny non-directional changes, are generally acceptable under the query. What must be removed are the vection triggers: parallax, large translations, continuous spin and zoom, and anything that plays automatically.