← Back to skills
extension
Category: Development & EngineeringAPI key requirement unconfirmed

web-accessibility-web-accessibility

WCAG, ARIA, keyboard navigation

personAuthor: jakexiaohubgithub

Accessibility

Quick Guide: Most of accessibility is settled by choosing the right element: a <button> arrives with focus, keyboard activation and a role that a <div> needs a dozen lines to fake. What the element cannot give you is the rest — a name for every control, a visible focus indicator, 4.5:1 contrast on text, an announcement when content changes, and information never carried by colour alone. Target WCAG 2.2 Level AA. Automated checks find roughly half the failures, so a keyboard pass is not optional.

Detailed Resources:


<critical_requirements>

Before writing UI code

Reach for the semantic element before the ARIA attribute. <button>, <a href>, <nav> and <table> arrive with a role, keyboard behaviour and focus already correct — every one of which has to be rebuilt by hand on a <div>, and the rebuild is where the failures are.

Make every interactive element reachable and visibly focused. Keyboard users navigate by what the focus ring tells them, so outline: none without a replacement leaves them moving blind through the page.

Give every control a name a screen reader can announce. An icon-only button reads as "button" without one, which tells the listener there is something there and nothing about what it does.

Hold text to 4.5:1 against its background, and UI boundaries and focus indicators to 3:1. Below that the text is unreadable in bright light, on a cheap panel, or to anyone with reduced contrast sensitivity — which is most people eventually.

Pair every colour signal with a shape, an icon or a word. Red-for-error carries nothing to a red-green colour-blind reader, and nothing at all through a screen reader.

Announce content that appears without a page change. A validation error, a saved confirmation and a loaded result are all silent unless they arrive in a live region.

</critical_requirements>


Auto-detection: accessibility, a11y, WCAG, ARIA, aria-label, aria-labelledby, aria-describedby, aria-live, aria-expanded, aria-invalid, role attribute, keyboard navigation, focus management, focus trap, focus-visible, skip link, landmark, screen reader, sr-only, colour contrast, prefers-reduced-motion, tabindex, axe

Applies to:

  • Keyboard reachability, tab order, focus movement and focus indicators
  • ARIA roles, states and properties, and when the element makes them unnecessary
  • Contrast ratios and colour-independent information
  • Accessible names, live regions and announcement timing
  • Target size, motion preferences, and the WCAG 2.2 criteria that added them

Handled elsewhere:

  • Component internals for complex widgets — a tested primitive library implements the ARIA contract below; this skill states the contract so you can check whichever one you use.
  • Visual design tokens — the palette is designed elsewhere, and this skill supplies the ratio it has to clear.
  • Test runner mechanics — running an audit is your test tooling's job, and what to assert is here.
  • Content authoring — reading level, plain language and caption text are writing decisions rather than markup ones.

<philosophy>

Philosophy

An accessible interface is not a variant of the interface. It is the same one, built out of elements that already carry meaning, with the parts a browser cannot infer stated explicitly.

That reframes most of the work as subtraction. Semantic HTML is the accessible baseline; every <div> that replaces a real element is a debt paid back in ARIA attributes, key handlers and focus management, and paid back badly. The attributes below exist for the cases where no element carries the meaning — not as a layer applied over markup that could have carried it.

Build for the keyboard first. Everything a pointer can do, a keyboard has to do too, and a design that works without a mouse works with one.

</philosophy>

<decision_framework>

Which mechanism the case needs

What is being built?
├─ Something that acts when activated → <button>
│   └─ Icon only → add an accessible name
├─ Something that navigates → <a href>
│   └─ Current destination → aria-current="page"
├─ A structural region → <nav>, <main>, <header>, <aside>, <footer>
│   └─ More than one of a kind → label each with aria-label
├─ A complex widget — dialog, listbox, tabs, combobox, tree
│   └─ Take a tested primitive. The ARIA contract is large,
│      and a partial implementation reads as broken rather than absent.
└─ Content that appears without navigation
    ├─ An error or something urgent → role="alert"
    └─ Progress or confirmation → role="status"

ARIA or not: the first rule of ARIA is not to use ARIA. role="button" on a <div> gives a screen reader the right word and gives the keyboard nothing, so the handlers, tabindex and focus styling are all still owed. Reach for ARIA when no element expresses the thing — a live region, a disclosure's aria-expanded, a sort direction on a column header.

AA or AAA: AA is the compliance target and what regulation generally references. Individual AAA criteria are often cheap — 7:1 contrast, 44×44 targets — and worth taking where the design allows, without committing the whole product to AAA.

</decision_framework>


<patterns>

Core patterns

Pattern 1: Skip link

The first focusable element on the page, hidden until it takes focus, jumping past the navigation every page repeats.

<a href="#main-content" className="skip-link">Skip to main content</a>
…
<main id="main-content" tabIndex={-1}>{children}</main>

tabIndex={-1} on the target is what lets focus actually land there; without it the fragment moves the viewport and leaves focus at the top of the document.

Full code: examples/core.md

Pattern 2: Landmarks

