Skip to Content
React in a Week
Day 7 · Production with Next.js

Accessibility

Make the Reading List work for keyboard users and screen readers with semantic HTML, labels, alt text, focus management, live regions, and lint rules.

Accessibility, often shortened to a11y (an "a", 11 letters, then a "y"), means building apps that people can use however they use a computer. Some people navigate with only a keyboard. Some use a screen reader, software that reads the page aloud and lets them jump between headings, links and form fields. Some zoom in to 200%, or can't tell red from green.

The good news is that the browser already supports all of this, as long as you use HTML elements for what they are meant for. Most accessibility bugs in React apps come from using a <div> where a <button> or a <label> belongs. This lesson is the Reading List's accessibility pass.

Use the element that matches the job

Here are two "Delete" controls that look alike. Click inside the preview, then press Tab to move between them, and press Enter or Space on each.

Live code· A div is not a button

The <div> works with a mouse, but Tab skips straight past it and Enter does nothing. A screen reader announces it as plain text, so a blind user doesn't know it can be pressed. The <button> gets all of that for free: it is focusable, it responds to Enter and Space, and it is announced as "Delete (button), button".

Each built-in element comes with a role that tells assistive technology what it is. So pick elements by their meaning, not their looks:

You wantUseNot
Something that does an action<button><div onClick>
Something that goes to another page<a href> or Next.js Link<button onClick={navigate}>
Page regions<header>, <nav>, <main>, <footer><div className="header">
A title for a section<h1> to <h6>, in orderA bold <div>
A list of things<ul> and <li>A stack of <div>s

Screen reader users often jump from heading to heading to skim a page, the way sighted people scan. So give each page one <h1>, and don't skip levels. The Reading List uses <h1> for the page, <h2> for "Add a book" and "Your books", and <h3> for each book title.

Labels, names and alt text

Every form control and every button needs an accessible name: the text a screen reader reads out for it.

  • Inputs get their name from a <label>. In JSX, connect them with htmlFor on the label and the same id on the input. A placeholder is not a label: it disappears as soon as you type, and is often too faint to read.
  • Buttons get their name from their text. A button that only shows an icon, such as "×", needs an aria-label.
  • Images get their name from alt. Describe what matters about the picture. If the image is only decoration, use alt="" so screen readers skip it.

Compare the two forms. Click on the word "Title" in each one.

Live code· Placeholder vs label

Clicking a real label puts the cursor in its input, which also gives a bigger target for people with shaky hands. A screen reader on the second form says "Title, edit text". On the first it may only say "edit text", and the "×" button is read as "times" or "multiplication sign".

Deep diveWhy not a hard-coded id?

id="title" is fine for a form that appears once per page, like the Reading List's. If a component can appear several times, two inputs would share an id and the labels would point to the wrong one. React's useId hook gives each copy a unique id:

import { useId } from 'react';

function Field({ label, name }: { label: string; name: string }) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} name={name} />
    </>
  );
}
Quick check

A book card has a cover image next to the title, and the title already says the book's name. What alt text should the cover have?

Keyboard and focus

Try using your app with only the keyboard: Tab to move forward, Shift+Tab to move back, Enter to follow links and press buttons, Space for buttons and checkboxes. Everything you can do with a mouse should be possible, in a sensible order, and you should always be able to see where you are.

That last part is the focus indicator, usually an outline around the focused element. Never remove it with outline: none without adding something better. The :focus-visible selector shows a ring for keyboard users without showing it on every mouse click:

app/globals.css
:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

Two more keyboard helpers in the Reading List live in app/layout.tsx:

  • A skip link as the very first thing in the page. It stays hidden until it gets focus, then lets keyboard users jump past the header straight to <main id="main">.
  • <html lang="en">, so screen readers pronounce the text with the right language.
app/layout.tsx
<body>
  <a href="#main" className="skip-link">
    Skip to main content
  </a>
  <header className="site-header">
    <nav aria-label="Main">
      <Link href="/">Reading List</Link>
    </nav>
  </header>
  <main id="main">{children}</main>
