Web development basics · Lesson 10 of 11 · about 35 minutes

Accessibility

The bakery's pre-order form, tested the way disabled customers use it: with only a keyboard, through what a screen reader hears, and at 400% zoom. Written the quick way, a keyboard user couldn't place an order at all, and the automated checker found 2 of the problems. Rebuilt with real buttons, labels and a few ARIA attributes, it passed every test.

By the end you'll know

  • What WCAG is, and which version and level the law usually points to
  • How to test a page with only the keyboard, and what a screen reader gets from it
  • Why a <div> with onClick locks keyboard users out
  • How labels, role="alert", aria-invalid and aria-describedby make a form usable without seeing it
  • What automated checkers like axe catch, and the larger part they miss

Before you start

  • You need: Lesson 2, where semantic HTML, contrast and fluid layout first came up, and Lesson 4 for client components. Node.js 20 or later, and Playwright.
  • Versions: Next.js 16.4.5, Playwright 1.63.0 and axe-core 4.14.0 (through @axe-core/playwright), November 2026.
  • The example: Juniper & Rye, the made-up bakery, and two versions of its pre-order form. Every run and line of output below is real.

The short version

  • Use real elements. A <div> styled as a button can't be reached with the Tab key. A <button> can, for free. On the quick form, a keyboard user could type a name and email and then go no further.
  • Every control needs a name a screen reader can say: a <label> for inputs, aria-label for icon buttons like ×, alt text for images.
  • Errors must be announced, not just coloured red: role="alert" to say it, aria-invalid and aria-describedby to tie it to the field.
  • Never remove the focus outline without putting a clear one back.
  • Automated checkers find a minority of problems. axe found 2 on the quick form; the keyboard, screen reader and zoom tests found 6 more.

Who this is for

About one in four adults in the US has some kind of disability. For a website, that includes people who:

  • can't use a mouse, and use a keyboard, a switch or voice control instead, all of which move from one control to the next the way the Tab key does;
  • can't see the screen, and use a screen reader (VoiceOver on Apple devices, NVDA or JAWS on Windows, TalkBack on Android) that reads the page aloud and lets them jump between headings, links and form fields;
  • see poorly, and zoom to 200% or 400%, or need stronger contrast;
  • are colour-blind, so "the red one" means nothing.

Plus everyone with a broken trackpad, a bright screen in the sun or a baby in one arm. Accessible pages work better for all of them.

The rules most organisations follow are the WCAG (Web Content Accessibility Guidelines), from the W3C, the web's standards body. Each guideline has a level: A (the minimum), AA (the usual target, and what laws refer to) and AAA. The current version is 2.2; US rules for state and local government sites require 2.1 AA, which 2.2 AA covers and adds to.

The two forms

The practice folder, lesson-10, has the bakery's pre-order form twice. /order-bad was written the quick way:

JSX
<span className="close" onClick={() => setBanner(false)}>×</span>
<img src="/loaf.svg" width="400" height="220" />
<div className="title">Pre-order dark rye</div>
<p className="faint">Pickup Saturday, 8am to noon</p>
<input placeholder="Your name" value={name} onChange={(e) => setName(e.target.value)} />
<input placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
<div className="qty">
  <span onClick={() => setQty(Math.max(1, qty - 1))}>−</span> {qty} <span onClick={() => setQty(qty + 1)}>+</span>
</div>
{error && <p className="err">{error}</p>}
<div className="btn" onClick={reserve}>Reserve</div>

with CSS that includes .bad *:focus { outline: none; }, a common way to hide the "ugly" ring browsers draw around the focused element, and a fixed width of 600 pixels. With a mouse, on a laptop, it works and looks fine. /order-good does the same job; you'll see its code as each problem comes up.

(Your machine)

BASH
cd lesson-10
npm install
npx playwright install chromium
cd site
npm install
npm run build
npm start
  • The first npm install installs Playwright and axe, in the lesson folder. npx playwright install chromium downloads the browser Playwright drives (skip it if you did it in an earlier lesson with the same version).
  • The second installs the site; npm run build and npm start serve it on port 3010. Leave it running and run the tests from the lesson-10 folder in another terminal.

The automated check

axe is the most widely used accessibility checker; it's what's inside many browser extensions and testing tools. scan.cjs runs it on both pages, counting only rules tied to WCAG A and AA:

JS
const { violations } = await new AxeBuilder({ page })
  .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
  .analyze();

