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.
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 want | Use | Not |
|---|---|---|
| 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 order | A 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 withhtmlForon the label and the sameidon the input. Aplaceholderis 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, usealt=""so screen readers skip it.
Compare the two forms. Click on the word "Title" in each one.
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} />
</>
);
}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:
: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.
<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>.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"(oraria-live="polite") waits until the screen reader finishes its current sentence, then reads the change. Use it for most messages.role="alert"(oraria-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.
Three accessibility details are in this form:
role="status"on the message makes it a polite live region.aria-invalidmarks the input as wrong when there is an error, andaria-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.
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-a11yimport { 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.
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.
Show solution
The five fixes:
- 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 usealt=""here, since the title is right next to it. - The title is a real heading (
<h3>), so screen reader users can jump to it. The card itself is an<article>. - 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". - "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. - "×" 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
Data and caching
Learn how Next.js splits a page into a static shell and streamed parts, how to cache data with 'use cache', and how to turn on the React Compiler.
Deploy to Vercel
Build the Reading List for production, put it on GitHub, and deploy it to a public URL with Vercel's Git integration or the Vercel CLI.