Skip to content
academia.sh

Lesson 17 / 23

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.

Contents

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.

// 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.010.01 milliseconds and not 00? 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

/* 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?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close