Accessibility and compatibilityAnnouncing changes and respecting preferences

100%

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.

Intermediate35 minUpdated 2 Oct 2026

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.AreaRequirementTarget / measure
01Status 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
02Text 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
03Non-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
04Use 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
05Reflow 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
06Target 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
07Motion 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
Was this section helpful?

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.

1

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

MarkupImplicit aria-liveImplicit aria-atomicWhat it is forAt Railmoor
role="status" (and <output>)politetrueAdvisory results and progress"8 trains found", "Saved to My journeys"
role="alert"assertivetrueUrgent, time-sensitive messages"2 minutes left on your seat hold"
role="log"politefalse (default)A growing history where new entries are appendedNot used here; it is the chat pattern
role="timer"offfalse (default)A clock or countdown that would be noise if read every secondThe visible 09:58 countdown on the seat hold
role="marquee"offfalse (default)Scrolling non-essential text such as a tickerThe departure board strip on the home page
aria-live="polite" on any elementpolitefalse unless setA custom region with no special roleThe 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.

  1. 0 s · Traveller · Space on Direct only
  2. 0.1–1.6 s · Screen reader speech · "Direct only, checkbox, checked"
  3. 0.2–0.9 s · DOM (rows and count) · fetch and render 8 rows
  4. 0.9 s · DOM (rows and count) → Live region (role=status), arriving 1.4 s: settle 500 ms
  5. 1.4 s · Live region (role=status) · text written
  6. 1.4–1.7 s · Screen reader speech · window: waits for the reader to go idle
  7. 1.4 s · Live region (role=status) → Screen reader speech, arriving 1.7 s: polite
  8. 1.7–2.7 s · Screen reader speech · "8 direct trains found" (ok)
Illustrative timings; real speech rate depends on the reader and the traveller's settings. The traveller presses Space at 0 s, the reader starts naming the checkbox, results render at 0.9 s and the count is written 500 ms later, once nothing else has changed. Switch tabs to compare an assertive region, a region inserted too late, and counts written on every intermediate render.

From a filter tap to one announcement

From a filter tap to one announcement, as an ordered list of steps:
From a filter tap to one announcement7 steps between Traveller + screen reader, TimetableResults, ResultStatus, Accessibility API. The steps are listed as text after the diagram.Accessibility APIResultStatusTimetableResultsTraveller + screen readeraria-busy="true" on the list while it fetchesno further change for 500 mstoggle "Direct only"1render 8 rows, restart the 500 ms settle timer2textContent = "8 direct trains found"3text changed in a polite, atomic region4spoken once the reader is idle5
  1. Traveller + screen reader → TimetableResults: toggle "Direct only"
  2. Note over TimetableResults: aria-busy="true" on the list while it fetches
  3. TimetableResults → TimetableResults: render 8 rows, restart the 500 ms settle timer
  4. Note over ResultStatus: no further change for 500 ms
  5. TimetableResults → ResultStatus: textContent = "8 direct trains found"
  6. ResultStatus → Accessibility API: text changed in a polite, atomic region
  7. 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.

2

Media features, the user's side of the conversation

What the user can tell you, and what Railmoor does with it

Media featureValuesSet byRailmoor's response
prefers-reduced-motionno-preference | reduceReduce 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-contrastno-preference | more | less | customIncrease contrast (macOS, iOS), contrast settings on WindowsSecondary text goes from slate #5F6B78 to ink #1F2A37; seat outlines go from grey #7B8794 to ink; dashed dividers become solid.
forced-colorsnone | activeWindows contrast themesLet 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-schemelight | darkOS or browser appearanceSwaps the colour tokens; each token pair is contrast-checked in both schemes (see Design tokens and theming).
(no media feature) zoom and text sizebrowser zoom, default font sizeBrowser settings, OS text scalingSizes 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.

3

The numbers under every screen

Contrast of Railmoor's brand teal and its darker sibling on white

Assumptions
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
Working
  1. Brand teal channels linearisedR 0.0242, G 0.3968, B 0.3278—from Brand teal and Linearise each channel c = value / 255
  2. Brand teal luminance0.2126 × 0.0242 + 0.7152 × 0.3968 + 0.0722 × 0.32780.3126from Brand teal channels linearised
  3. 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
  4. Deep teal luminance0.2126 × 0.0086 + 0.7152 × 0.1946 + 0.0722 × 0.15900.1525from Deep teal and Linearise each channel c = value / 255
  5. Deep teal on white(1.0 + 0.05) / (0.1525 + 0.05)5.19:1from Deep teal luminance and White background · passes body text
