Announcing changes and respecting preferences.
A sighted traveller taps Direct only and watches the list shrink to 8 trains. A traveller using a screen reader taps the same checkbox and hears that it is checked, then nothing. Single-page apps change the screen all the time without telling anyone. This topic covers two channels: live regions that carry changes out to assistive technology, and media features that carry the user's own settings (less motion, more contrast, forced colours) in to your CSS. It also covers the WCAG 2.2 AA numbers every screen has to pass: contrast, reflow, text size, target size.
Builds on Semantics and the accessibility tree.
The idea.
A page that updates in place owes two messages. It must say what changed to people who cannot see the change, and it must listen to what people have already told their device about how they need things shown.
Picture Railmoor's timetable for Ashcombe Junction to Kestrel Bay on a Thursday afternoon: 12 trains. A traveller ticks Direct only. The request goes out, four rows disappear, and the line above the list now reads 8 trains found. With eyes on the screen that change is obvious. With a screen reader, focus is still on the checkbox, the reader has just said "Direct only, checkbox, checked", and the list that changed is somewhere else on the page. Nothing moved focus there, so nothing gets read. The traveller has no idea whether the filter did anything.
The fix is not to drag focus to the list; that would throw the traveller out of the filter panel. You mark one element as a live region, and the browser passes changes to its text on to assistive technology as announcements. You also choose how urgent each one is: polite waits until the reader has finished speaking, assertive cuts in. WCAG 2.2 calls these status messages (success criterion 4.1.3, level AA), and a single-page app that shows status messages like this without a live region fails it.
The other channel runs the opposite way. A traveller with a vestibular disorder has switched on Reduce motion in their operating system; another uses Windows contrast themes; a third has set the default text size to 150%. The browser exposes those settings as CSS media features (prefers-reduced-motion, prefers-contrast, forced-colors) and as ordinary zoom. A page that never asks about them animates the route map anyway, draws focus rings with box-shadow that vanish in forced colours, and clips text at 200%. Under both channels sits a set of measurable WCAG 2.2 AA thresholds that every Railmoor screen must pass.
| No. | Area | Requirement | Target / measure |
|---|---|---|---|
| 01 | Status messages (4.1.3, AA) | Results, progress, errors and confirmations reach assistive technology without taking focus. | role=status / role=alert / role=logregion present before it changes Railmoor's result count, delay banner, save toast |
| 02 | Text contrast (1.4.3, AA) | Text stands out from its background. | ≥ 4.5:1 body text≥ 3:1 large text (≈24 px, or ≈18.5 px bold) Measured on the actual pixels behind the text, not rounded |
| 03 | Non-text contrast (1.4.11, AA) | Control boundaries, states, focus indicators and meaningful graphics are visible. | ≥ 3:1 against adjacent colours Seat outlines, checkbox borders, the route line |
| 04 | Use of colour (1.4.1, A) | Colour is never the only way information is carried. | words or icon beside every colour cue Delayed trains get the words "Delayed 12 min", not just amber |
| 05 | Reflow and text size (1.4.10, 1.4.4, AA) | Works at 320 CSS px wide with only vertical scrolling, and with text at 200%. | 320 CSS px = 1280 px at 400% zoomtext 200% without loss Timetable rows restack instead of scrolling sideways |
| 06 | Target size (2.5.8, AA, new in 2.2) | Pointer targets are big enough, or spaced far enough apart. | 24 × 24 CSS pxor the 24 px circle spacing test Seat map buttons |
| 07 | Motion and timing (2.2.1, 2.2.2, A) | Time limits can be extended; moving content that runs over 5 s can be paused. | warn + ≥20 s to extendextend ≥10 times Seat hold countdown; honour prefers-reduced-motion as well |
How it works.
A live region is a contract between three parties: your DOM, the browser's accessibility tree, and the screen reader's speech queue. Media features are a read-only view of the user's settings. The WCAG numbers are arithmetic you can check in CI.
Live regions, step by step
When text inside an element marked aria-live (or given a live role) changes, the browser raises an event on its accessibility API, and the screen reader decides what to say and when. Three attributes shape that. aria-live picks the urgency: off, polite (spoken when the user is idle) or assertive (spoken now, interrupting). aria-atomic says whether to read the whole region or only the part that changed; a count such as "8 trains found" needs the whole sentence, so it is atomic. aria-relevant says which kinds of change count, additions and text by default.
The rule that catches most teams: the region has to exist, and be known to the screen reader, before its content changes. The browser reports changes to a region it is already watching; a region that appears already holding its text is a new node, not a change, and is usually not announced at all. So Railmoor puts an empty status element in the server-rendered HTML and only ever writes into it. When a region has to be created later (a component that mounts on demand), create it empty and write the text in a later task, after the browser has had a chance to expose it.
Assertive exists for things that cannot wait. WAI-ARIA 1.2 allows assistive technology to throw away queued polite updates when an assertive one arrives, and an interruption cuts off whatever the traveller was listening to, often the name of the control they just used. A rule of thumb that holds up: polite for results and confirmations, assertive only when a delay would cost the traveller something.
The live roles and what they imply
| Markup | Implicit aria-live | Implicit aria-atomic | What it is for | At Railmoor |
|---|---|---|---|---|
| role="status" (and <output>) | polite | true | Advisory results and progress | "8 trains found", "Saved to My journeys" |
| role="alert" | assertive | true | Urgent, time-sensitive messages | "2 minutes left on your seat hold" |
| role="log" | polite | false (default) | A growing history where new entries are appended | Not used here; it is the chat pattern |
| role="timer" | off | false (default) | A clock or countdown that would be noise if read every second | The visible 09:58 countdown on the seat hold |
| role="marquee" | off | false (default) | Scrolling non-essential text such as a ticker | The departure board strip on the home page |
| aria-live="polite" on any element | polite | false unless set | A custom region with no special role | The disruption banner, with aria-atomic="true" |
What the traveller hears after ticking Direct only
Scenario 1 of 4: As described.
Timeline as a list
What the traveller hears after ticking Direct only: 4 lanes, from 0 s to 4 s.
- 0 s · Traveller · Space on Direct only
- 0.1–1.6 s · Screen reader speech · "Direct only, checkbox, checked"
- 0.2–0.9 s · DOM (rows and count) · fetch and render 8 rows
- 0.9 s · DOM (rows and count) → Live region (role=status), arriving 1.4 s: settle 500 ms
- 1.4 s · Live region (role=status) · text written
- 1.4–1.7 s · Screen reader speech · window: waits for the reader to go idle
- 1.4 s · Live region (role=status) → Screen reader speech, arriving 1.7 s: polite
- 1.7–2.7 s · Screen reader speech · "8 direct trains found" (ok)
From a filter tap to one announcement
- Traveller + screen reader → TimetableResults: toggle "Direct only"
- Note over TimetableResults: aria-busy="true" on the list while it fetches
- TimetableResults → TimetableResults: render 8 rows, restart the 500 ms settle timer
- Note over ResultStatus: no further change for 500 ms
- TimetableResults → ResultStatus: textContent = "8 direct trains found"
- ResultStatus → Accessibility API: text changed in a polite, atomic region
- Accessibility API → Traveller + screen reader (reply): spoken once the reader is idle
A region that exists first and speaks once
<form id="filters"> … <input type="checkbox" id="direct"> <label for="direct">Direct only</label> … </form>
<!-- Visible count, and the live region, from the first paint. Empty until results settle. -->
<p id="result-count" role="status"></p>
<ul id="trains" aria-describedby="result-count"> … </ul>
const countEl = document.getElementById('result-count');
const list = document.getElementById('trains');
let settle;
export function startSearch() {
list.setAttribute('aria-busy', 'true'); // set when the request goes out
}
export function showResults(trains, filterLabel) {
renderRows(list, trains);
list.removeAttribute('aria-busy'); // cleared once the rows are in
// Announce the outcome, not every intermediate render.
clearTimeout(settle);
settle = setTimeout(() => {
// Context in the text keeps a repeat ("8 … found" twice) from reading as "no change".
countEl.textContent = formatCount(trains.length, filterLabel); // "8 direct trains found"
}, 500);
}
// Newer browsers: speak without a DOM node. Kiosks two years old lack it, so detect.
export function say(message, urgent = false) {
if ('ariaNotify' in document) {
document.ariaNotify(message, { priority: urgent ? 'high' : 'normal' });
} else {
(urgent ? alertRegion : politeRegion).textContent = message;
}
}
Announce outcomes, not activity
A live region is a microphone to one listener, so treat every write as speech. Say the result of an action (8 direct trains found), not the machinery (loading, loading, loaded). Wait until things settle before speaking; 300 to 700 ms after the last change is a common range, and Railmoor uses 500. Keep the sentence short and front-load the new fact. Count words need plural rules in every language, so the count goes through the same message formatting as the rest of the UI (see Messages and plurals). And some browser and reader pairs skip a region whose text is set to exactly what it already said, which is why Railmoor's message names the filter.
Media features, the user's side of the conversation
What the user can tell you, and what Railmoor does with it
| Media feature | Values | Set by | Railmoor's response |
|---|---|---|---|
| prefers-reduced-motion | no-preference | reduce | Reduce motion (macOS, iOS), Animation effects off (Windows 11), Remove animations (Android) | Route map shows the whole route at once instead of a train gliding along it; rows appear without sliding; skeleton shimmer stops. Fades under 200 ms stay. |
| prefers-contrast | no-preference | more | less | custom | Increase contrast (macOS, iOS), contrast settings on Windows | Secondary text goes from slate #5F6B78 to ink #1F2A37; seat outlines go from grey #7B8794 to ink; dashed dividers become solid. |
| forced-colors | none | active | Windows contrast themes | Let the system palette win. Selected seats use Highlight / HighlightText; focus rings are outlines, since box-shadow is forced to none. Every engine supports the query; Windows contrast themes are where travellers turn it on. |
| prefers-color-scheme | light | dark | OS or browser appearance | Swaps the colour tokens; each token pair is contrast-checked in both schemes (see Design tokens and theming). |
| (no media feature) zoom and text size | browser zoom, default font size | Browser settings, OS text scaling | Sizes in rem, no fixed heights on text boxes, layout that restacks at narrow widths. |
Reading preferences in CSS and in script
.train-row { animation: row-in 180ms ease-out; }
.skeleton { animation: shimmer 1.2s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.train-row, .skeleton { animation: none; }
}
@media (prefers-contrast: more) {
:root {
--text-secondary: #1F2A37; /* 14.54:1 on white, was 5.44:1 */
--seat-outline: #1F2A37; /* was #7B8794, 3.66:1 */
}
}
.seat:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
@media (forced-colors: active) {
.seat[aria-selected="true"] { background: Highlight; color: HighlightText; }
.delay-badge { border: 1px solid CanvasText; } /* its amber fill is gone */
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
function drawRoute(map, journey) {
if (reduce.matches) {
map.showWholeRoute(journey); // final state, no travel animation
} else {
map.animateTrainAlong(journey, { durationMs: 2400 });
}
}
// The setting can change while the page is open.
reduce.addEventListener('change', () => drawRoute(routeMap, currentJourney));
Reduced motion means reduce, not remove. The motions that trouble people with vestibular disorders are large movements: parallax, zooms, things sliding across big areas, auto-scrolling. A short opacity fade or a colour change is usually fine and often helps orientation. Railmoor keeps a 150 ms fade on the delay banner and drops the train that glides across the route map. How to make the remaining animations cheap (transform and opacity on the compositor) is a separate topic, Compositor animations. WCAG's 2.3.3 Animation from Interactions asks for the same thing, though only at level AAA.
The numbers under every screen
Contrast of Railmoor's brand teal and its darker sibling on white
- Brand teal
- #2BA99B → R 43, G 169, B 155
- Deep teal
- #177A6F → R 23, G 122, B 111
- White background
- #FFFFFF, luminance 1.0
- Linearise each channel c = value / 255
- c ≤ 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055)^2.4WCAG 2.2 definition of relative luminance
- Brand teal channels linearisedR 0.0242, G 0.3968, B 0.3278—from Brand teal and Linearise each channel c = value / 255
- Brand teal luminance0.2126 × 0.0242 + 0.7152 × 0.3968 + 0.0722 × 0.32780.3126from Brand teal channels linearised
- Brand teal on white(1.0 + 0.05) / (0.3126 + 0.05)2.90:1from Brand teal luminance and White background · fails 4.5:1 and even 3:1
- Deep teal luminance0.2126 × 0.0086 + 0.7152 × 0.1946 + 0.0722 × 0.15900.1525from Deep teal and Linearise each channel c = value / 255
- Deep teal on white(1.0 + 0.05) / (0.1525 + 0.05)5.19:1from Deep teal luminance and White background · passes body text
- The green channel carries 72% of the weight, so a bright teal reads as light even though it looks strongly coloured.
- White text on a brand-teal button is the same pair the other way round: 2.90:1. Railmoor's buttons use deep teal.
- Ratios are compared unrounded; 4.499:1 is a fail.
Seat buttons and the 24 px circle test
- width 4, Empty space
- 1A, width 20, Seat button (target)
- width 6, Empty space
- 2A, width 20, Seat button (target)
- width 6, Empty space
- 3A, width 20, Seat button (target)
- width 4, Empty space
- centre 14, key at 14
- centre 40, key at 40
- centre 66, key at 66
- 1A circle, from 2 to 26
- 2A circle, from 28 to 52
- 3A circle, from 54 to 78
- Seat button (target)
- Empty space
- Circle overlaps
As it starts. 3 steps follow.
Zoom, text size and colour
In practice.
Railmoor's results page, seat hold and palette, and how you would put all of this into a front-end system design answer in a few sentences, not a lecture.
Timetable results, and what each change sounds like
The list is aria-busy while the first search runs, and skeleton rows hold the space. The status element is already in the page but empty, so nothing is said yet; a spinner alone would say nothing either.
Loading. List “Ashcombe Junction → Kestrel Bay, Thu 8 Oct”: 0 items. Search: “Ashcombe Junction → Kestrel Bay”. Sort: Earliest first. Footer: none. 4 skeleton items while loading. Note on count: role="status", empty on first paint
- ResultStatus (role=status)2ResultStatus
- TimetableResults1TimetableResults
- DisruptionBanner (polite)3DisruptionBanner
- ToastRegion4ToastRegion
Railmoor's palette against its backgrounds
Data
| Colour pair | ratio |
|---|---|
| Ink #1F2A37 on white | 14.54 |
| Ink on amber #F2A900 | 7.23 |
| Slate #5F6B78 on white | 5.44 |
| Deep teal #177A6F on white | 5.19 |
| Red #D64545 on white | 4.38 |
| Seat outline #7B8794 on white | 3.66 |
| Muted #8A94A0 on white | 3.08 |
| Brand teal #2BA99B on white | 2.9 |
| Amber #F2A900 on white | 2.01 |
| Light outline #C9D1D9 on white | 1.54 |
- 4.5:1 text: Contrast ratio = 4.5
- 3:1 large text and UI: Contrast ratio = 3
What each pair may be used for
| Pair | Ratio | Verdict | Railmoor's use |
|---|---|---|---|
| #1F2A37 on #FFFFFF | 14.54:1 | Pass everything | Body text, times, fares |
| #1F2A37 on #F2A900 | 7.23:1 | Pass everything | Text on the amber delay badge |
| #5F6B78 on #FFFFFF | 5.44:1 | Pass everything | Secondary text (duration, changes) by default; ink replaces it under prefers-contrast: more |
| #177A6F on #FFFFFF | 5.19:1 | Pass everything | Links and primary buttons (white text on deep teal is the same 5.19:1) |
| #D64545 on #FFFFFF | 4.38:1 | Large text and UI only | "Cancelled" set at 24 px bold is fine; the 14 px error line under a field is not, so it uses ink with a red icon |
| #7B8794 on #FFFFFF | 3.66:1 | UI components only | Seat outlines and checkbox borders (1.4.11) |
| #8A94A0 on #FFFFFF | 3.08:1 | Large text and UI only | The old muted text colour; slate #5F6B78 replaced it for anything smaller than large text |
| #2BA99B on #FFFFFF | 2.90:1 | Fail | Decoration only: the logo stripe and illustrations. Logotypes are exempt; links are not |
| #F2A900 on #FFFFFF | 2.01:1 | Fail | Never text; the badge is a fill behind ink text, with a border in forced colours |
| #C9D1D9 on #FFFFFF | 1.54:1 | Fail | Dividers only; a disabled seat may use it, since inactive controls are exempt |
The seat hold, and when it speaks
States of5SeatHoldTimer
A 10-minute hold starts when the traveller picks seats. The visible countdown is role=timer, which is aria-live off, so the ticking is never read. The page speaks at five minutes (polite) and two minutes (assertive, with an Extend button), which meets the Extend option of 2.2.1: a warning, well over 20 seconds to act, and up to ten extensions.
| From → To | Event | Guard | Action | Actor |
|---|---|---|---|---|
| Holding (10:00) → Five minutes left | 5 min remain | write to status region | ||
| Five minutes left → Two minutes left | 2 min remain | write to alert region | ||
| Two minutes left → Holding (10:00) | Extend pressed | extensions < 10 | +10 min | traveller |
| Holding (10:00) → Paid and booked | payment succeeds | |||
| Five minutes left → Paid and booked | payment succeeds | |||
| Two minutes left → Paid and booked | payment succeeds | |||
| Two minutes left → Hold released | timer reaches 0 | alert: "Your seats were released" |
- Holding (10:00)start
- countdown visible, silent
- Five minutes left
- polite: "5 minutes left on your seat hold"
- Two minutes left
- assertive, Extend button shown
- Paid and bookedend
- Hold releasederror
- seats return to sale; details kept
Ticket details under a seat hold
The countdown is visible and silent (role=timer). The banner explains the hold once, when the page loads.
Hold running. Banner (info): “Seats 4A and 4B in coach C are held for you until 14:42 (10 minutes).”. Form “Ticket details · coach C, 4A and 4B”. Fields: Email for your tickets = “amara.okonjo@example.com”; Mobile for delay alerts = “07700 900461”; Railcard number (optional) (empty) (focused). Button: “Continue to payment”.
- SeatHoldTimer messages5SeatHoldTimer
- ToastRegion4ToastRegion
Saying it in a front-end system design interview
Where the system flows use this
Trade-offs.
Most accessibility bugs in this area are not missing features but misjudged volume: too much speech, too urgent, too brief, or too much motion. And the brand palette is usually where contrast fails first.
- Pro:Never cuts off what the traveller is listening to
- Pro:Queued updates survive (an assertive one may clear them)
- Pro:The same text is there to read later
- Con:May be spoken a few seconds after the data arrives
- Con:A traveller who never pauses may hear it late
Interrupts, often mid-sentence on the control the traveller is using; Trains the traveller to ignore alerts when they fire for non-urgent news; Reserved at Railmoor for losing seats and payment failures
Throws the traveller out of whatever they were doing; Breaks the keyboard flow that Keyboard access and focus management protects
- Pro:Works in the kiosks' two-year-old browsers
- Pro:Regions double as visible text, which sighted users need too
- Pro:One announce() helper hides which path is used
- Con:Regions must be placed and kept empty by convention
- Con:Two code paths to test until the kiosks update
Messages with no visible home need a visually hidden element
Baseline newly available in 2026, so older browsers get silence; Nothing visible, so it cannot be the only place a status appears
Too much or too little
What goes wrong, and how you notice
| Failure | Impact | Detection | Mitigation | Meanwhile |
|---|---|---|---|---|
| Count written on every render2ResultStatus | Travellers hear stale numbers queued ahead of the right one | Component test that counts writes to the region per filter change | Settle timer (500 ms) and only write when the text differs in meaning | The final count is still spoken, late |
| Banner colour carries the meaning3DisruptionBanner | Travellers who cannot tell amber from teal, or who use forced colours, miss the delay | Review in grayscale and in a Windows contrast theme; axe flags contrast but not meaning | Words and an icon on every status, and a border that survives forced colours | The banner text still says it |
| Toast dismisses on a timer while holding the only Undo4ToastRegion | Keyboard and screen reader travellers cannot reach Undo in time (2.2.1) | Tab to the toast from where the action happened and time it | Pause on hover and focus, keep action toasts until dismissed, offer Undo in My journeys too | The saved journey can still be removed from My journeys |
| Motion ignores prefers-reduced-motion1TimetableResults | Sliding rows and the gliding train can cause dizziness or nausea | Run the visual tests once with reduced motion emulated (browser dev tools can force it) | Every animation declared behind the media query; JS animations read matchMedia | Content is still correct; it just moves |
| Countdown given aria-live5SeatHoldTimer | The reader recites the clock every second and nothing else can be heard | Manual screen reader pass on the booking flow | role=timer (live off) and threshold announcements | none; the page is unusable by ear until fixed |
- Pro:The brand still reads as teal in the logo and illustrations
- Pro:Deep teal passes at 5.19:1 for links and buttons
- Pro:One token change, checked in CI for every scheme
- Con:Marketing has to accept two teals
2.90:1 fails text and even 3:1 for components; A legal and usability risk on every page
A rebrand in disguise; the decision belongs to the design system, not a single page