Accessible components, with code to copy
Dialogs, menus, status messages, form errors, icon buttons and focus styles appear in almost every web app, and each is easy to get wrong for keyboard and screen reader users. Here is how to build each one, in HTML or React, with code to copy.
Design engineering
Start with the native element
A <button> is focusable, fires on Enter and Space, and announces itself as a button. A <div onClick> does none of that until you add it back by hand. Use the native element whenever one exists, and reach for ARIA only to describe what HTML can’t.
1. Dialogs: move focus in, keep it there, give it back
Open a modal with showModal() on a native <dialog>. The browser moves focus into it, makes the rest of the page inert, and closes it on Escape. Name it with its heading. When it closes, current browsers return focus to the button that opened it; the script below does it explicitly, so a keyboard user never lands at the top of the page in an older browser or a dialog closed some other way.
<button id="open-invite">Invite a teammate</button>
<dialog id="invite" aria-labelledby="invite-title">
<h2 id="invite-title">Invite a teammate</h2>
<form method="dialog">
<label for="invite-email">Email</label>
<input id="invite-email" type="email" autocomplete="email">
<button value="send">Send invitation</button>
<button value="cancel">Cancel</button>
</form>
</dialog>
<script>
const opener = document.getElementById("open-invite");
const dialog = document.getElementById("invite");
opener.addEventListener("click", () => dialog.showModal());
dialog.addEventListener("close", () => opener.focus());
</script>import { useId, useRef } from "react";
export function InviteDialog() {
const id = useId();
const dialog = useRef<HTMLDialogElement>(null);
const opener = useRef<HTMLButtonElement>(null);
return (
<>
<button ref={opener} onClick={() => dialog.current?.showModal()}>
Invite a teammate
</button>
<dialog
ref={dialog}
aria-labelledby={`${id}-title`}
onClose={() => opener.current?.focus()}
>
<h2 id={`${id}-title`}>Invite a teammate</h2>
<form method="dialog">
<label htmlFor={`${id}-email`}>Email</label>
<input id={`${id}-email`} type="email" autoComplete="email" />
<button value="send">Send invitation</button>
<button value="cancel">Cancel</button>
</form>
</dialog>
</>
);
}Meets 2.1.2 No Keyboard Trap, 2.4.3 Focus Order and 4.1.2 Name, Role, Value.
2. Status messages: announce without moving focus
“Saved”, “3 results” and “Invitation sent” change the page without moving focus, so a screen reader user never hears them unless they’re in a live region. Put an empty role="status" container in the page when it loads, then change its text. A container added at the same moment as its text is often not announced.
<!-- in the page from the start, empty -->
<div role="status" id="status"></div>
<script>
function announce(message) {
document.getElementById("status").textContent = message;
}
announce("Invitation sent to Sam.");
</script>import { useState } from "react";
export function InviteButton({ invite }: { invite: () => Promise<void> }) {
const [status, setStatus] = useState("");
return (
<>
<button
onClick={async () => {
await invite();
setStatus("Invitation sent to Sam.");
}}
>
Send invitation
</button>
{/* rendered from the start, empty, so the change is announced */}
<div role="status">{status}</div>
</>
);
}Keep the message on screen until the person dismisses it or does something else. A toast that disappears after three seconds is gone before many people have read it.
Meets 4.1.3 Status Messages and 2.2.1 Timing Adjustable.
3. Form errors: say what’s wrong, where, and how to fix it
Tie each error to its field with aria-describedby, mark the field with aria-invalid, and write the fix, not just the problem: “Enter an email address with an @, like sam@example.com” rather than “Invalid email”. A red border alone isn’t enough, because people who can’t see color need the words.
On submit, move focus to the first field with an error. A screen reader then reads its label and its error together, and a keyboard user is already where the fix goes. Validate on submit or when a field loses focus, not on every keystroke, so nobody is told they’re wrong while they’re still typing.
<form id="signup" novalidate>
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required
aria-describedby="email-error">
<p id="email-error" hidden>Enter an email address with an @, like sam@example.com.</p>
<button>Create account</button>
</form>
<script>
const form = document.getElementById("signup");
const email = form.elements.email;
const error = document.getElementById("email-error");
form.addEventListener("submit", (event) => {
const valid = email.checkValidity();
if (valid) email.removeAttribute("aria-invalid");
else email.setAttribute("aria-invalid", "true");
error.hidden = valid;
if (!valid) {
event.preventDefault();
email.focus(); // the first field with an error
}
});
</script>import { useId, useRef, useState, type FormEvent } from "react";
export function SignupForm({ onSignup }: { onSignup: (email: string) => void }) {
const id = useId();
const email = useRef<HTMLInputElement>(null);
const [error, setError] = useState("");
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const input = email.current;
if (!input) return;
if (!input.checkValidity()) {
setError("Enter an email address with an @, like sam@example.com.");
input.focus(); // the first field with an error
return;
}
setError("");
onSignup(input.value);
}
return (
<form onSubmit={submit} noValidate>
<label htmlFor={id}>Work email</label>
<input
ref={email}
id={id}
type="email"
autoComplete="email"
required
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
/>
{error ? <p id={`${id}-error`}>{error}</p> : null}
<button>Create account</button>
</form>
);
}On a long form, also list the errors at the top as links to their fields, and give that list a heading such as “2 fields need a fix”.
Meets 3.3.1 Error Identification, 3.3.3 Error Suggestion, 1.4.1 Use of Color and 1.3.1 Info and Relationships.
4. Icon buttons: give them a name
A button that shows only an icon has no name for a screen reader to announce. Give it one, and hide the icon itself. When a button shows visible text, the accessible name should start with those words, so people using voice control can say what they see.
<button type="button" aria-label="Close">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
<!-- visible text: the name starts with the words people see -->
<button type="button">
<svg aria-hidden="true" focusable="false">…</svg>
Delete project
</button>import { Trash2, X } from "lucide-react";
export function CloseButton({ onClose }: { onClose: () => void }) {
return (
<button type="button" aria-label="Close" onClick={onClose}>
<X aria-hidden="true" />
</button>
);
}
// visible text: the name starts with the words people see
export function DeleteButton({ onDelete }: { onDelete: () => void }) {
return (
<button type="button" onClick={onDelete}>
<Trash2 aria-hidden="true" />
Delete project
</button>
);
}Meets 4.1.2 Name, Role, Value, 1.1.1 Non-text Content and 2.5.3 Label in Name.
5. Menus and disclosures: say whether it’s open
A button that shows and hides something, such as an account menu, a filter panel or a row of details, tells a screen reader whether it’s open with aria-expanded. Keep the content right after the button in the reading order, and hide it with hidden so it leaves the tab order when closed. For a plain show-and-hide, <details> and <summary> do all of this with no script.
Reserve role="menu" for app menus that behave like a desktop menu bar, with arrow keys between items. A list of links that drops down is a disclosure, and works best left as a list of links.
<button type="button" aria-expanded="false" aria-controls="account-links">
Account
</button>
<ul id="account-links" hidden>
<li><a href="/settings">Settings</a></li>
<li><a href="/billing">Billing</a></li>
</ul>
<script>
const toggle = document.querySelector("[aria-controls=account-links]");
const links = document.getElementById("account-links");
toggle.addEventListener("click", () => {
const open = toggle.getAttribute("aria-expanded") === "true";
toggle.setAttribute("aria-expanded", String(!open));
links.hidden = open;
});
</script>import { useId, useState } from "react";
export function AccountLinks() {
const id = useId();
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
>
Account
</button>
<ul id={id} hidden={!open}>
<li><a href="/settings">Settings</a></li>
<li><a href="/billing">Billing</a></li>
</ul>
</>
);
}Meets 4.1.2 Name, Role, Value and 2.4.3 Focus Order.
6. Focus: visible, and never hidden
Keyboard users need to see where they are. Never remove the focus outline without a replacement; style :focus-visible so mouse users don’t see rings they didn’t ask for. Then check that a sticky header, cookie banner or chat widget doesn’t cover the focused control.
:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* keep focused controls clear of a sticky header */
html {
scroll-padding-top: 4rem;
}Meets 2.4.7 Focus Visible and 2.4.11 Focus Not Obscured (Minimum).
Check it yourself
- Put the mouse away and use the component with Tab, Shift+Tab, Enter, Space and Escape. You should always see where focus is, and never get stuck.
- Turn on a screen reader (VoiceOver on a Mac with Command+F5, or NVDA on Windows) and listen. Each control should say what it is and what it does, and status messages should be read out.
- For components with no native element, such as a combobox or tabs, follow the keyboard and ARIA guidance in the WAI-ARIA Authoring Practices Guide.
See it on a product
- Read an example review, one task from start to finish.
- Ask for a review of one task on your own product.