What it means
  • 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

20 px seats, 6 px gaps
  1. width 4, Empty space
  2. 1A, width 20, Seat button (target)
  3. width 6, Empty space
  4. 2A, width 20, Seat button (target)
  5. width 6, Empty space
  6. 3A, width 20, Seat button (target)
  7. 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
Start

As it starts. 3 steps follow.

Three seat buttons down the window side of coach C (1A, 2A, 3A), measured in CSS pixels. 2.5.8 asks for 24 × 24 px targets, or for undersized targets spaced so that a 24 px circle centred on each touches no other target and no other circle. Step through the three layouts; seats across a row are spaced the same way.

Zoom, text size and colour

Reflow at 320 CSS px (1.4.10)
A 1280 px laptop window at 400% zoom leaves 320 CSS px of layout. Railmoor's six-column train row (depart, arrive, duration, changes, platform, fare) restacks into a two-line card below 480 px, so the page scrolls in one direction only. The seat map is a two-dimensional layout that may pan, but every seat's label and fare still reflow inside it.
Text at 200% (1.4.4)
Font sizes in rem follow the traveller's default text size; fixed pixel heights on buttons and banners are what clip text. Railmoor's delay banner has a min-height, never a height, and its text wraps.
Text spacing (1.4.12)
Travellers with dyslexia often apply their own spacing with a user style sheet: line height 1.5, paragraph spacing 2×, letter spacing 0.12 em, word spacing 0.16 em. Nothing may clip or overlap when they do, which is the same discipline: no fixed heights around text.
Never colour alone (1.4.1)
Amber for delayed, red for cancelled, teal for on time is a helpful cue and a useless signal for a traveller who cannot tell those apart, or who sees them all as system colours in forced colours mode. Every status has words (Delayed 12 min, Cancelled) and an icon with its own accessible name.
Was this section helpful?

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

  1. ResultStatus (role=status)2ResultStatus
  2. TimetableResults1TimetableResults
  3. DisruptionBanner (polite)3DisruptionBanner
  4. ToastRegion4ToastRegion

Railmoor's palette against its backgrounds

Railmoor's palette against its backgroundsThree of Railmoor's ten colour pairs, the brand teal among them at 2.90:1, fail even the 3:1 bar, and three more are fit only for large text and UI.02468101214Ink #1F2A37 on whiteInk on amber #F2A900Slate #5F6B78 on whiteDeep teal #177A6F on whiteRed #D64545 on whiteSeat outline #7B8794 on whiteMuted #8A94A0 on whiteBrand teal #2BA99B on whiteAmber #F2A900 on whiteLight outline #C9D1D9 on white14.57.235.445.194.383.663.082.92.011.544.5:1 text3:1 large text and UIColour pairContrast ratioRailmoor's palette against its backgroundsThree of Railmoor's ten colour pairs, the brand teal among them at 2.90:1, fail even the 3:1 bar, and three more are fit only for large text and UI.02468101214Ink #1F2A37 on wh…Ink #1F2A37 on whiteInk on amber #F2A…Ink on amber #F2A900Slate #5F6B78 on …Slate #5F6B78 on whiteDeep teal #177A6F…Deep teal #177A6F on whiteRed #D64545 on wh…Red #D64545 on whiteSeat outline #7B8…Seat outline #7B8794 on whiteMuted #8A94A0 on …Muted #8A94A0 on whiteBrand teal #2BA99…Brand teal #2BA99B on whiteAmber #F2A900 on …Amber #F2A900 on whiteLight outline #C9…Light outline #C9D1D9 on white14.57.235.445.194.383.663.082.92.011.544.5:1 text3:1 large text and UIColour pairContrast ratio
Ratios computed with the WCAG 2.2 formula. The 4.5:1 line is for body text, the 3:1 line for large text and for UI components and graphics (1.4.11). Pairs are foreground on background.
Data
Colour pairratio
Ink #1F2A37 on white14.54
Ink on amber #F2A9007.23
Slate #5F6B78 on white5.44
Deep teal #177A6F on white5.19
Red #D64545 on white4.38
Seat outline #7B8794 on white3.66
Muted #8A94A0 on white3.08
Brand teal #2BA99B on white2.9
Amber #F2A900 on white2.01
Light outline #C9D1D9 on white1.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