(Your machine)

BASH
node scan.cjs
TEXT
/order-bad: 2 problems
  color-contrast (serious, 1 element): Elements must meet minimum color contrast ratio thresholds
  image-alt (critical, 1 element): Images must have alternative text
/order-good: 0 problems

Two problems: the faint pickup line, and the image with no alt text. contrast.cjs works out the first one with WCAG's formula:

TEXT
/order-bad: pickup text rgb(184, 184, 184), contrast 1.98 to 1 (AA needs 4.5 to 1)
/order-good: pickup text rgb(87, 83, 78), contrast 7.63 to 1 (AA needs 4.5 to 1)

Fix those two and axe reports zero. The rest of this lesson is what that zero would have hidden.

Only the keyboard

keyboard.cjs uses each page the way someone without a mouse does. It presses Tab, records what has focus and whether anything shows it, then tries to place an order: Tab to the name field and type, Tab to the email and type, Tab to Reserve and press Enter.

(Your machine)

BASH
node keyboard.cjs
TEXT
/order-bad, pressing Tab:
  1. <input> "Your name"  focus visible: NO
  2. <input> "Email"  focus visible: NO
  3. (back to the browser: end of the page)
  Order by keyboard: could not reach Reserve; no order
/order-good, pressing Tab:
  1. <button> "Dismiss announcement"  focus visible: yes
  2. <input> "Your name"  focus visible: yes
  3. <input> "Email"  focus visible: yes
  4. <button> "One fewer loaf"  focus visible: yes
  5. <button> "One more loaf"  focus visible: yes
  6. <button> "Reserve"  focus visible: yes
  7. (back to the browser: end of the page)
  Order by keyboard: reached Reserve; order placed

On the quick form, a keyboard user can't order. The Tab key visits only the two inputs, because browsers only make real controls focusable: links, buttons, inputs. The <span> × and −/+ and the <div> "button" have onClick, which a mouse can trigger, and nothing a keyboard can. And with the outline removed, even the two inputs give no sign of which one is active.

The fix is to use the element that already does the job:

JSX
<button type="button" aria-label="One fewer loaf" onClick={() => setQty(Math.max(1, qty - 1))}>−</button>
...
<button className="reserve" type="submit">Reserve</button>

A <button> is focusable, responds to Enter and Space, and tells screen readers it's a button, without a line of extra code. Rebuilding that on a <div> takes tabIndex, a role, and key handlers, and is easy to get subtly wrong. The Reserve button also sits inside a <form> with onSubmit, so pressing Enter in any field submits it, as people expect.

For the focus ring, the good page uses:

