Web Accessibility Fundamentals
How the browser turns your DOM into the accessibility tree screen readers use, why semantic HTML beats ARIA, and how to get keyboard, focus and forms right.
A screen reader never sees your pixels. The browser builds an accessibility tree from the DOM, giving each meaningful node a role (button, link, heading), an accessible name ("Save") and a state (expanded, checked, disabled), and assistive technology reads and drives the page through that tree. Native HTML elements fill it in correctly for free, including keyboard behaviour; a div with a click handler leaves it empty, and ARIA (Accessible Rich Internet Applications) attributes can only patch the description, not add the behaviour.
Context
Web accessibility, shortened to a11y (11 letters between the a and the y), means people with disabilities can perceive, operate and understand a site. That covers blind users on screen readers, low-vision users zooming to 400%, people who cannot use a mouse and navigate by keyboard or switch devices, deaf users who need captions, and people with cognitive or vestibular conditions. The World Health Organization estimates about 1.3 billion people, roughly one in six, live with a significant disability (2023), and many more are in temporary or situational versions of the same situation: a broken wrist, a phone in bright sunlight, a screen read aloud while driving.
The rules come from the W3C (World Wide Web Consortium): WCAG (Web Content Accessibility Guidelines) 1.0 in 1999, 2.0 in 2008, 2.1 in 2018 and 2.2 in October 2023. WAI-ARIA 1.0 became a standard in 2014 to describe custom widgets that HTML had no element for. Laws reference those guidelines: the Americans with Disabilities Act (1990) is the basis for thousands of US website lawsuits a year, and the European Accessibility Act has applied to many products and services sold in the EU since 28 June 2025. You have met the results as alt text on images, the "Skip to content" link that appears when you press Tab on a large site, and the focus ring frameworks try to hide. The simplest view of it is what gets announced:
<button>Save</button> <!-- "Save, button" -->
<a href="/pricing">Pricing</a> <!-- "Pricing, link" -->
<img src="q3.png" alt="Sales up 20% in Q3"> <!-- reads the alt -->
<img src="divider.svg" alt=""> <!-- decorative: skipped -->
<div onclick="save()">Save</div> <!-- "Save": no role, no Tab stop -->- AT (assistive technology)
- Software or hardware that sits between the user and the page: screen readers (VoiceOver, NVDA, JAWS, TalkBack), screen magnifiers, voice control, switch devices.
- Accessibility tree
- A parallel tree the browser derives from the DOM, with role, name, state and value for each node, exposed to AT through the operating system’s accessibility APIs.
- Accessible name
- What AT calls an element: the button text, the label of an input, the alt of an image, or an aria-label.
- Role
- What kind of thing a node is (button, link, checkbox, dialog, heading). It tells the user what they can do with it.
- WCAG levels
- A, AA and AAA. AA is the level laws and contracts usually require.
- POUR
- WCAG’s four principles: content must be Perceivable, Operable, Understandable and Robust.
Why it matters
Most accessibility failures are not exotic. Automated scans of the top million home pages (the WebAIM Million report) keep finding detectable WCAG failures on about 95% of them, dominated by the same handful: low-contrast text, missing alt text, empty links and buttons, and inputs without labels. Each one is a person who cannot buy, sign up or finish a task, and since June 2025 it is also a compliance problem for anyone selling into the EU. The work is cheapest when it is part of how components are built; retrofitting a design system after an audit or a demand letter means touching every screen.
The accessibility tree
For every element, the browser computes a role from the tag (or an explicit role), an accessible name from content, labels or ARIA, and states from attributes such as disabled, checked or aria-expanded. A screen reader announces exactly that, and its navigation shortcuts (next heading, next form field, list of links, list of landmarks) work only on nodes that carry the right roles. You can inspect the tree in the Accessibility pane of Chrome or Firefox DevTools.
Making the div behave like a button means re-implementing what the browser already does: a role, a tab stop, Enter and Space handling, a disabled state, and form submission when it sits inside a form. That is why the fix is almost always to use the native element and restyle it.
// every line here is the browser's job, done badly
<div
role="button"
tabIndex={0}
onClick={save}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
save()
}
}}
aria-disabled={busy}
>
Save
</div>// role, focus, Enter/Space, disabled: all native
<button type="button" onClick={save} disabled={busy}>
Save
</button>
// a link navigates, a button acts:
<a href="/settings">Settings</a>Names, roles and ARIA
Every interactive element and every meaningful image needs an accessible name. The browser computes it in a fixed order (the W3C accessible name computation): an aria-labelledby reference wins, then aria-label, then the native source such as a label, the alt attribute or the element's text content, and title only as a last resort. Visible text that AT also reads is the most robust option, because sighted users, voice-control users ("click Save") and screen reader users all get the same word.
| Name source | Example | Use when |
|---|---|---|
| Text content | <button>Save</button> | Buttons, links, headings: the default |
| <label> | <label for="email"> | Every form control; clicking it also focuses the input |
| alt | alt="Sales up 20%" | Informative images; alt="" for decorative ones |
| aria-labelledby | aria-labelledby="dlg-title" | The name is already visible elsewhere, e.g. a dialog heading |
| aria-label | aria-label="Close" | Icon-only buttons with no visible text |
The first rule of ARIA
The W3C's own guidance starts with: if a native HTML element or attribute already has the semantics and behaviour you need, use it instead of adding ARIA. ARIA changes only what the accessibility tree says, never what the element does, so role="button" makes a promise the div cannot keep unless you also add focus and key handling. That is why the Authoring Practices warn that no ARIA is better than bad ARIA. ARIA is the right tool for patterns HTML lacks (tabs, comboboxes, tree views), for states HTML cannot express (aria-expanded on a disclosure button, aria-current="page" on the active nav link), and for announcing changes that happen without a page load.
// A live region must exist in the DOM *before* its text changes;
// inserting the region and the message together is often not announced.
<p role="status" aria-live="polite" className="sr-only">
{saved ? 'Changes saved' : ''}
</p>
// Form errors: mark the field and tie the message to it
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{error && <p id="email-error">{error}</p>}Keyboard and focus
Everything a mouse can do must work from the keyboard: Tab and Shift+Tab move between interactive elements in DOM order, Enter activates links and buttons, Space toggles buttons and checkboxes, arrow keys move inside composite widgets, and Escape closes things. Focus order follows the DOM, so a layout that visually reorders content with CSS can make Tab jump around the screen. tabIndex={0} adds an element to the natural order, tabIndex={-1} makes it focusable only from script (for a heading you move focus to), and positive values should never be used because they override the order for the whole page.
The focus indicator is how a keyboard user sees where they are, so removing outline without a replacement makes the page unusable for them. :focus-visible (supported in every major browser since 2022) shows the ring for keyboard focus but not for mouse clicks, which removes the usual design objection.
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* respect the OS "reduce motion" setting */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}Walk-through: an accessible modal
- 1Remember which element had focus before opening, usually the button that opened the dialog.
- 2Open it as a real modal. The native
<dialog>element withshowModal()(in every major browser since 2022) moves focus inside, makes the rest of the page inert so Tab cannot leave, gives it the dialog role and closes on Escape. - 3Give the dialog a name with
aria-labelledbypointing at its heading, so it is announced as "Delete project?, dialog". - 4Put initial focus on the least destructive sensible control, not on "Delete".
- 5On close, return focus to the element from step 1, so the user continues where they were instead of at the top of the page.
function ConfirmDialog({open, onClose, onConfirm}: Props) {
const ref = useRef<HTMLDialogElement>(null)
const opener = useRef<HTMLElement | null>(null)
useEffect(() => {
const dialog = ref.current
if (!dialog) return
if (open && !dialog.open) {
opener.current = document.activeElement as HTMLElement
dialog.showModal() // focus trap, inert page, Esc
} else if (!open && dialog.open) {
dialog.close()
}
}, [open])
return (
<dialog
ref={ref}
aria-labelledby="confirm-title"
onClose={() => {
onClose()
opener.current?.focus() // return focus to the trigger
}}
>
<h2 id="confirm-title">Delete project?</h2>
<button autoFocus onClick={() => ref.current?.close()}>
Cancel
</button>
<button onClick={onConfirm}>Delete</button>
</dialog>
)
}Contrast, motion and testing
WCAG AA asks for a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (24px, or about 19px bold) and for the boundaries of controls and focus indicators. Light grey placeholder text on white is the most common failure. Colour must never be the only signal: an error needs an icon or text, not just a red border. WCAG 2.2 added, among others, a minimum target size of 24 by 24 CSS pixels and a rule that focused elements must not be hidden behind sticky headers.
Automated tools such as axe and Lighthouse find a useful but limited share of issues, often quoted as somewhere between a third and a half: they can see a missing label, not whether the label makes sense or whether the focus order is logical. A practical routine combines all three layers.
- 1Automate the detectable: run axe in component and end-to-end tests so missing names, bad contrast and invalid ARIA fail the build.
- 2Query by role in tests: Testing Library's
getByRole('button', {name: 'Save'})only finds elements that are exposed correctly, so ordinary tests double as accessibility tests. - 3Keyboard pass: unplug the mouse and complete the main flows with Tab, Enter, Space, arrows and Escape; watch that focus is always visible and never lost.
- 4Screen reader smoke test: VoiceOver (built into macOS and iOS) or NVDA (free on Windows) on the key pages: are headings, landmarks, labels and error messages announced?
import {test, expect} from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('checkout has no detectable a11y violations', async ({page}) => {
await page.goto('/checkout')
const results = await new AxeBuilder({page})
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze()
expect(results.violations).toEqual([])
})Pitfalls
- Click handlers on divs and spans
They are invisible to the keyboard and announced as plain text, because the browser only gives buttons and links a role, a tab stop and Enter/Space activation. Use
<button>for actions and<a href>for navigation, and restyle them. - Placeholder used as the label
The placeholder disappears as soon as the user types, usually fails contrast, and is not reliably used as the accessible name. Users with memory or attention difficulties lose the question they are answering. Keep a visible
<label>; the placeholder can show an example. - Removing the focus outline
outline: nonein a global reset leaves keyboard users with no idea where they are. Style:focus-visibleinstead, so mouse users do not see the ring and keyboard users do. - ARIA that contradicts the element
role="button"on a link,aria-hiddenon a focusable element, or anaria-labelthat differs from the visible text all make the tree lie about the page. ARIA overrides native semantics, so a wrong attribute is worse than none. - Trusting an overlay or a green score
Third-party "accessibility overlay" widgets do not fix the underlying markup and often interfere with users' own assistive tools, and a 100 Lighthouse accessibility score only means no automated rule failed. Neither replaces semantic markup, a keyboard pass and a screen reader check.
Interview questions
Q1What is the accessibility tree, and why does semantic HTML matter for it?
It is the tree the browser derives from the DOM with a role, name and state for each meaningful node, and it is what screen readers and other assistive technology read and operate. Native elements populate it correctly and come with keyboard behaviour, so a button is announced as a button and works with Enter and Space. Generic divs produce generic nodes, and everything then has to be rebuilt by hand.
Q2When should you use ARIA?
Only when HTML has no native element or attribute for what you need. Examples are widgets like tabs or comboboxes, states like aria-expanded or aria-current, naming an icon-only button, and live regions for updates without a page load. ARIA only changes what is announced, not behaviour, so a role without the matching keyboard support is a broken promise.
Q3Walk me through implementing an accessible modal dialog.
Store the element that had focus, then open a native dialog with showModal(), which traps focus, makes the background inert, sets the dialog role and handles Escape. Name it with aria-labelledby pointing at its heading, put initial focus on a safe control, and on close return focus to the stored element. With a custom div I would have to implement the focus trap, inert background and Escape myself, which is why I prefer the native element.
Q4What happens when a screen reader user submits a form with errors?
If nothing is done, nothing is announced: the red text appears, but focus stays on the submit button. I mark each invalid field with aria-invalid, tie its message with aria-describedby so it is read when the field gets focus, and move focus to the first invalid field or to an error summary at the top. For asynchronous validation, a polite live region announces the result.
Q5How do you make client-side route changes accessible?
Replace what a full page load would have done: update the document title, then either move focus to the new page's main heading with tabIndex={-1} or announce the new title in a live region. Next.js has a built-in route announcer for the second approach. Without one of these, keyboard focus stays on the link that was clicked and the screen reader says nothing.
Q6What do WCAG levels and the AA contrast rules mean in practice?
WCAG has three conformance levels, and AA is the one laws and contracts usually require, including the EU accessibility rules in force since June 2025. For contrast, AA means 4.5:1 for normal text and 3:1 for large text and for control boundaries and focus indicators. The guidelines are grouped under four principles: perceivable, operable, understandable and robust.
Q7How would you test the accessibility of a feature?
In three layers. Axe in component and end-to-end tests catches the detectable rules, and querying by role in Testing Library makes ordinary tests fail when semantics break. Then a manual keyboard pass through the main flow checks order, visible focus and traps. Finally a short screen reader check with VoiceOver or NVDA confirms that names, headings and error messages make sense, which no tool can judge.
- Assistive technology reads the accessibility tree, not the screen: every interactive node needs a role, a name and correct states.
- Native elements give you semantics and keyboard behaviour for free; a clickable div gives you neither.
- Use ARIA only where HTML has no equivalent, and remember it changes what is announced, never what the element does.
- Everything must work by keyboard, with a visible
:focus-visiblering and focus moved deliberately for dialogs and route changes. - AA is the usual legal target: 4.5:1 text contrast, labels on every input, errors tied to their fields and never conveyed by colour alone.
- Automated checks catch only part of the problems; add a keyboard pass and a screen reader smoke test.