PairRatioVerdictRailmoor's use
#1F2A37 on #FFFFFF14.54:1Pass everythingBody text, times, fares
#1F2A37 on #F2A9007.23:1Pass everythingText on the amber delay badge
#5F6B78 on #FFFFFF5.44:1Pass everythingSecondary text (duration, changes) by default; ink replaces it under prefers-contrast: more
#177A6F on #FFFFFF5.19:1Pass everythingLinks and primary buttons (white text on deep teal is the same 5.19:1)
#D64545 on #FFFFFF4.38:1Large 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 #FFFFFF3.66:1UI components onlySeat outlines and checkbox borders (1.4.11)
#8A94A0 on #FFFFFF3.08:1Large text and UI onlyThe old muted text colour; slate #5F6B78 replaced it for anything smaller than large text
#2BA99B on #FFFFFF2.90:1FailDecoration only: the logo stripe and illustrations. Logotypes are exempt; links are not
#F2A900 on #FFFFFF2.01:1FailNever text; the badge is a fill behind ink text, with a border in forced colours
#C9D1D9 on #FFFFFF1.54:1FailDividers only; a disabled seat may use it, since inactive controls are exempt

The seat hold, and when it speaks

States of5SeatHoldTimer

The seat hold, and when it speaks. 5 states, 7 transitions. The table below lists them.
The seat hold, and when it speaksThe states of SeatHoldTimer. 5 states, 7 transitions. The table below lists them.

5 min remain / write to status region

2 min remain / write to alert region

Extend pressed [extensions ‹ 10] / +10 min

payment succeeds

payment succeeds

payment succeeds

timer reaches 0 / alert: ”Your seats were released”

Holding (10:00)

Five minutes left

Two minutes left

Paid and booked

Hold released

4 steps. A traveller filling in a railcard number with a switch device needs more time.

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.

Transitions of The seat hold, and when it speaks
From → ToEventGuardActionActor
Holding (10:00) → Five minutes left5 min remainwrite to status region
Five minutes left → Two minutes left2 min remainwrite to alert region
Two minutes left → Holding (10:00)Extend pressedextensions < 10+10 mintraveller
Holding (10:00) → Paid and bookedpayment succeeds
Five minutes left → Paid and bookedpayment succeeds
Two minutes left → Paid and bookedpayment succeeds
Two minutes left → Hold releasedtimer reaches 0alert: "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”.

  1. SeatHoldTimer messages5SeatHoldTimer
  2. ToastRegion4ToastRegion

Saying it in a front-end system design interview

Q1
RequirementsThe interviewer asks for your non-functional requirements. What do you say about accessibility in thirty seconds?
WCAG 2.2 AA as the bar. Concretely for this UI: every async result or error is a status message in a live region that exists from first paint; urgency is polite by default and assertive only for time-critical errors; contrast 4.5:1 for text and 3:1 for controls; works at 320 px and 200% text; 24 px targets; motion honours prefers-reduced-motion. Then move on, and come back to it in the component deep dive.
Q2
Deep diveYou are designing search results with filters (or an autocomplete). Where do announcements live in your architecture?
One status region per results view, owned by the results component and written by a small announce() helper that debounces and de-duplicates. The combobox's own option announcements come from aria-activedescendant and are covered with keyboard and focus. For a chat thread, an offscreen role=log gets one short line per incoming message while the scrolling rows stay silent; a feed says "4 new posts" once behind a pill instead of reading posts.
Q3
Trade-offThe interviewer pushes back that a toast is simpler than an inline message. How do you answer?
A toast is fine for confirmations whose result is visible elsewhere (the saved journey is in My journeys). It is wrong for errors that need action or for anything with an Undo the traveller must reach: those stay until dismissed or live inline next to the thing they are about. Either way the toast container is a polite region created on page load.
Q4
TestingHow would you know announcements actually work?
Automated checks (axe in CI) catch contrast, missing names and invalid ARIA, but not whether a region speaks at the right moment. Add a component test that asserts the region's text after results settle, and a manual pass with at least two screen reader and browser pairs (for example NVDA with Firefox or Chrome, VoiceOver with Safari) on the flows that change content in place.

Where the system flows use this

