Many people use the web with a keyboard only, a screen reader, zoom or voice control. Most accessibility comes free from semantic HTML: a real <button> is focusable and works with Enter and Space, <label for> gives every input a spoken name and a bigger click target, and headings and <main> let screen-reader users jump around the page. A <div onclick> has none of this.
Forms should explain problems in words, not only colour. Link each error message to its input with aria-describedby, mark the field with aria-invalid="true" and move focus to the first invalid field. A live region (role="status") announces messages such as "Saved" without moving focus. Show where focus is with :focus-visible styles. Never remove the outline without a replacement.
Use ARIA only to describe what HTML cannot: aria-expanded on a button that shows and hides content, roles for custom widgets such as tabs. The native <dialog> element with showModal() traps focus and closes on Escape; afterwards, return focus to where the user was. Test with only the keyboard (Tab, Shift+Tab, Enter, Escape) and run an automated checker such as Lighthouse or axe.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Register a student</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 1rem auto; padding: 0 1rem; }
label { display: block; margin-top: 0.75rem; font-weight: 600; }
input { display: block; padding: 0.4rem; font-size: 1rem; }
[aria-invalid="true"] { border: 2px solid #b00020; }
.error { color: #b00020; margin: 0.25rem 0 0; }
:focus-visible { outline: 3px solid #1a73e8; outline-offset: 2px; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
</style>
<script src="app.js" defer></script>
</head>
<body>
<main>
<h1>Register a student</h1>
<form id="register" novalidate>
<label for="name">Full name</label>
<input id="name" name="name" autocomplete="name" aria-describedby="name-error" />
<p class="error" id="name-error"></p>
<label for="phone">Parent's phone <span class="visually-hidden">(10 digits, starting with 0)</span></label>
<input id="phone" name="phone" type="tel" inputmode="numeric" aria-describedby="phone-hint phone-error" />
<p id="phone-hint">e.g. 0712345678</p>
<p class="error" id="phone-error"></p>
<button type="button" id="fees-toggle" aria-expanded="false" aria-controls="fees">Show fee details</button>
<div id="fees" hidden>
<p>Tuition TSh 150,000 per term. Uniform TSh 45,000.</p>
</div>
<p><button type="submit">Register</button></p>
</form>
<!-- Screen readers announce changes to a live region without moving focus -->
<p id="status" role="status"></p>
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Register this student?</h2>
<p id="confirm-text"></p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="ok">Yes, register</button>
</form>
</dialog>
</main>
</body>
</html>const form = document.querySelector("#register");
const status = document.querySelector("#status");
const dialog = document.querySelector("#confirm");
const toggle = document.querySelector("#fees-toggle");
// A disclosure: a real <button> is focusable and works with Enter/Space for free
toggle.addEventListener("click", () => {
const open = toggle.getAttribute("aria-expanded") === "true";
toggle.setAttribute("aria-expanded", String(!open));
document.getElementById(toggle.getAttribute("aria-controls")).hidden = open;
toggle.textContent = open ? "Show fee details" : "Hide fee details";
});
function setError(input, message) {
document.getElementById(`${input.id}-error`).textContent = message;
if (message) input.setAttribute("aria-invalid", "true");
else input.removeAttribute("aria-invalid");
}
function validate() {
const name = form.elements.name;
const phone = form.elements.phone;
setError(name, name.value.trim().length < 2 ? "Enter the student's full name." : "");
setError(phone, /^0[67]\d{8}$/.test(phone.value.trim()) ? "" : "Enter a 10-digit phone number starting with 06 or 07.");
return form.querySelector('[aria-invalid="true"]');
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const firstInvalid = validate();
if (firstInvalid) {
firstInvalid.focus(); // take keyboard and screen-reader users to the problem
status.textContent = "Please fix the highlighted fields.";
return;
}
status.textContent = "";
document.querySelector("#confirm-text").textContent = `${form.elements.name.value.trim()}, parent phone ${form.elements.phone.value.trim()}`;
dialog.showModal(); // traps focus inside; Escape closes it
});
dialog.addEventListener("close", () => {
if (dialog.returnValue === "ok") {
status.textContent = `${form.elements.name.value.trim()} registered.`;
form.reset();
form.elements.name.focus();
} else {
form.querySelector('button[type="submit"]').focus(); // return focus where the user was
}
});Key points
- Semantic elements (
button,label,main, headings) give keyboard and screen-reader support for free. - Tie errors to inputs with
aria-describedbyandaria-invalid, and move focus to the problem. - Announce changes with a live region, manage focus around dialogs and always keep visible focus styles.
Exercise
Build accessible tabs for Maths, Biology and Chemistry results: role="tablist", tab and tabpanel, aria-selected, and a roving tabindex so only the current tab is in the Tab order. Left/Right arrows move between tabs (wrapping around), and Home/End jump to the first and last.
Show solution
Try the exercise yourself first — then compare your approach with this one.
Each tab is a real <button>, with role="tab" saying what kind of control it is. Only the selected tab has tabindex="0", so Tab moves from the tab list straight into the panel; the arrow keys move between tabs. aria-selected, aria-controls and aria-labelledby let a screen reader say "Biology, tab, 2 of 3, selected" and name each panel.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Results by subject</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 1rem auto; padding: 0 1rem; }
[role="tablist"] { display: flex; gap: 0.25rem; border-bottom: 2px solid #ccc; }
[role="tab"] { padding: 0.5rem 1rem; border: 0; background: #eee; font-size: 1rem; }
[role="tab"][aria-selected="true"] { background: #1a73e8; color: #fff; }
[role="tabpanel"] { padding: 1rem 0; }
:focus-visible { outline: 3px solid #f9a825; outline-offset: 2px; }
</style>
<script src="tabs.js" defer></script>
</head>
<body>
<main>
<h1>Form 4 results</h1>
<div role="tablist" aria-label="Subjects">
<button role="tab" id="tab-maths" aria-controls="panel-maths" aria-selected="true">Maths</button>
<button role="tab" id="tab-biology" aria-controls="panel-biology" aria-selected="false" tabindex="-1">Biology</button>
<button role="tab" id="tab-chemistry" aria-controls="panel-chemistry" aria-selected="false" tabindex="-1">Chemistry</button>
</div>
<section role="tabpanel" id="panel-maths" aria-labelledby="tab-maths" tabindex="0">
<p>Average 70.8 · Highest: Ali Mohamed (95)</p>
</section>
<section role="tabpanel" id="panel-biology" aria-labelledby="tab-biology" tabindex="0" hidden>
<p>Average 67.8 · Highest: Neema Kimaro (84)</p>
</section>
<section role="tabpanel" id="panel-chemistry" aria-labelledby="tab-chemistry" tabindex="0" hidden>
<p>Average 61.0 · Highest: Amina Hassan (77)</p>
</section>
</main>
</body>
</html>const tabs = [...document.querySelectorAll('[role="tab"]')];
function select(tab) {
for (const t of tabs) {
const selected = t === tab;
t.setAttribute("aria-selected", String(selected));
t.tabIndex = selected ? 0 : -1; // roving tabindex: only the current tab is in the Tab order
document.getElementById(t.getAttribute("aria-controls")).hidden = !selected;
}
tab.focus();
}
for (const tab of tabs) {
tab.addEventListener("click", () => select(tab));
tab.addEventListener("keydown", (event) => {
const i = tabs.indexOf(tab);
const next = {
ArrowRight: tabs[(i + 1) % tabs.length],
ArrowLeft: tabs[(i - 1 + tabs.length) % tabs.length],
Home: tabs[0],
End: tabs[tabs.length - 1],
}[event.key];
if (next) {
event.preventDefault();
select(next);
}
});
}