</body>
app/globals.css
.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { left: 1rem; top: 1rem; background: var(--bg); padding: 0.5rem 1rem; }

When you move between pages with Link, Next.js has a built-in route announcer that tells screen readers the page changed. It reads the page's <title> first, then the <h1>. That is one more reason to give every page a unique, clear <h1>, like the book's title on the detail page.

Announcing changes

When a sighted user adds a book, they see "Added “Dune” to your list." appear under the form. A screen reader user hears nothing, because their reading position is still on the button. A live region fixes this. It is an element that screen readers watch: when its text changes, they read the new text aloud.

  • role="status" (or aria-live="polite") waits until the screen reader finishes its current sentence, then reads the change. Use it for most messages.
  • role="alert" (or aria-live="assertive") interrupts. Keep it for urgent errors.

The live region must already be on the page before its text changes, so render the element all the time and change only its contents.

You can also move focus on purpose when it helps. After adding a book, putting the cursor back in the Title field lets the user type the next one straight away. You did this with refs on Day 4, and since React 19 a ref is an ordinary prop.

Live code· A form that announces and refocuses

Three accessibility details are in this form:

  • role="status" on the message makes it a polite live region.
  • aria-invalid marks the input as wrong when there is an error, and aria-describedby="form-status" links the message to the input, so a screen reader reads the error when the user focuses the field.
  • titleRef.current.focus() puts the cursor back where the user needs it next.

In the Next.js project, the AddBookForm already renders its message in a <p role="status">, so the addBook result is announced the same way.

Quick check

Your form shows 'Saved!' after a Server Function finishes. Which markup makes a screen reader announce it politely?

Let a linter catch mistakes

You don't have to remember every rule. The ESLint plugin eslint-plugin-jsx-a11y reads your JSX and warns about common problems: an <img> without alt, a click handler on a <div> without keyboard support, a label not connected to a control, an invalid aria- attribute.

Next.js's ESLint config, eslint-config-next, already includes this plugin with a handful of its rules turned on. To turn on its full recommended set, install it directly and add its rules to eslint.config.mjs:

npm install -D eslint-plugin-jsx-a11y
eslint.config.mjs
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from 'eslint-config-next/typescript';
import jsxA11y from 'eslint-plugin-jsx-a11y'; 

const eslintConfig = defineConfig([
  ...nextVitals,
  ...nextTs,
  { 
    files: ['**/*.{js,jsx,ts,tsx}'], 
    rules: jsxA11y.flatConfigs.recommended.rules, 
  }, 
  globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
]);

export default eslintConfig;

Then run npm run lint. Many editors, including VS Code with the ESLint extension, also show the warnings as you type.

A linter only sees your source code, so it can't catch everything. Before you ship, also:

  • use the whole app with only the keyboard;
  • run Lighthouse in Chrome DevTools (the Accessibility category) or a browser extension such as axe DevTools;
  • try a screen reader for five minutes: VoiceOver is built into macOS (Cmd+F5), and NVDA is free for Windows;
  • zoom to 200% and check that nothing is cut off.
Try it

Fix this book card

This card has five accessibility problems. Find and fix them all, using the keyboard to test. Hint: look at the element types, the image, the "×", the status that only uses color, and the heading.

Live code· Challenge: an inaccessible card
Show solution
Live code· Solution: an accessible card

The five fixes:

  1. The image gets alt="". The cover is a plain colored block that adds nothing to the title, so it is decorative. A real cover photo could also use alt="" here, since the title is right next to it.
  2. The title is a real heading (<h3>), so screen reader users can jump to it. The card itself is an <article>.
  3. The status is no longer color alone. The text "Reading" or "Done" sits next to the dot, and the dot is hidden from screen readers with aria-hidden="true".
  4. "Next status" is a <button>, so it can be reached with Tab and pressed with Enter or Space. It does an action, so it is a button, not a link.
  5. "×" has aria-label="Delete Dune", so it is announced as "Delete Dune, button" instead of "times". Including the book's title tells the user which delete button this is, when there are many cards.

Day 7 progress

0/8
Next: Deploy to Vercel

On this page