Landmark elements are how a screen reader user jumps between regions rather than reading linearly.

<header>…</header>
<nav aria-label="Main">…</nav>
<main>…</main>
<aside aria-label="Related">…</aside>
<footer>…</footer>

One <main> per page. Where two landmarks share a type, each needs a distinguishing label — two unlabelled <nav>s are announced identically.

Full code: examples/core.md

Pattern 3: Button versus link

Activation is a button; navigation is a link. The distinction decides the keyboard behaviour, the announcement, and whether the browser's own affordances work.

<button onClick={handleSubmit}>Submit form</button>
<a href="/dashboard">Go to dashboard</a>

A <button> that navigates costs the reader "open in new tab", the status-bar URL and the middle click. A <div onClick> costs them the whole interaction.

Full code: examples/core.md

Pattern 4: Accessible names

Every control needs a name, and the icon-only case is where it goes missing.

<button aria-label="Delete item">
  <TrashIcon aria-hidden="true" />
</button>

<button>
  <TrashIcon aria-hidden="true" />
  <span className="sr-only">Delete item</span>
</button>

Both announce "Delete item, button". The second survives translation tooling and shows up in a text search of the DOM, so prefer it where either matters.

Resolution order is in reference.md; full code in examples/screen-reader.md.

Pattern 5: Form fields and errors

A label associated with the input, the invalid state on the input, and the message reachable from it.

<label htmlFor="email">Email</label>
<input
  id="email"
  type="email"
  aria-required="true"
  aria-invalid={Boolean(error)}
  aria-describedby={error ? "email-error" : undefined}
/>
{error && <span id="email-error" role="alert">{error}</span>}

A placeholder is not a label: it disappears the moment typing starts, and it fails contrast in most designs.

Full code: examples/forms.md

Pattern 6: Focus indicators

:focus-visible shows the ring for keyboard interaction and withholds it from a mouse click, which removes the reason people delete focus styles in the first place.

.button:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

At least 2px, and at least 3:1 against whatever sits behind it.

Full code: examples/focus.md

Pattern 7: Dialogs and focus movement

The contract a modal owes, whoever implements it: focus moves in on open, cannot leave while it is open, Escape closes it, and focus returns to the element that opened it.

<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
  <h2 id="dialog-title">{title}</h2>
  …
</div>

Focus returning to the trigger is the half most often missed — without it a keyboard user is dropped back at the top of the document with no idea where they were.

Full code: examples/focus.md

Pattern 8: Live regions

Content that arrives without a navigation is silent unless a live region announces it.

<div role="status">{savedMessage}</div>
<div role="alert">{errorMessage}</div>

role="status" waits for a pause; role="alert" interrupts. The region must be in the DOM before the message arrives — inserting the region and its content together often announces nothing.

Pattern 9: Colour-independent status

Colour is an accent on the signal, never the signal.

<span className={statusClass}>
  <span aria-hidden="true">{status === "error" ? "×" : "✓"}</span>
  <span>{status === "error" ? "Failed" : "Complete"}</span>
</span>

The icon carries it visually and the text carries it to a screen reader; remove the colour and both still work.

Full code: examples/color.md

Pattern 10: Motion preferences

Animate by opting in, so a reader who has asked for less motion gets it by default.

@media (prefers-reduced-motion: no-preference) {
  .card {
    animation: slide-in 300ms ease-out;
  }
}

reduce means minimise rather than eliminate — a fade in place of a slide keeps the feedback and drops the vestibular trigger.

</patterns>

<red_flags>

Red flags

Breaks at runtime:

  • outline: none with no replacement — keyboard users lose their position entirely; scope the ring to :focus-visible instead of removing it.
  • A <div> or <span> with an onClick — not focusable, not activated by Enter or Space, announced as nothing; use a <button>.
  • role="button" on a <div> — supplies the word and none of the behaviour, so Tab, Enter and Space are all still owed.
  • aria-hidden="true" on anything focusable — focus lands on an element the screen reader insists is not there.
  • tabindex above zero — hoists the element ahead of the whole document order, and one such value disorders the entire page.
  • An input with no associated label — announced by its type alone, so a form reads as "edit text, edit text, edit text".

Surprising behaviour:

  • alt="" is the correct value for a decorative image; omitting alt entirely makes a screen reader read the filename.
  • A live region added to the DOM at the same moment as its message usually announces nothing — render the container first.
  • A live region announces only the part that changed, so "3 of 10 results" updating in place can be read as "5" with no context; aria-atomic="true" makes it re-read the whole region.
  • role="alert" interrupts whatever is being read, so a per-keystroke validation message in one is unusable.
  • A placeholder is not a label, and disappears exactly when the reader needs it most.
  • Disabled buttons are skipped by focus, so a submit button disabled until the form is valid gives no route to the reason.
  • A <label> wrapping the input and a for/htmlFor both work; two labels on one input do not, and only one is announced.
  • prefers-reduced-motion: reduce asks for less, not none — removing essential feedback overshoots.
  • Automated audits find roughly half of WCAG failures. Everything about order, wording and whether the announcement made sense needs a person.

</red_flags>