CSS
.good :focus-visible { outline: 3px solid #1d4ed8; outline-offset: 2px; }

:focus-visible applies when the browser decides focus should be shown, which in practice means keyboard use. Mouse users don't see a ring when they click a button; keyboard users always know where they are. If a design calls for removing the default outline, this is what to put back.

What a screen reader gets

A screen reader doesn't read the screen. It reads the accessibility tree: the browser's description of the page as a list of roles (button, heading, textbox) and names ("Reserve"). reader.cjs prints that tree with Playwright's ariaSnapshot():

(Your machine)

BASH
node reader.cjs
TEXT
/order-bad, accessibility tree:
  - text: × Rye week starts Tuesday
  - img
  - text: Pre-order dark rye
  - paragraph: Pickup Saturday, 8am to noon
  - textbox "Your name"
  - textbox "Email"
  - text: − 1 + Reserve
  - alert
TEXT
/order-good, accessibility tree:
  - main:
    - paragraph: Rye week starts Tuesday
    - button "Dismiss announcement": ×
    - img "A dark rye loaf, scored three times across the top"
    - heading "Pre-order dark rye" [level=1]
    - paragraph: Pickup Saturday, 8am to noon
    - text: Your name
    - textbox "Your name"
    - text: Email
    - textbox "Email"
    - paragraph:
      - strong: Loaves
    - group "Loaves":
      - button "One fewer loaf": −
      - status: "1"
      - button "One more loaf": +
    - button "Reserve"
  - alert

(The empty alert at the end of both is Next.js's own: it uses it to announce page changes when someone moves between pages.)

Reading the first tree as a screen reader user would hear it:

  • "× Rye week starts Tuesday" is just text; there's no way to know the × closes anything.
  • img with no name: depending on the screen reader, people may hear just "image", or the file name, "loaf dot S V G".
  • "Pre-order dark rye" is plain text. It looks like a heading, but it isn't one, so people who move around a page by headings (one of the most common ways to use a screen reader) never find it.
  • "− 1 +" and "Reserve" are text, not buttons. There's nothing to press.

The good page's tree is the page's real structure: a main region, a level-1 heading, buttons with names that say what they do, and a labelled group for the quantity. Where the names came from:

  • alt="A dark rye loaf, scored three times across the top" describes the image for people who can't see it. Describe what matters in context; for a purely decorative image, use alt="", which tells screen readers to skip it.
  • <label htmlFor="name">Your name</label> names the input with id="name". A visible label also stays put while you type; a placeholder, the quick form's only label, disappears with the first letter.
  • aria-label="Dismiss announcement" names a button whose only content is a symbol. ARIA (Accessible Rich Internet Applications) is a set of attributes for telling assistive technology what HTML alone can't. The first rule of ARIA is to use a real HTML element if one exists; here the element is a real <button>, and ARIA only gives it a name.
  • role="group" with aria-labelledby="qty-label" groups the − and + buttons under the heading "Loaves", and <output aria-live="polite"> makes the screen reader say the new number after each press (it appears as status: "1" in the tree).

Errors

The same script then presses Reserve with no email, and checks three things about the error message:

TEXT
  After a bad email: error in a role="alert" no, aria-invalid no, error linked to the field no
TEXT
  After a bad email: error in a role="alert" yes, aria-invalid yes, error linked to the field yes

On the quick form, the error appears in red under the fields, and that's all. A screen reader user pressed a button and heard nothing. On the good form:

JSX
<input
  id="email" type="email" autoComplete="email"
  value={email} onChange={(e) => setEmail(e.target.value)}
  aria-invalid={error ? "true" : undefined}
  aria-describedby={error ? "email-error" : undefined}
/>
{error && <p id="email-error" className="err" role="alert">{error}</p>}
  • role="alert" makes screen readers read the message out as soon as it appears.
  • aria-invalid="true" marks the field as wrong, so screen readers announce it as invalid when someone returns to it. The CSS also gives it a red border, so it's not shown by colour alone.
  • aria-describedby="email-error" links the message to the field, so it's read along with the field's name.
  • The message says how to fix it: "Enter an email address with an @, like ada@example.com", not just "invalid".
  • autoComplete="email" lets browsers and password managers fill it in, which helps everyone, and most of all people for whom typing is slow or painful.

At 400% zoom

Lesson 2 showed a fixed-width page overflowing a phone. Zoom does the same thing on a desktop: at 400% on a 1280-pixel-wide screen, a page has 320 CSS pixels of width, and WCAG asks that nothing then needs scrolling sideways to read. reflow.cjs loads both forms 320 pixels wide:

TEXT
/order-bad at 320 px wide: content is 632 px wide, 312 px off screen
/order-good at 320 px wide: content is 320 px wide, fits
Both forms at 400% zoom. Select the image to enlarge it.

The quick form's × is off screen entirely: someone zoomed in can't dismiss the banner without scrolling sideways to find it. The good form's CSS uses max-width instead of width, and max-width: 100% on the image, so everything shrinks to fit.

What axe missed

Putting it together, for the quick form:

ProblemWCAG 2.2 guidelineFound by
Image with no alt text1.1.1 Non-text content (A)axe
Faint pickup text, 1.98 to 11.4.3 Contrast (AA)axe
Reserve, −, + and × can't be reached by keyboard2.1.1 Keyboard (A)Keyboard test
No visible focus2.4.7 Focus visible (AA)Keyboard test
Heading and buttons are plain text to screen readers1.3.1 Info and relationships, 4.1.2 Name, role, value (A)Accessibility tree
Inputs labelled only by placeholders3.3.2 Labels or instructions (A)Reading the code and the tree
Error not announced or linked to the field4.1.3 Status messages (AA), 3.3.1 Error identification (A)Error test
312 pixels off screen at 400% zoom1.4.10 Reflow (AA)Zoom test

axe found 2 of the 8. That's not a fault in axe. It reports only what it can be sure of from the page's code, so what it finds is reliable. But it can't press keys and notice it's stuck, or know that "Pre-order dark rye" was meant to be a heading. Its separate "best practice" rules, which aren't WCAG requirements, came closer:

(Your machine)

BASH
node scan-all.cjs
TEXT
/order-bad: 3 best-practice problems
  landmark-one-main (moderate): Document should have one main landmark
  page-has-heading-one (moderate): Page should contain a level-one heading
  region (moderate): All page content should be contained by landmarks

Run automated checks on every build, because they're cheap and catch regressions. Then test by hand: keyboard first, then a screen reader, then zoom.

In real systems

  • Try a real screen reader for five minutes. On a Mac, Command+F5 turns VoiceOver on; on Windows, NVDA is free. Listen to your own page from the top. It changes how you write forms.
  • Put axe in your tests. @axe-core/playwright in end-to-end tests, or the axe browser extension while developing. Fail the build on new WCAG violations, the way Lesson 9 suggested for speed budgets.
  • Use components that are accessible already for the hard parts, like dialogs, menus, tabs and date pickers. Getting focus, keyboard and announcements right in a custom dialog is a lot of work; libraries like Radix and React Aria have done it.
  • It's law in many places. In the US, state and local government sites must meet WCAG 2.1 AA (the Justice Department moved the first deadline to April 26, 2027 for larger governments), and lawsuits against businesses' sites under the ADA (Americans with Disabilities Act) are common. The European Accessibility Act has applied to many online services sold in the EU since June 2025.
  • Overlays don't fix it. Scripts that promise to make a site accessible with one line of code don't change the underlying HTML, and many screen reader users find they make sites worse.

Spot the problem

Work out what's wrong before you read the explanation.

"Our accessibility score is 100." A shop's checkout passes axe and gets 100 in Lighthouse's accessibility category. A blind customer emails to say they couldn't pay. The payment step has a custom "Pay now" control and a card-number field that shows "Card declined" in red when the number is wrong. What's wrong? A perfect automated score proves only the absence of what automated tools can detect. Likely culprits, from this lesson: "Pay now" is a styled <div>, so it can't be focused or pressed without a mouse, and is just text to a screen reader; and "Card declined" is shown, not announced, so a screen reader user pressed pay and heard nothing. The fix: a real <button>, and the error in a role="alert" linked to the field with aria-describedby. Then check: can you pay using only the keyboard, and with VoiceOver or NVDA on, does the error get read out?

Keep this

NeedUseNot
Something to press<button><div onClick>
Name for an input<label htmlFor>A placeholder alone
Name for an icon buttonaria-labelOnly a symbol
An imagealt that describes it, or alt="" if decorativeNo alt
An errorrole="alert", aria-invalid, aria-describedby, and how to fix itRed text only
Focus:focus-visible with a clear outlineoutline: none
Alwaysaxe on every build; then keyboard, screen reader and 400% zoom by hand

Where you are now

You can test a page with only a keyboard, read what a screen reader gets from it, check contrast and zoom, and fix a form so anyone can use it: real buttons, labels, names for icons, announced errors and a visible focus. You also know that a clean automated scan is where testing starts, not where it ends. The last lesson puts the whole track together: how a real site is built, from the first HTML file to a deployed Next.js app with logins, data, speed and accessibility checks.

Check yourself

  1. Why couldn't a keyboard user press the quick form's Reserve?

  2. How many of the quick form's 8 problems did axe report?

  3. What does role='alert' do for the email error?

  4. What did a screen reader get for the quick form's '− 1 +'?

  5. Why use :focus-visible for the focus ring?

  6. At 400% zoom, what was wrong with the quick form?

Key terms

  • Accessibility (a11y): making sites usable by people with disabilities. "a11y" is the word with its 11 middle letters counted.
  • WCAG (Web Content Accessibility Guidelines): the W3C's standard, with levels A, AA and AAA; AA is the usual legal target.
  • Screen reader: software that reads a page aloud and lets people move through it by headings, links and controls.
  • Accessibility tree: the browser's description of a page as roles and names, which assistive technology reads.
  • Accessible name: what a control is called to assistive technology, from its text, a label, alt or aria-label.
  • ARIA (Accessible Rich Internet Applications): attributes like aria-label and role that add information HTML alone can't express.
  • Focus and :focus-visible: the element that receives keyboard input, and the CSS that marks it when the keyboard is in use.
  • Live region (role="alert", aria-live): part of a page whose changes screen readers announce.
  • Reflow: content fitting a narrow width, such as 400% zoom, without sideways scrolling.
  • axe: an open-source engine that checks pages against accessibility rules.

Go deeper