Accessibility is not a badge, a widget, or a perfect audit score. It is whether someone can understand the page and finish the task with the input and output they use. Start with native HTML, keep the keyboard path intact, and test the actual result.
🎙️ Published & recorded: ·
Browsers already know what a button, heading, link, list, table, and form field mean. Native elements bring keyboard behavior, roles, states, and platform support together. A clickable div brings none of that. My rule is blunt: if an HTML element does the job, use it. Add ARIA only for information HTML cannot express.
<!-- Wrong: no role, no keyboard action, no button semantics -->
<div class="save" onclick="save()">Save</div>
<!-- Right: Enter and Space work without custom key code -->
<button type="button" onclick="save()">Save</button>
<main>
<h1>Account settings</h1>
<nav aria-label="Settings">...</nav>
</main>
role="button" on a div still leaves you responsible for focus, Enter, Space, disabled behavior, and state. That is brittle code replacing one native element. Do not use ARIA to restyle the accessibility tree while leaving interaction broken.Unplug the mouse. Tab should move through interactive controls in a sensible order. Shift plus Tab should move backward. Enter should activate links and buttons; Space should activate buttons. Escape should close a temporary layer when closing it is expected. Arrow keys belong inside specific composite widgets, not as a replacement for Tab across the whole page.
/* Keep DOM order equal to reading and focus order */
<a href="#main" class="skip">Skip to main content</a>
...
<main id="main" tabindex="-1">...</main>
.skip { position: absolute; left: -9999px; }
.skip:focus { left: 1rem; top: 1rem; z-index: 10; }
/* Never do this globally */
*:focus { outline: none; }
Avoid positive tabindex. Values such as one and two create a second ordering system that drifts away from the DOM. Use native focusable controls and DOM order. Use tabindex="-1" only when script must move focus to something that should not join normal Tab navigation.
Keyboard focus answers one question: where will my next action happen? Give it a visible indicator that survives light and dark backgrounds. When a dialog opens, move focus inside it; keep Tab within the dialog; close on Escape; then return focus to the control that opened it. When ordinary content changes, do not move focus just to announce that something happened.
:focus-visible {
outline: 3px solid #0b63ce;
outline-offset: 3px;
}
const opener = document.activeElement;
dialog.showModal();
dialog.querySelector("button, input").focus();
dialog.addEventListener("close", () => opener?.focus());
[aria-hidden="true"] elements contain focusable descendants. One: reproduce it by tabbing while the drawer or modal is closed. Two: identify the link, button, or input still receiving focus. Three: use the native hidden attribute, inert, or remove the closed panel from the DOM. Four: reopen it and restore focus deliberately. Five: test forward and reverse Tab order again. Adding more aria-hidden does not remove keyboard focus.A screen reader does not narrate your CSS layout. It uses the accessibility tree produced from HTML, text, labels, and ARIA. Every control needs a useful accessible name. Its role should match the action. Its state, such as expanded, checked, selected, or invalid, must update when the interface changes. Headings and landmarks let users jump instead of listening from the top.
<button aria-expanded="false" aria-controls="filters">
Filters
</button>
<div id="filters" hidden>...</div>
/* On open, change both the DOM and exposed state */
button.setAttribute("aria-expanded", "true");
filters.hidden = false;
/* Icon-only button needs a name */
<button aria-label="Close dialog">×</button>
Test with at least one real browser and screen reader pair used by your audience. On Windows, NVDA with Firefox or Chrome is a useful starting point. On Apple devices, use VoiceOver with Safari. Learn a small route first: headings, landmarks, form controls, links, and buttons. Reading every sentence linearly is not how experienced users browse.
Placeholder text is a hint, not a label. Connect a visible label to each input. Put instructions before they are needed. On submit, show a concise error summary, link each message to its field, mark invalid fields, and preserve everything the person already entered. Error text should say what happened and how to fix it.
<label for="email">Work email</label>
<input id="email" name="email" type="email"
autocomplete="email"
aria-describedby="email-hint email-error">
<p id="email-hint">Use the address for your company account.</p>
<p id="email-error">Enter an address such as [email protected].</p>
/* Set only after validation fails */
input.setAttribute("aria-invalid", "true");
An invalid form control with name='email' is not focusable. One: submit the form and confirm the required field is inside a hidden step or collapsed panel. Two: do not keep native-required controls hidden when the browser validates. Three: reveal the step before validation, or disable/remove inactive controls from submission. Four: focus the first invalid field and expose its message. Five: submit again by keyboard. Do not silence the console by removing validation and shipping no replacement.WCAG uses contrast ratios, not opinions about whether gray looks readable. Normal text generally needs four point five to one. Large text generally needs three to one. Important control boundaries and focus indicators also need enough contrast. Measure the actual foreground over the actual background, including hover, disabled, selected, error, light mode, and dark mode.
/* Information survives without color */
.error {
color: #9b1c1c;
border-left: 4px solid currentColor;
}
.error::before { content: "Error: "; font-weight: 700; }
/* Keep links identifiable in body copy */
.prose a { color: #075db7; text-decoration: underline; }
.prose a:hover { text-decoration-thickness: 3px; }
Alternative text should do the job the image does in context. A linked logo might be named after the destination. A chart needs the conclusion and its data nearby. A decorative flourish should have empty alt text so it is skipped. Do not start with “image of.” The screen reader already announces an image.
<!-- Informative -->
<img src="checkout-error.png"
alt="Checkout shows Card declined below the card number">
<!-- Decorative -->
<img src="wave.svg" alt="">
<!-- Video: provide accurate captions and a transcript -->
<video controls>
<source src="setup.mp4" type="video/mp4">
<track kind="captions" srclang="en" src="setup-en.vtt"
label="English" default>
</video>
Automatic captions are a draft. Correct names, technical terms, punctuation, speaker changes, and meaningful sounds. Audio-only material needs a transcript. Video that conveys essential visual action may need audio description or an equivalent narrated version.
Single-page interfaces change without loading a new document. Screen readers may not notice. Use a live region for a short status that must be announced, such as “Saved” or “Five results.” Put the live region in the initial DOM, then change its text. Do not put an entire results list in an assertive live region.
<p id="status" role="status" aria-live="polite"></p>
async function save() {
status.textContent = "Saving";
await saveSettings();
status.textContent = "Settings saved";
}
/* Respect a user's motion preference */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto; }
.decorative-animation { animation: none; }
}
Loading, success, and failure are states, not visual decorations. Disable duplicate submission only when necessary, expose the busy state, and give the user a route to retry. If route navigation changes the main heading, update the document title and place focus where the new page starts.
WCAG organizes requirements around content being perceivable, operable, understandable, and robust. For most product work, target the applicable WCAG level required by policy or law, commonly level A and double A. But a checklist cannot tell you whether checkout is understandable, whether focus lands somewhere useful, or whether alternative text communicates the point.
Fast test order:
1. Zoom to 200% and check reflow
2. Finish the main task with keyboard only
3. Inspect headings, landmarks, names, roles, and states
4. Run automated checks and fix definite failures
5. Use a screen reader on the main task and error path
6. Test high contrast, dark mode, and reduced motion
7. Include disabled users in research before release
Test the smallest complete user journey, not a gallery of isolated components. Include the happy path, validation failure, loading state, empty state, and recovery from a server error.
Structure
[ ] One descriptive h1; headings do not skip for styling
[ ] Landmarks and page title identify the page
[ ] Native buttons, links, labels, lists, and tables are used
Keyboard and focus
[ ] Main task works with Tab, Shift+Tab, Enter, Space, Escape
[ ] Focus is always visible and never trapped by accident
[ ] Dialog focus enters, stays, and returns correctly
Content and forms
[ ] Controls have useful names; states update programmatically
[ ] Errors identify the field, problem, and repair
[ ] Color is not the only signal; contrast is measured
[ ] Images have contextual alt text; media has accurate captions
Reality check
[ ] 200% zoom and narrow viewport do not hide actions
[ ] Automated findings are reviewed, not treated as a score
[ ] A real screen reader completes the main and error paths
[ ] Known limitations have owners and release decisions
The best accessibility architecture is usually boring: native controls, sensible document order, visible focus, plain labels, and short status messages. Build that foundation first. Custom widgets and ARIA should be the exception you can explain and test, not the default style of frontend development.