---
title: 'Accessible Motion'
source: 'https://academia.sh/en/courses/layout-and-responsive-design/accessible-motion'
course: 'Layout Systems and Responsive Design'
language: en
updated: '2026-08-17T18:09:25+00:00'
license: 'CC BY-SA 4.0'
---

# Accessible Motion

What motion sensitivity is, separating large-area motion from local motion, the cost of turning motion off wholesale in the reduced-motion preference, and the rules for replacing motion instead of removing it.

The previous lesson covered motion's cost to the browser. There is one more cost, and it
falls not on the browser but on the user: motion on screen produces dizziness, nausea, or
difficulty focusing in some users.

The Responsive Design topic introduced the `prefers-reduced-motion` query and showed that
the user announces this preference from the operating system. This lesson covers what goes
inside that query: which motion is removed, which is kept, and what does wholesale
shutdown cost?

## What Motion Sensitivity Responds To

Motion sensitivity arises from a mismatch between the inner ear's balance mechanism and
visual perception. When a large area of the screen moves, the visual system produces the
impression that we ourselves are moving; the balance mechanism does not confirm this, and
the mismatch causes discomfort.

The kinds of motion that trigger it are specific: large areas sliding, layers scrolling at
different speeds, zooming in and out, rotation, oscillation. The kinds that do not trigger
it are specific too: opacity change, color change, small-area displacement.

The distinction is not "is there an animation or not." The distinction is **how much area,
over how much distance**, the motion carries.

## Cascade and Area Calculation

The following program computes two things. First, it resolves the winning declarations
for the two states of the reduced-motion preference using cascade rules. Second, it
classifies four motions by the area they cover and the distance they travel.

```js
// reduce.mjs — cascade resolution under the preference, and the area a motion covers on screen
// Cascade: importance first, then specificity triples, then write order.
const RULES = [
  { order: 1, selector: ".indicator", specificity: [0, 1, 0], condition: null,     declaration: { "animation-name": "pulse", "animation-duration": "1400ms" } },
  { order: 2, selector: ".card",      specificity: [0, 1, 0], condition: null,     declaration: { "transition-duration": "240ms" } },
  { order: 3, selector: "*",          specificity: [0, 0, 0], condition: "reduce", declaration: { "animation-duration": "0.01ms", "transition-duration": "0.01ms" }, important: true },
  { order: 4, selector: ".indicator", specificity: [0, 1, 0], condition: "reduce", declaration: { "animation-name": "none" } },
];

const applies = (rule, preference) => rule.condition === null || rule.condition === preference;
const higher = (a, b) => {                       // comparison of specificity triples
  for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
  return false;
};

function resolve(preference, properties) {
  const result = {};
  for (const property of properties) {
    let winner = null;
    for (const r of RULES) {
      if (!applies(r, preference) || !(property in r.declaration)) continue;
      if (winner === null) { winner = r; continue; }
      const rImportant = !!r.important, wImportant = !!winner.important;
      if (rImportant !== wImportant) { if (rImportant) winner = r; continue; }
      if (higher(r.specificity, winner.specificity)) { winner = r; continue; }
      if (!higher(winner.specificity, r.specificity) && r.order > winner.order) winner = r;
    }
    result[property] = winner ? `${winner.declaration[property]}   (rule ${winner.order}: ${winner.selector}${winner.important ? " !important" : ""})` : "no declaration";
  }
  return result;
}

const PROPERTIES = ["animation-name", "animation-duration", "transition-duration"];
for (const preference of ["no-preference", "reduce"]) {
  console.log(`\n=== prefers-reduced-motion: ${preference} ===`);
  const s = resolve(preference, PROPERTIES);
  for (const p of PROPERTIES) console.log(`  ${p.padEnd(20)} -> ${s[p]}`);
}

// --- proportion of the viewport a motion covers ---
// Large-area, long-path motion is treated in a different class than small-area motion
// for users with motion sensitivity.
console.log("\n--- ratio of motion to viewport (1280 x 800 units) ---");
const W = 1280, H = 800;
const MOTIONS = [
  { name: "card highlight",     w: 220, h: 120, path: 8 },
  { name: "indicator pulse",    w: 12,  h: 12,  path: 1 },
  { name: "side panel opening", w: 320, h: 800, path: 320 },
  { name: "background shift",   w: 1280, h: 800, path: 240 },
];
console.log("motion".padEnd(20) + "area ratio".padStart(12) + "path / screen".padStart(15) + "  classification");
for (const m of MOTIONS) {
  const area = (m.w * m.h) / (W * H);
  const path = m.path / Math.max(W, H);
  const largeArea = area >= 0.25 && path >= 0.05;
  console.log(
    m.name.padEnd(20) +
    `%${(area * 100).toFixed(1)}`.padStart(12) +
    `%${(path * 100).toFixed(1)}`.padStart(15) +
    "  " + (largeArea ? "large-area motion" : "local motion"),
  );
}
```

```

=== prefers-reduced-motion: no-preference ===
  animation-name       -> pulse   (rule 1: .indicator)
  animation-duration   -> 1400ms   (rule 1: .indicator)
  transition-duration  -> 240ms   (rule 2: .card)

=== prefers-reduced-motion: reduce ===
  animation-name       -> none   (rule 4: .indicator)
  animation-duration   -> 0.01ms   (rule 3: * !important)
  transition-duration  -> 0.01ms   (rule 3: * !important)

--- ratio of motion to viewport (1280 x 800 units) ---
motion                area ratio  path / screen  classification
card highlight              %2.6           %0.6  local motion
indicator pulse             %0.0           %0.1  local motion
side panel opening         %25.0          %25.0  large-area motion
background shift          %100.0          %18.8  large-area motion
```

