a11y
Accessible interfaces are not a separate thing from good interfaces. Almost every rule here also makes the interface better for someone with a mouse and perfect vision. Treat it as craft, not compliance.
HTML first
The element carries the behaviour. Use it:
| Want | Use | Not |
|---|---|---|
| Something that does an action | <button type="button"> |
<div onClick> |
| Something that navigates | <a href> |
<button> that calls router.push |
| A list | <ul>/<ol> with <li> |
divs |
| A form | <form> with <button type="submit"> |
onClick on a button outside a form |
| A checkbox, radio, switch | <input type="checkbox">, role="switch" on a button |
custom divs |
| A section with a heading | <section aria-labelledby> |
a div with a bold paragraph |
| Emphasis | <strong>, <em> |
<b>, <i> or a span with font-weight |
| A dialog | <dialog> or a primitive with role="dialog" |
a fixed div |
A native element gets keyboard behaviour, focus, a role and a name for free. Every custom replacement has to earn all four back.
Names
Every control has an accessible name. In order of preference:
- Visible text inside it (
<button>Save changes</button>). - A visible
<label for>(inputs). aria-labelledbypointing at visible text.aria-label, only for icon-only controls, and it says what the control does, not what the icon is ("Copy link", not "Clipboard icon").
Images: alt that says what the image conveys, or alt="" if decorative. Icons inside labelled controls are aria-hidden.
Names are unique in context. Five "Edit" buttons in a list need "Edit Q3 report", either visibly or via aria-label.
Keyboard
- Everything interactive is reachable with Tab in visual order.
tabindexis0or-1, never positive. - Custom widgets follow the ARIA pattern for their role: arrows within a menu, tabs, listbox, radio group; Tab leaves the widget.
- Escape closes the topmost layer only.
- Focus moves into a dialog when it opens, is trapped while open, and returns to the trigger when it closes.
- Content behind a modal is
inert. - A skip link is the first focusable element and is visible on focus.
- Single-key shortcuts do not fire while focus is in a text field; modifier shortcuts do not clash with the browser's.
- Roving tabindex or
aria-activedescendantfor composite widgets, so one Tab stop per widget.
Focus visible
:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
:focus:not(:focus-visible) { outline: none; }
- Visible on every surface colour. Test on the darkest and the lightest.
- Matches the element's radius.
- Never
outline: nonewithout a replacement of at least 3:1 contrast against both the element and its background. - Rows and cards that are links show focus on the whole row, not on the text.
ARIA, sparingly
The first rule of ARIA: do not use ARIA if HTML does it. Then:
aria-expandedon the trigger of anything that expands.aria-controlspointing at it.aria-current="page"on the active nav link;aria-current="location"for in-page nav.aria-pressedfor toggle buttons;aria-checkedfor switches and checkboxes;aria-selectedfor tabs and options.aria-busyon regions that are loading;aria-disabledwhen a disabled control must stay focusable.aria-describedbyfrom an input to its error and hint.aria-live="polite"on a region that announces toasts and status.assertiveonly for errors that block.aria-hidden="true"on decorative icons, on visual duplicates (a word split into spans for animation gets anaria-labelon the parent), and on the backdrop.- Never
role="button"on a div when a button would do. Neveraria-labelon adivwith no role.
Forms
- Every field has a visible label. Placeholders are hints.
- Required fields are marked, and the mark is explained once.
- Errors: appear after blur or submit, sit under the field, are linked with
aria-describedby, and the first invalid field receives focus on submit. - Error text says what is wrong and how to fix it, in words; colour and an icon accompany it, never replace it.
- Groups of radios and checkboxes are in a
<fieldset>with a<legend>. autocompleteset so browsers and assistive tech can fill them.- Submit works with Enter from any field.
Colour and contrast
- Text 4.5:1; large text and UI boundaries 3:1; in both themes.
- Colour is never the only signal: status has an icon or word, links are underlined in running text, charts have patterns or labels.
- Placeholders and disabled text may sit below 4.5:1 only if nothing depends on reading them.
- Focus rings and selection colours checked against every surface.
prefers-contrast: morebumps borders to--fg-2and removes translucency.forced-colors: active(Windows High Contrast): borders on controls that relied on background,outlinefor focus, nobackground-imagefor meaning.
Motion
prefers-reduced-motion: reduce: transitions and animations to near zero, smooth-scroll off, parallax off, autoplaying video paused, but nothing hidden. Elements that would have animated in are simply present.- Nothing flashes more than three times a second.
- Autoplaying motion longer than five seconds has a pause control.
Structure
- One
h1per page; headings in order without skipping levels; a heading for every region a screen-reader user would want to jump to. - Landmarks: one
main,header,footer,nav(labelled if there are several),aside,search. - Lists are lists. A row-based UI is a
<ul>witharia-label. - Tables are
<table>with<th scope>, or a grid with proper roles. Not divs. langon the document and on any passage in another language.- Reading order in the DOM matches visual order. CSS
orderandflex-direction: row-reversebreak it.
Size and zoom
- Targets 24px minimum by WCAG, 44px in practice on touch.
- The page works at 200% browser zoom and at 320px width with no horizontal scroll and no lost content.
- Text spacing can be increased (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em) without clipping.
- Never disable pinch zoom.
Timing
- Toasts stay at least 5 seconds, longer with an action, and pause on hover and focus.
- Session timeouts warn with time to extend.
- Nothing requires a timed response to use.
Five-minute test
- Unplug the mouse. Tab through the screen. Can you see where you are? Can you do everything?
- Turn on VoiceOver (⌘F5) or NVDA. Navigate by headings (VO+⌘+H). Do the headings describe the page? Tab to controls: does each say its role, name and state?
- Open a dialog. Is focus inside? Does Escape close it and put focus back?
- Zoom to 200%. Does it still work?
- Turn on reduced motion. Is anything missing?
- Run axe. Fix every "serious" and "critical". Read the "moderate" ones; most are real.
Marks of inaccessible UI
| Sign | Fix |
|---|---|
div onClick |
button |
outline: none |
:focus-visible ring |
| Icon button with no name | aria-label |
| Placeholder as label | visible label |
| Error in red text only | icon + text + aria-describedby |
| Modal without focus trap | dialog primitive, inert behind |
| Custom select | native <select> or a listbox primitive |
| Hover-only affordance | visible always, or on focus too |
| Motion that hides content under reduced motion | content visible, motion removed |
| Colour-only status | icon or word |
From the catalogue
- Sara Soueidan — accessibility and SVG, written by someone who builds.
- ARIA Authoring Practices — the keyboard and role spec for each pattern.
- Inclusive Components — components built accessible from the start.
- axe, Polypane — test in the browser you already have open.
- Nielsen Norman Group — usability research that overlaps almost entirely with this list.
- More at stealth.pm/tools.