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 milliseconds and not ? 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:
- Replace displacement with opacity. A panel that slides in can instead appear in place. The information is preserved, the path disappears.
- Shorten the duration, remove the loop. A motion that repeats endlessly is reduced to a single round under the preference, or stopped.
- 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
!importantduration 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.