The second block gives a triage criterion. The card highlight covers less than three
percent of the viewport and travels eight units; the indicator pulse is even smaller. The
side panel covers a quarter of the viewport and travels a distance equal to its own width;
the background shift covers the entire screen.

The thresholds are not exact numbers, they are triage tools: motions where both the area
covered and the distance traveled are large are placed in a separate class. Motions in
this class are removed once the preference is announced; local motions can be kept,
shortened.

## The Cost of Wholesale Shutdown

The first block resolves a commonly written pattern: a rule that writes a near-zero
duration to the universal selector with `!important`. Reading the result shows the rule's
power — the third rule suppresses even the duration of the first rule, which is more
specific than it is.

Why is the duration $0.01$ milliseconds and not $0$? A zero-duration transition does not
run at all, and its end event does not fire either; scripts waiting for motion to finish
never receive that event. A duration that is very short but greater than zero makes the
motion invisible while keeping the event chain intact.

The pattern's cost is that it makes no distinction. The same rule also suppresses:

- A form's error message appearing softly — this is not motion, it is attention
  direction.
- A dropdown menu closing — an instant close hides where the element went.
- A loading indicator spinning — it carries status information; if removed, text needs to
  take its place.

Wholesale shutdown can be written as a starting point, but if it is not reviewed component
by component, it is a half solution. This is what the fourth rule does: it turns off the
indicator's animation by name and takes on the responsibility of putting another
declaration in its place.

## Not Removing, Replacing

The reduced-motion preference does not mean "I want no animation at all"; it asks for
large-area, distance-covering motion to be reduced. This does not mean erasing the
information carried by the motion either.

Three rules are enough:

1. **Replace displacement with opacity.** A panel that slides in can instead appear in
   place. The information is preserved, the path disappears.
2. **Shorten the duration, remove the loop.** A motion that repeats endlessly is reduced to
   a single round under the preference, or stopped.
3. **Replace status-carrying motion with text.** Text saying "waiting for data" carries the
   same information as a spinning indicator and does not move at all.

Scrolling text, auto-advancing promotional carousels, and background videos are a separate
topic. Accessibility criterion 2.2.2 requires motion lasting more than five seconds that
starts on its own to be pausable; 2.3.1 forbids flashing more than three times per second;
2.3.3 requires interaction-triggered motion to be turned off. The first two apply even when
no preference has been announced.

## Choosing a Notational Direction

There are two notational directions. The first writes motion as the default and turns it
off in the `reduce` query. The second writes without motion and turns it on in the
`no-preference` query.

The second has an advantage: when the query cannot be evaluated, its condition is not met,
and motion does not turn on. That is, uncertainty resolves in favor of no motion. In the
first direction, uncertainty resolves in favor of motion.

Its cost is that motion is written in a place separate from the default state; a
component's style is split into two parts. The choice is made according to how much
weight motion carries on the page: if there are only a few small transitions throughout
the page, the first direction is enough; if motion sits at the center of the design, the
second direction is safer.

## On the Station Page

```css
/* motion.css — step 5: reduced-motion preference */
@media (prefers-reduced-motion: reduce) {
  .measurement-cards > .card {
    transition-duration: 80ms;
  }

  .measurement-cards > .card:hover,
  .measurement-cards > .card:focus-within {
    transform: none;
    box-shadow: 0 0 0 2px var(--brand-dark);
  }

  .station-status[data-status="waiting"] .indicator {
    animation: none;
    will-change: auto;
  }

  .sidebar {
    scroll-behavior: auto;
  }
}
```

The card highlight was not removed, it was **replaced**: instead of lifting and growing, a
frame appears. The user still sees which card is highlighted, and the card does not change
place. The duration was shortened, not zeroed; a sudden change is uncomfortable too.

The indicator's animation is turned off and the layer hint is withdrawn. Status
information also needs to be given in text; this is the document's responsibility, not the
style file's, and the text next to the `.indicator` element takes on that role.

The `scroll-behavior: auto` declaration turns smooth scrolling into instant scrolling.
Because scrolling moves the entire viewport, it belongs in the large-area motion class.

## Summary

- Motion sensitivity arises from a mismatch between visual perception and the balance
  mechanism; what triggers it is not whether an animation exists, but the size of the
  area covered and the distance traveled.
- Motions are triaged by computing the area covered and the path ratio: large-area ones
  are removed, local ones can be kept, shortened.
- An `!important` duration rule written on the universal selector suppresses every
  declaration; duration is written near zero, not zero, so that end events keep firing.
- Motion's information is not removed along with the motion: displacement is turned into
  opacity, loops are stopped, status-carrying motion is replaced with text.
- The choice between writing motion as the default and turning it off, versus writing
  without motion and turning it on, determines which direction uncertainty resolves in.

## Next Step

Throughout this topic, a third style file named `motion.css` has grown. Now three separate
files feed declarations to the same element: visual declarations in `station.css`, layout
in `layout.css`, motion here. Class names have multiplied too, and which name belongs to
which component is known only by habit. The next topic turns this disorder into a
question: by what rules is the style layer named and divided so that it stays readable as
it grows?
