Accessibility is not a separate pass you bolt on at the end, it is part of what makes an interface feel correct to everyone. Most of these rules cost almost nothing at build time and quietly break the experience for real people when skipped. Build them in as you go.
Design for Three Kinds of Constraint
Do not picture accessibility as one permanent condition. Test the interface against three realities:
- Permanent: long-term visual, hearing, motor, speech, cognitive, or neurological differences.
- Temporary: a broken arm, eye surgery, migraine, injury, illness, or short-lived loss of precision.
- Situational: bright sunlight, a noisy room, one occupied hand, fatigue, urgency, distraction, or an unreliable connection.
Designing for the temporary and situational cases prevents the same failures that exclude people permanently.
The Three-Question Accessibility Gate
For every key action, ask:
- Visible without searching: can the user find it without opening an unrelated menu, guessing a gesture, dismissing an obstruction, or scrolling past the decision point?
- Operable without precision: is the target large, separated from risky neighbors, reachable by keyboard, and usable with reduced dexterity or one hand?
- Understandable without guessing: does it look interactive, name its outcome, expose its state, and avoid relying on an unexplained icon or convention?
An action that is visible, self-explanatory, and easy to activate is a stronger accessible default than a visually clever control that needs discovery.
Color Contrast
Text and interface elements must hold up against their background. Use the WCAG AA thresholds as the floor:
- Normal text: at least 4.5:1 against its background.
- Large text (roughly 24px, or 18.66px when bold): at least 3:1.
- Non-text UI (icon glyphs, input borders, focus rings, control outlines, chart strokes): at least 3:1, so the boundary of a control is still perceivable.
Never carry meaning with color alone. An input that signals an error only by turning its border red is invisible to colorblind users. Pair the color with a second signal: an icon, a text message, a shape change.
Disabled states are a common trap. Dimming a control with raw opacity makes its contrast depend on whatever sits behind it, so the same disabled button can pass on one surface and fail on another. Use a dedicated muted token instead of an opacity reduction, so the result is predictable.
/* Avoid: contrast depends on the backdrop */
.button:disabled { opacity: 0.4; }
/* Prefer: a predictable muted token */
.button:disabled {
color: var(--muted-foreground);
background: var(--muted);
}
Focus-Visible Rings
Keyboard users navigate by focus. A visible focus indicator is how they know where they are, so never strip it. Removing the outline with outline: none and leaving nothing in its place is an accessibility failure.
If the default ring clashes with your design, replace it, do not delete it. Use :focus-visible so the ring shows for keyboard interaction but not on every mouse click, and give it an offset so it sits clear of the element edge.
.button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
The ring itself counts as non-text UI, so it needs at least 3:1 contrast against both the element and the surrounding surface.
Keyboard Navigation and Focus Order
Everything reachable by mouse must be reachable by keyboard. The fastest way to guarantee this is to use native interactive elements: a real <button> is focusable, pressable with Enter and Space, and announced correctly for free, while a <div role="button"> needs hand-wired focus and key handling that is easy to get wrong.
<!-- Avoid -->
<div role="button" onclick="...">Save</div>
<!-- Prefer -->
<button type="button" onclick="...">Save</button>
Tab order should match the order things appear on screen. Screen readers and keyboard focus follow DOM order, not visual order, so reordering elements purely in CSS (for example with order) desyncs the two. Keep the DOM in the same sequence as the layout.
Only visible, active elements should be in the tab sequence. Hide offscreen panels from focus with visibility: hidden or the inert attribute so users do not tab into things they cannot see.
Two more keyboard essentials:
- Skip link: on pages with a long navigation, add a visually hidden "Skip to content" link that becomes visible on focus, so keyboard users are not forced through every nav item on every page.
- Scroll into view: when focus lands on an element that sits outside the viewport, scroll it into view so the user can see what is focused.
function handleFocus(event) {
event.target.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
Focus Management in Dialogs
When a modal or dialog opens, move focus into it, either to its first interactive control or to the dialog container. While it is open, trap focus inside so Tab cannot wander to the page behind it, which would expose background content to screen reader users. When it closes, return focus to the element that opened it, so the user picks up where they left off.
Tap Target Sizing
On touch devices, fingers are far less precise than a cursor. The visible graphic can be smaller; the touchable region is what has to clear the minimum.
| Context | Hit area |
|---|---|
| Absolute floor, any input, any device | 24px by 24px |
| Touch | 44px by 44px |
| Desktop pointer, where the layout allows | 40px by 40px |
/* Small visual, full-size hit area */
.icon-button {
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
When the visible element genuinely has to stay tiny, expand the hit area with a pseudo-element rather than enlarging the graphic:
.icon-button {
position: relative;
width: 24px;
height: 24px;
}
.icon-button::before {
content: "";
position: absolute;
inset: -10px; /* grows the touchable region to ~44px */
}
An extended hit area must never overlap its neighbour. The pseudo-element above is invisible, so two adjacent icon buttons each growing by inset: -10px will silently trade taps in the strip where their regions cross, and whichever sits later in the DOM wins. The user taps the left icon and the right one fires, with nothing on screen explaining why. Give adjacent controls at least the sum of their extensions as real spacing, and verify by hovering the boundary in DevTools, which paints the true region rather than the drawn one.
Spacing matters as much as size. Targets packed edge to edge cause mis-taps even when each one meets the minimum, so leave breathing room between adjacent controls. Also associate every <label> with its input (for / id) so the label text becomes part of the tap target and toggles the control when tapped.
Touch Devices
Touch devices fire a synthetic hover on tap, which makes hover-only behavior misfire. Two rules follow:
First, hover should enhance, never enable. If an action is only reachable on hover, touch users cannot reach it at all, so critical actions must always be visible, not hidden behind a hover state.
Second, gate genuinely hover-dependent visuals behind a media query so they only apply on devices with a fine, hovering pointer:
@media (hover: hover) and (pointer: fine) {
.card:hover {
transform: scale(1.02);
}
}
A couple of touch niceties worth setting: touch-action: manipulation on buttons, links, and inputs removes the double-tap zoom delay on controls, and touch-action: none lets custom pan or zoom surfaces own their gestures without native interference.
Findability and Predictability
Accessibility also fails when an action technically exists but gives no hint that it can be found.
- Expose key actions. Do not hide the only important action in an overflow menu, hover state, or undocumented gesture.
- Hint at overflow. A clipped next item, arrow, label, or other visual affordance should reveal that a horizontal list or hidden region continues.
- Make actions look like actions. An unlabeled image or icon should not behave like a button unless the affordance is unmistakable and an accessible name is present.
- Use a small set of patterns per view. Mixing horizontal scroll, vertical scroll, nested drawers, and one-off gestures increases the amount a user must learn and remember.
- Challenge expert assumptions. Teams immersed in a product underestimate what a first-time user must infer. Test labels, formats, and controls with someone who does not share the team's mental model.
Reduced Motion
Some users get motion sickness or vestibular symptoms from animation. Vestibular disorders are the most cited reason, and they are not the only one: dyslexia, ADHD, autism, epilepsy and post-concussion vision problems all give people reasons to want less movement.
Start from the fact most people get wrong. The operating-system setting does nothing to a web page on its own. macOS, Windows, iOS and Android all expose a reduce-motion toggle, and none of them stop a CSS transition, a keyframe animation or a requestAnimationFrame loop. The preference only reaches your page if your page asks for it. A user who has explicitly asked for less motion gets your animation at full strength until you write the query.
Gate Motion In, Not Out
prefers-reduced-motion has exactly two values, no-preference and reduce. A browser that does not understand the feature matches neither, which makes the direction of the query a safety decision rather than a style choice.
Write the motion inside a no-preference block. A browser that cannot evaluate the feature drops the block as invalid and the user gets a motion-free page by default. Write it the other way, with the motion unconditional and a reduce block removing it, and that same browser keeps every animation.
/* Fail-safe: motion is opt-in */
@media (prefers-reduced-motion: no-preference) {
.panel {
transition: opacity 0.2s ease, transform 0.2s ease;
}
}
Reduced does not mean none. Where you do keep something under the preference, keep the gentle opacity or color change that helps comprehension and drop the movement: no sliding, scaling, or position-based motion.
Use this form in every doc, component and review you touch, rather than reaching for a reduce block. Two cases are the deliberate exception, because there is no motion declaration to gate: a blanket duration reset aimed at CSS you did not write, and switching off a behaviour you do not own. Keep those in a reduce block and say in a line why the direction is intentional, so the next reader does not read it as an oversight.
A Fade Can Still Read as Motion
"Keep the fade, drop the movement" is a starting point, not a test. A row of elements fading in and out one after another animates nothing but opacity, and it reads as a light chasing along the row. It provokes the same responses as movement, because perception does not care which property you animated.
The test is perceptual. If it looks like something is moving, gate it, whatever the CSS says. Sequenced fades, rapid cross-dissolves and blur pulses all qualify.
Motion That Explains Needs a Static Equivalent
Reduced motion removes movement, never content. The sharper version of that rule: motion that carries meaning needs a replacement that carries the same meaning, not a frozen first frame.
An item flying from a product card to the cart icon says "this went there". Stopped on frame one it says nothing at all. Replace it with a count that increments, a brief highlight on the cart, or a status message, so the user learns the same fact by another route. Ask what the animation told the user, then deliver that.
Photosensitivity Has a Hard Threshold
Reduced motion is a preference. Flashing is a hazard, and it has a testable limit that applies to every user regardless of their settings.
WCAG 2.3.1 Three Flashes or Below Threshold (Level A) requires that content does not flash more than three times in any one second period, unless the flash stays under the general flash and red flash thresholds. Anything driven at frame rate is an order of magnitude past that limit.
The loops that hit it are ordinary ones: a value re-randomised inside a draw call, a hover state toggling as the pointer sits on a proximity boundary, a class flipped on every tick. See pointer interactions, canvas animation and canvas fields and grids for the specific loops and their fixes.
Read the Preference by Subscription, Not by Sample
A one-shot .matches read is correct until the moment the user changes the setting, which they can do mid-session, often precisely because your page made them uncomfortable. Subscribe to the query and re-apply.
const motionOk = window.matchMedia("(prefers-reduced-motion: no-preference)");
function applyMotionPreference() {
document.body.classList.toggle("motion-ok", motionOk.matches);
}
applyMotionPreference();
motionOk.addEventListener("change", applyMotionPreference);
In React, branch the motion values rather than the whole component, using a hook that wraps the same listener and returns a live boolean. motion ships a useReducedMotion hook that does this if you already depend on it.
const reduceMotion = useReducedMotion();
const offset = reduceMotion ? 0 : "-100%";
Treat the standard and reduced behaviors as two product variants and verify both. The standard variant may use movement, scale, or choreography. The reduced variant must preserve the same information and final state with movement removed or minimized, usually through an instant update or a short opacity or color transition. Do not branch away the whole component, skip the state change, or leave content hidden because its reveal was disabled.
For Motion-based apps, set <MotionConfig reducedMotion="user"> near the app root as a safety net, then keep local branches for behavior that needs a different reduced variant. CSS transitions, canvas loops, video, and smooth scrolling are outside that component-level safety net and still need their own gates.
Enable smooth scrolling only when the user has not requested reduced motion:
html {
scroll-behavior: auto;
}
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
The same rule applies to autoplaying and looping media. Under reduced motion, pause video and canvas loops, disable CSS animation or use animation-play-state: paused only when the paused frame is fully visible, and replace animated images that cannot be paused with a representative static frame. Keep visible controls so the user can choose to play motion. Re-test after changing the emulated preference: content must remain visible, controls must still work, and completion callbacks must still run.
Testing It
Emulate the preference in developer tools rather than toggling your system settings for every check. In Chromium browsers it is in the Rendering pane, and in the command menu under "reduce".
The trap: the emulation is only active while developer tools are open. Close the panel and it is silently cancelled, so a page you just confirmed was motion-free is animating again on the next reload. Reopen the tools and re-enable it, and do not read a closed-tools reload as a passing test.
Support for emulating the preference varies between engines. Some expose a toggle, some need a configuration flag, and some have no emulation at all, so check what your browser offers instead of assuming the toggle exists. Confirm the finished work once against the real operating-system setting, since that is the path your users are on.
Give the User an Override When Motion Is the Point
The system setting is global and binary. A user who wants your motion back has to disable the preference for every site they visit, which is not a reasonable thing to ask.
Where motion is core to the experience, offer an in-page control that overrules the preference. Default to the reduced experience, tell the user what has been turned off, and let them opt back in for your site only. Where motion is incidental, a hover, a fade, a sheet sliding up, do not interrupt anyone. Let the query decide silently.
Zero Duration Kills Transition Events
The familiar reduced-motion reset uses 0.01ms rather than 0 for durations, and that value is not a superstition you can drop. With no transition duration and no delay there is no transition at all, so none of the transition events fire, and any handler waiting on transitionend never runs. The tiny non-zero value keeps the event firing while the motion stays imperceptible.
The failure is a stall, not a cosmetic glitch. A panel that unmounts itself on transitionend stays in the DOM, a queue that advances on the event stops advancing, and the user is left holding an element nothing will ever clear.
Animations need the iteration count as well as the duration. Per the CSS Animations specification an animation-duration of 0s still fires the start and end events, so the duration alone is not what breaks them. An infinite animation-iteration-count is, because the animation never finishes and animationend never arrives.
Prefer removing the transition entirely and doing the cleanup synchronously, which is simpler and has no timing to get wrong. Keep 0.01ms where existing code genuinely waits on transitionend or animationend to unmount, reveal or advance something.
@media (prefers-reduced-motion: reduce) {
/* Only where a handler depends on the completion event */
.panel {
transition-duration: 0.01ms;
animation-duration: 0.01ms;
animation-iteration-count: 1;
}
}
Semantic HTML and ARIA
Reach for the native element before reaching for ARIA. Semantic HTML carries built-in roles, keyboard behavior, and screen-reader semantics that you would otherwise have to recreate by hand. ARIA fills the gaps the platform does not cover, it is not a replacement for using the right element.
The most common gap is an icon-only control. A button whose only content is an icon has no accessible name, so give it one with aria-label that describes the action, not the element type:
<button aria-label="Close dialog">
<CloseIcon />
</button>
aria-label="Search" (the action) beats aria-label="icon" (the element type). Decorative illustrations built in code should also be labelled, or marked decorative, so assistive tech describes or skips them correctly:
<div role="img" aria-label="Abstract geometric pattern" />
Screen-Reader-Only Text
Sometimes the screen reader needs context the visual design does not, for example a label for a control that is clear from layout but not from markup. Provide it with text that is hidden from sight but still read aloud. Do not use display: none or visibility: hidden for this, both remove the text from the accessibility tree as well. Use the visually-hidden pattern:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
<button>
<TrashIcon />
<span class="sr-only">Delete item</span>
</button>
The skip link above is the same idea inverted: hidden until focused, then visible, so it serves keyboard users without cluttering the visual layout.
Charts and Data Graphics
Leaving a chart's text exposed to a screen reader produces a recital of numbers with no shape to it: every axis tick, every series name, every data label, read in DOM order. A sighted reader gets the point in one glance. The line climbs all year. One bar dwarfs the rest. Two series cross in March.
Give the screen reader that point instead. How you deliver it depends on whether the chart is only looked at or also operated, and the two cases need different markup.
A Presentational Chart Can Be Hidden Whole
If the rendered output is inert, no tooltip triggers, no legend toggles, no links, no tabindex, nothing focusable anywhere inside it, then hide the graphic from assistive technology with aria-hidden and state the takeaway in visually hidden text beside it.
<figure>
<div aria-hidden="true">
<RevenueChart data={data} />
</div>
<figcaption className="sr-only">
Revenue grew in every quarter of 2025, with the largest jump between Q2 and Q3.
</figcaption>
</figure>
Write the sentence a colleague would say out loud about the chart, not a description of the chart's construction. "A line chart with four points" is the shape; "revenue grew every quarter" is the information.
Where the exact numbers matter as well as the trend, expose them too: a real <table> next to the chart, or a link to one. Hiding the graphic is only safe once everything it carried is reachable another way.
An Interactive Chart Must Never Be Blanket-Hidden
The wrapper above becomes a defect the moment anything inside the chart can take focus. aria-hidden is inherited by every descendant, so one attribute on the container strips the whole subtree from the accessibility tree. It does not remove anything from the tab order. MDN states the rule plainly: aria-hidden="true" must not be used on an element that can receive focus, and because the attribute is inherited, it must not be put on the ancestor of a focusable element either.
Break that rule and a keyboard user still tabs into the chart, but lands on a control the screen reader cannot describe, because as far as it is concerned nothing is there. That is an ARIA conformance failure, and it is worse than the recital of numbers you were trying to avoid.
So an interactive chart carries its own names instead:
- Do not put
aria-hiddenon any element that contains a focusable descendant. Scope it to the genuinely decorative parts, the gridlines, the axis ticks, the fill gradients, none of which take focus - Give every focusable part a real accessible name that says what it does and what it refers to, not what it is drawn as:
aria-label="Revenue, Q3 2025, 1.2 million"on a data point trigger,aria-pressedplus the series name on a legend toggle - Use real
<button>elements for those parts, so they are focusable, operable by Enter and Space, and announced without hand-wired key handling - Keep the summary. A visually hidden takeaway is still the fastest route to the point, and it sits alongside the interactive parts rather than replacing them
If you cannot name every focusable part, the honest fix is to make the graphic presentational, remove the focusable elements, and move the interaction somewhere it can be labelled. Do not reach for aria-hidden to paper over it.
A Disabled Control Cannot Explain Itself
A disabled control is removed from the tab order and stops firing pointer events. So the tooltip explaining why it is disabled never opens for a keyboard user, never opens on touch, and on a mouse only opens if the tooltip listens on a wrapper rather than the control. The pattern fails for most of the people who need it most: the ones who cannot see that the button is dim.
Two fixes, in order of preference.
Put the reason in visible text. If the user has to know why they cannot proceed, that is content, not a hover affordance. A line beside the control reaches everyone and needs no interaction.
<button type="submit" disabled={!hasSeats}>Invite teammate</button>
{!hasSeats && <p id="seat-limit">Your plan has no seats left. Upgrade to add people.</p>}
Or use aria-disabled to keep the control focusable. aria-disabled="true" announces the control as unavailable while leaving it in the tab order, so its tooltip and its description are still reachable. The trade is that the control still works, so you must block the action yourself.
Block it at the form's submit handler, not the button's click. A form submits from more than one place: pressing Enter in any text field submits it too, without the button ever being clicked. A guard sitting in onClick misses that path and lets through the exact action the interface presented as unavailable. onSubmit is the boundary every route passes through.
<form
onSubmit={(e) => {
e.preventDefault();
if (!hasSeats) return; // covers Enter in a field, not just the button
invite();
}}
>
<input name="email" type="email" />
<button
type="submit"
aria-disabled={!hasSeats}
aria-describedby={!hasSeats ? "seat-limit" : undefined}
>
Invite teammate
</button>
</form>
For a control that is not a submit button, the same rule applies one level in: guard the function that performs the action, not the event handler that happens to be attached today.
Style [aria-disabled="true"] the same way you style :disabled, using a muted token rather than raw opacity, per Color Contrast above. Never pair the two attributes: disabled wins, and the element leaves the tab order regardless of what aria-disabled claims.
Prevent Slips Before Explaining Them
Many accessibility failures are slips: the user understands the goal, but the interface makes the wrong action too easy.
- Remove impossible choices or disable them with an explanation before submission.
- Prefer an undo path for reversible actions.
- Separate destructive controls from frequent actions and confirm only when the consequence is hard to reverse.
- Keep state and consequences visible at the decision point; an error message after the mistake is weaker than prevention.
Checklist
- Text contrast at least 4.5:1, large text and non-text UI at least 3:1
- Meaning never conveyed by color alone (pair with icon, text, or shape)
- Disabled states use a muted token, not raw opacity
- Visible
:focus-visiblering with offset, neveroutline: nonealone - Native interactive elements used before ARIA workarounds
- Tab order matches DOM order matches visual order
- Skip-to-content link on pages with long navigation
- Dialogs move focus in, trap it, and return it on close
- Tap targets at least 44px on touch, 40px on desktop, 24px absolute floor
- Extended hit areas never overlap a neighbour's
- A disabled control's reason sits in visible text, or the control uses
aria-disabledto stay focusable - Key actions are visible without searching, operable without precision, and understandable without guessing
- Overflow and hidden content have a discoverable visual hint
- Screen uses a small, predictable set of interaction patterns
- Permanent, temporary, and situational constraints were considered
- Impossible or destructive mistakes are prevented; reversible actions offer undo
- Labels associated with inputs via
for/id - Hover gated behind
@media (hover: hover) and (pointer: fine), critical actions never hover-only - Motion is gated inside
@media (prefers-reduced-motion: no-preference), not removed inside areduceblock - Sequenced fades and other opacity-only effects that read as motion are gated too
- Motion that carried meaning has a static equivalent carrying the same meaning, not a frozen first frame
- Standard and reduced-motion variants both reach the same visible content and completed state
- Smooth scrolling, autoplaying media, animated images, and loops stop or become user-controlled under reduced motion
- Nothing flashes more than three times per second (WCAG 2.3.1), including per-frame loops
- The preference is subscribed to with a
changelistener, not read once - Reduced motion tested with devtools emulation while the panel is open, and confirmed once against the real system setting
- Motion-heavy pages offer an in-page opt-in that overrules the global preference
- Icon-only controls have a descriptive
aria-label - Screen-reader-only text via the visually-hidden pattern, not
display: none - Presentational charts are hidden from assistive tech and paired with a visually hidden statement of the takeaway
- Where the exact figures matter as well as the trend, they are reachable as text, a real
<table>beside the chart or a link to one, not only as a summary sentence -
aria-hiddennever sits on a focusable element or on any ancestor of one, so interactive charts are not blanket-hidden - Every focusable part of an interactive chart carries its own accessible name
Live Regions
Content that changes without a page load, a toast, a validation message, a search-result count, a loading state, is invisible to a screen reader unless it lives in a live region. A live region tells assistive tech to announce updates to a part of the page the user is not focused on.
Before reaching for one, work down this list and stop at the first match:
- Focus already moves there (an opened dialog, the first invalid field on submit): the focus move is the announcement, nothing extra needed.
- Tied to a specific control (a field error, a character count): use
aria-describedbyon that control so it is read with the field. - Not urgent, not tied to a control (a toast, "Saved", a result count): a polite live region.
- Urgent, not tied to a control (a form-level failure, a session about to expire): an assertive region.
Two politeness levels, expressed most simply as roles:
role="status"equalsaria-live="polite"plusaria-atomic="true". It waits for a pause in speech before announcing. This is the default for almost everything: toasts, saved confirmations, counts, loading updates.role="alert"equalsaria-live="assertive"plusaria-atomic="true". It interrupts whatever the user was hearing. Reserve it for genuine errors and urgent problems.
Default to polite. Overusing assertive is the most common live-region mistake, it talks over the user mid-sentence for something that could have waited.
The reliability trick that trips people up: for repeated polite updates, render one stable, empty region up front and change its text, rather than inserting a fresh region together with its content each time. A region injected along with its message is announced inconsistently across screen readers.
// Region present from first render, text swapped in later
<div role="status" className="sr-only">
{statusMessage}
</div>
aria-atomic="true" (baked into both roles above) re-reads the whole region on every change, so keep each message short and self-contained. For loading, set aria-busy="true" on the updating region, announce "Loading" politely, then announce the outcome ("Loaded, 12 results"). Never move focus to a toast: announce it and leave focus where the user is working, give it a generous timeout or a dismiss button, and never put the only path to an action inside one that auto-dismisses.
Reflow and Zoom
Interfaces have to survive being scaled up, which is how low-vision users read them. Two WCAG thresholds are the floor:
- 200% text zoom (WCAG 1.4.4): all content and functionality must still work with text scaled to 200%. Fixed heights are what break here, use
min-heighton anything holding text and let containers grow. - Reflow at 320px (WCAG 1.4.10): at 400% zoom on a 1280px viewport (equivalent to a 320px-wide window) the page must work with vertical scrolling only. No horizontal scrolling of the page as a whole. The exceptions are genuinely two-dimensional content, tables, maps, code blocks, which may scroll sideways inside their own container.
/* A wide element scrolls within itself, the page does not scroll sideways */
.code-block {
overflow-x: auto;
max-width: 100%;
}
Never block zoom. Do not ship user-scalable=no or maximum-scale=1 in the viewport meta tag: both stop users pinch-zooming on the page. Safari ignores the cap, but every other browser enforces it, so it is a real lockout.
<!-- Avoid: locks users out of zoom -->
<meta name="viewport" content="width=device-width, user-scalable=no, maximum-scale=1" />
<!-- Prefer -->
<meta name="viewport" content="width=device-width, initial-scale=1" />
Keyboard Patterns per Widget
A role is a promise. The moment you give an element role="tab" or role="menu", users expect the full keyboard model that native equivalent would give them for free. Native elements ship these behaviors already, custom widgets have to implement them by hand, which is the strongest reason to reach for the native element first.
The ARIA Authoring Practices key model, per widget:
| Widget | Keys |
|---|---|
| Dialog | Tab / Shift+Tab cycle inside and wrap at the ends; Escape closes |
| Tabs | Arrow keys move between tabs (wrapping); Home / End jump to first / last; Tab exits to the panel |
| Menu button | Enter / Space / ArrowDown opens and focuses the first item; ArrowUp opens and focuses the last; arrows navigate; Escape closes and refocuses the button |
| Disclosure / accordion | The header is a <button aria-expanded>; Enter and Space toggle it |
| Combobox | ArrowDown opens the list and moves into it; typing filters; Enter accepts; Escape closes and returns to the input |
| Listbox / radio group | Arrow keys move selection; the whole group is a single Tab stop |
Universal rules: Escape dismisses whatever opened last (tooltip, then menu, then dialog); arrow keys move within a composite widget while Tab moves between widgets.
Roving tabindex
A composite widget (tabs, a menu, a toolbar, a radio group) is one Tab stop, not one stop per item. The active item carries tabindex="0", every other member carries tabindex="-1", and arrow keys move both the focus and the 0.
<div role="tablist">
{tabs.map((tab, i) => (
<button
role="tab"
tabIndex={i === activeIndex ? 0 : -1}
aria-selected={i === activeIndex}
onKeyDown={handleArrowKeys} // ArrowLeft / ArrowRight move activeIndex, wrapping
>
{tab.label}
</button>
))}
</div>
Single-Page-App Route Changes
Client-side navigation does not reset focus or announce anything, so a screen reader user hears silence after a route change and a keyboard user is left focused wherever they last clicked. On every route change, do the work the browser would have done on a full page load:
- Update
document.titleto match the new context, most specific first, so the tab and the announcement name the right place. - Move focus to the new view's heading (an
<h1>giventabindex="-1"so it is programmatically focusable) or to<main>. That focus move is what announces the new content. - Restore scroll position on back / forward navigation, and scroll to top on a forward navigation.
useEffect(() => {
document.title = `${pageTitle} · Acme`;
headingRef.current?.focus(); // <h1 tabIndex={-1} ref={headingRef}>
}, [pathname]);
Dialogs, Landmarks, Forced Colors, and Label in Name
Native <dialog> with showModal(). Prefer it over a hand-built overlay: it gives you the focus trap, the inert background, and Escape-to-close for free. When a custom overlay genuinely cannot use it, recreate the semantics: role="dialog", aria-modal="true", an accessible name via aria-labelledby pointing at the dialog's heading, and set inert on everything behind it so focus and assistive tech cannot reach the background.
// On open
document.getElementById("app-content").inert = true;
dialogRef.current.showModal();
// On close
document.getElementById("app-content").inert = false;
triggerRef.current?.focus();
Landmarks. Expose one primary <main>, and use <nav>, <aside>, <footer>, <header> for their regions: screen-reader users jump between them directly. When two landmarks share a type, distinguish them with a label so the jump list is not two entries both called "navigation".
<nav aria-label="Primary">…</nav>
<nav aria-label="Breadcrumbs">…</nav>
Forced colors (Windows High Contrast). In forced-colors: active the OS swaps your palette for the user's chosen system colors. Let it. Keep the browser's default focus indicator or use a system color such as Highlight, and never freeze the authored ring color with forced-color-adjust: none, that is exactly what makes a control vanish against the forced background.
Label in Name (WCAG 2.5.3). The visible label text must appear inside the accessible name, so voice-control users who say "click Send" actually hit the button that reads "Send". A control showing "Send" with aria-label="Submit message" has no overlap between what is seen and what is spoken, and the voice command fails.
// Bad: visible "Send" is nowhere in the accessible name
<button aria-label="Submit message">Send</button>
// Good: accessible name comes from, or contains, the visible text
<button>Send</button>