Chat thread (WhatsApp-like web client)
Incoming messages go to an offscreen role=log with one line per message; the scrolling rows sit outside any live region. See whatsapp/chat-thread.
News feed (Facebook-like web app)
New posts wait behind a pill, and a polite region says "2 new posts available" once, instead of inserting and reading posts above the reader. See facebook/news-feed.
One-time code login
The resend countdown is a timer that stays silent, and the "code sent" and "code expired" messages are status and alert. See instagram/phone-number-and-otp-login.
Virtualised lists
When rows are recycled, the count and position announcements need a region outside the recycled rows. See virtualization/anchoring-and-accessibility.
Was this section helpful?

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.

01
How urgent is the delay banner?
Chosen:Polite region, visible banner, words on the row
  • 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
Downside we accept:
  • Con:May be spoken a few seconds after the data arrives
  • Con:A traveller who never pauses may hear it late
Ruled out:Assertive region (role=alert)

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

Ruled out:Move focus to the banner

Throws the traveller out of whatever they were doing; Breaks the keyboard flow that Keyboard access and focus management protects

02
How Railmoor makes the page speak
Chosen:Live regions in the initial markup, ariaNotify where available
  • 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
Downside we accept:
  • Con:Regions must be placed and kept empty by convention
  • Con:Two code paths to test until the kiosks update
Ruled out:Live regions only

Messages with no visible home need a visually hidden element

Ruled out:ariaNotify only

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

Over-announcing
Every keystroke in the station field reads a new suggestion countDebounce, and let the combobox pattern speak its own options
Loading, loaded and the count all spoken for one filter tapSay only the outcome
Several regions on the page each saying their pieceOne region per view, one helper that queues
Assertive for good newsNothing that can wait should interrupt
Under-announcing
Region mounted with its text already insideSilent in most readers
A toast that vanishes in 3 s and holds the only UndoKeep it until dismissed, pause on hover and focus, or offer Undo elsewhere too
Delay shown only as an amber rowFails 1.4.1 and says nothing to a screen reader
Errors appended below the form with no live rolerole=alert on the summary, or move focus to it on submit

What goes wrong, and how you notice

FailureImpactDetectionMitigationMeanwhile
Count written on every render2ResultStatusTravellers hear stale numbers queued ahead of the right oneComponent test that counts writes to the region per filter changeSettle timer (500 ms) and only write when the text differs in meaningThe final count is still spoken, late
Banner colour carries the meaning3DisruptionBannerTravellers who cannot tell amber from teal, or who use forced colours, miss the delayReview in grayscale and in a Windows contrast theme; axe flags contrast but not meaningWords and an icon on every status, and a border that survives forced coloursThe banner text still says it
Toast dismisses on a timer while holding the only Undo4ToastRegionKeyboard and screen reader travellers cannot reach Undo in time (2.2.1)Tab to the toast from where the action happened and time itPause on hover and focus, keep action toasts until dismissed, offer Undo in My journeys tooThe saved journey can still be removed from My journeys
Motion ignores prefers-reduced-motion1TimetableResultsSliding rows and the gliding train can cause dizziness or nauseaRun the visual tests once with reduced motion emulated (browser dev tools can force it)Every animation declared behind the media query; JS animations read matchMediaContent is still correct; it just moves
Countdown given aria-live5SeatHoldTimerThe reader recites the clock every second and nothing else can be heardManual screen reader pass on the booking flowrole=timer (live off) and threshold announcementsnone; the page is unusable by ear until fixed
03
The brand teal fails contrast. What changes?
Chosen:Keep teal for decoration, add a darker text shade to the tokens
  • 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
Downside we accept:
  • Con:Marketing has to accept two teals
Ruled out:Use the brand teal for links and buttons anyway

2.90:1 fails text and even 3:1 for components; A legal and usability risk on every page

Ruled out:Darken the brand colour everywhere

A rebrand in disguise; the decision belongs to the design system, not a single page

This topic
Live regions, their roles and urgency, and when to speak
prefers-reduced-motion, prefers-contrast, forced-colors
Contrast, reflow, text size, target size and colour as WCAG 2.2 AA numbers
Timing of toasts and countdowns
Elsewhere
Roles, names and the accessibility tree itselfSemantics and the accessibility tree
Where focus goes after a dialog closes or a row is deletedKeyboard access and focus management
Building the token system and dark modeDesign tokens and theming
Making animations cheapCompositor animations
Kiosk browsers, feature detection and fallbacksBrowser and device support
Was this section helpful?
Next in Core
Browser and device support
Read next