# Accessible Components

> Use when building or fixing an interactive component — dialog, menu, tabs, combobox, tooltip — or handling focus, ARIA, or keyboard support.

- Skill: `yogvidwankhede/accessible-components` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add yogvidwankhede/accessible-components`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yogvidwankhede/accessible-components/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: yogvidwankhede (https://skillmd.com/u/yogvidwankhede)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yogvidwankhede/accessible-components

---


<!--
  Generated by Vishwakarma. Do not edit this file directly.
  Edit the source skill and run `vishwakarma sync` to regenerate.
-->

# Accessible Components

Accessibility failures in component code are not distributed randomly. Almost all come from
four places: rebuilding something the browser already provides, mishandling focus, naming
things wrongly, and failing to announce change. Everything below is organised around those.

The mental model that prevents most of it: **ARIA changes what a component is announced as,
and changes nothing about what it does.** Adding `role="button"` to a div does not make it
focusable, does not make Enter or Space activate it, and does not give it a disabled state.
You have promised the assistive technology a button and delivered a rectangle.

---

## 1. Use the native element

`<button>` gives you, free: tab-order membership, Enter and Space activation, the `button`
role, a name from its own text content, a user-agent `:focus-visible` indicator, `disabled`
semantics that both remove it from the tab order and announce it as unavailable, form
submission via `type="submit"`, forced-colors rendering, and touch-and-explore activation
on mobile screen readers, which does not fire a click on arbitrary divs.

Rebuilding that on a div takes `role="button"`, `tabindex="0"`, a keydown handler for both
Enter and Space with `preventDefault` on Space to stop page scroll, `aria-disabled` plus
your own logic to actually block activation, and a hand-written focus ring. Six things to
get right and keep right, in exchange for nothing. The same holds for `<a href>`,
`<input type="checkbox">`, `<details>`, and `<dialog>` with `showModal()`, which supplies
top-layer stacking, background inertness, Escape via the `cancel` event, and focus
restoration to the previously focused element, all specified in HTML.

Reach for ARIA only when the platform has no equivalent — menu buttons, tabs, custom
comboboxes, tree views. Then implement the full pattern, not a fragment of it.

## 2. Focus management is the hard part

Every composite widget needs a decision about where focus lives. There are two correct
mechanisms.

**Roving tabindex**: exactly one item in the group carries `tabindex="0"`, every sibling
carries `tabindex="-1"`, and arrow keys move both DOM focus and the `0`. Use it whenever
the focused item is a real element you can focus — tabs, toolbars, menus, trees, radio
groups. It is the more robust option because the browser handles the focus event, the
scrolling, and the ring itself.

**`aria-activedescendant`**: DOM focus stays on a container or text input, whose
`aria-activedescendant` points at the id of the virtually focused option. Use it when focus
must stay in a text field while a list is navigated — comboboxes, and little else. Because
nothing actually moved, the browser will neither scroll the active option into view nor
draw a ring on it. A listbox that does not call `scrollIntoView({ block: 'nearest' })` is
one whose selection silently walks off the bottom of the visible area.

Modal dialogs additionally require a **trap**: Tab from the last tabbable element wraps to
the first, Shift+Tab from the first wraps to the last, and outside content is neither
focusable nor reachable by the screen-reader virtual cursor. Use `<dialog>.showModal()`, or
`inert` on the sibling content (Baseline since 2023). Do not use `aria-hidden` on the
background while leaving it focusable — that is the worst state available, where a keyboard
user tabs into elements the screen reader refuses to describe.

On close, **return focus to the element that opened the dialog**. If it no longer exists —
deleted row, dismissed card — focus its nearest surviving container with `tabindex="-1"`.
Focus falling to `<body>` sends a screen-reader user back to the top of the document with
no explanation.

Initial focus goes to the first meaningful control, or — for dialogs with substantial
reading content — to the heading with `tabindex="-1"`, so the title is announced first.
Never autofocus a destructive action.

## 3. Names, and the aria-label trap

Accessible names are computed in a fixed precedence: `aria-labelledby` wins, then
`aria-label`, then the host-language mechanism (`<label>`, `alt`, `<legend>`), then the
element's own text content for roles permitting name-from-content, then `title` as a last
resort. Each level silently suppresses everything below it, which is what makes
`aria-label` dangerous: it overrides visible text.

A button reading "Save" with `aria-label="Save changes to your profile"` is named something
no user can see. Speech-input users say what they see, so "click Save" now matches nothing.
WCAG 2.2 SC 2.5.3 (Label in Name, Level A) requires the accessible name to contain the
visible label text; if you must extend a name, start it with the visible string.

Two more naming failures: `aria-label` is ignored on elements with the `generic` role (a
plain `<div>` or `<span>`), so labelling a wrapper does nothing; and `aria-labelledby`
pointing at a missing id fails silently, leaving the component unnamed — invisible in code
review, immediate in a screen reader.

## 4. Live regions announce nothing if you create them late

A live region must exist in the accessibility tree **before** its contents change. Screen
readers register the region on insertion and then report mutations to it, so inserting the
container and its text in the same tick usually announces nothing. Render an empty
`<div role="status">` on mount, then write text into it.

`aria-live="polite"` (equivalently `role="status"`) queues behind current speech and is
correct for almost everything: save confirmations, result counts, loading completion.
`aria-live="assertive"` (`role="alert"`) interrupts mid-word and is only for information
the user must act on immediately.

Other reasons regions go silent: the region is `display: none` (use a clip-based
visually-hidden class); the same string is written twice, which most screen readers suppress
as unchanged; or the whole subtree is replaced rather than its text mutated.

For validation errors, wire `aria-describedby` from the field to its message and move focus
to the first invalid field — more reliable than a summary the user cannot navigate to.

## 5. The WCAG 2.2 criteria component libraries actually fail

- **2.4.7 Focus Visible** (AA) — `outline: none` with no replacement. Removing the default
  ring obliges you to supply one on `:focus-visible`.
- **1.4.11 Non-text Contrast** (AA) — the focus ring, and boundaries such as input borders
  and unchecked checkboxes, need 3:1 against adjacent colours. A subtle grey border on
  white is a failure.
- **2.4.11 Focus Not Obscured (Minimum)** (AA, new in 2.2) — sticky headers and cookie bars
  covering the focused element. Add `scroll-padding-top` equal to the sticky header height.
- **2.5.8 Target Size (Minimum)** (AA, new in 2.2) — pointer targets must be at least
  24 by 24 CSS pixels, or spaced so 24px circles centred on them do not intersect.
  Icon-only buttons and table row actions are the usual offenders.
- **1.4.13 Content on Hover or Focus** (AA) — tooltips and hover cards must be
  **dismissible** with Escape without moving the pointer, **hoverable** (the pointer can
  travel into the bubble without it vanishing), and **persistent** until dismissed. A
  tooltip that disappears on mouseout of the trigger fails.
- **3.2.6 Consistent Help** (A, new in 2.2) — help affordances appear in the same relative
  order on every page that has them.

## 6. Test with a real screen reader

Automated engines check what is statically expressible in the DOM: missing alt text,
contrast of solid colours, invalid ARIA values, duplicate ids. They structurally cannot
evaluate whether focus went somewhere sensible, whether an announcement was comprehensible,
or whether the keyboard model matches the role advertised — which is where most real
defects live.

The minimum manual pass: unplug the mouse and operate the component end to end, then run it
with VoiceOver and Safari, or NVDA and Firefox. Nearly every serious defect surfaces in the
first two minutes.

## Rules

### MUST NOT — Do not attach click or key handlers to a div, span, or other non-interactive element in order to build a control.

*Why:* A generic element is not in the tab sequence, has no role, does not respond to Enter or Space, and is not activated by the double-tap gesture mobile screen readers use, so the control is unreachable by keyboard and by touch exploration regardless of how it looks.

*Exceptions:*
- Container-level handlers that delegate to genuinely interactive descendants, where the descendant is the control.

### MUST NOT — Never apply aria-hidden="true" to an element that is focusable or that contains focusable content.

*Why:* aria-hidden removes an element from the accessibility tree but not from the tab sequence, producing an element a keyboard user can focus and a screen reader refuses to describe — silence with no way to recover context. Use inert, or remove the element, instead.

*Source:* [WAI-ARIA 1.2, aria-hidden](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden)

### MUST NOT — Do not rely on aria-label or aria-labelledby applied to a plain div or span with no role.

*Why:* Naming is prohibited on elements mapped to the generic role, so browsers discard the attribute entirely. The markup looks labelled in review and is unnamed at runtime, which is why this failure survives code review reliably.

*Source:* [ARIA in HTML, naming prohibited roles](https://www.w3.org/TR/html-aria/)

### MUST — Use the native HTML element for any behaviour the platform already provides, and reach for ARIA only when no native equivalent exists.

*Why:* Native interactive elements ship focusability, key handling, role, name computation, disabled semantics, forced-colors rendering, and mobile touch-exploration activation as one package that stays correct without maintenance. ARIA supplies only the announced role, so every other behaviour must be reimplemented and kept correct forever.

*Source:* [WAI-ARIA Authoring Practices, first rule of ARIA use](https://www.w3.org/TR/using-aria/)

Incorrect:

```tsx
<div className="btn" onClick={save}>Save</div>
```

Correct:

```tsx
<button type="button" className="btn" onClick={save}>Save</button>
```

### MUST — When implementing an ARIA pattern, implement its complete keyboard contract, not a subset.

*Why:* The announced role sets the user expectation. A user told they are in a menu will press Down Arrow, Escape, and Home; if only Enter is wired, the component is less usable than an unstyled list, because it has promised a model it does not honour.

### MUST — Escape must dismiss any transient overlay — dialog, menu, popover, tooltip, combobox popup — and return focus to the element that opened it.

*Why:* Escape is the only universally learned exit gesture, and for keyboard-only users an overlay with no keyboard dismissal is a trap. Returning focus to the trigger preserves the user position in the document, which is otherwise unrecoverable without spatial cues.

*Source:* [WCAG 2.2 SC 2.1.2 No Keyboard Trap](https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html)

### MUST — Trap Tab and Shift+Tab inside a modal dialog and make the background inert while it is open.

*Why:* A modal asserts that nothing behind it is available. If focus escapes, the user operates controls they cannot see, with no indication of where they are, and the visible overlay makes the focused element impossible to locate.

Incorrect:

```html
<div aria-hidden="true" id="app">…</div>
<div role="dialog">…</div>
```

Correct:

```html
<div inert id="app">…</div>
<dialog aria-modal="true" aria-labelledby="t">…</dialog>
```

### MUST — Restore focus to the invoking element when an overlay closes, falling back to a surviving ancestor if that element no longer exists.

*Why:* When focus is not restored it defaults to document.body, which resets a screen reader virtual cursor to the top of the page and resets a keyboard user tab position to the first link. Neither user is told this happened, so the loss of place is silent.

### MUST — A composite widget must be a single tab stop, with exactly one descendant at tabindex="0" and all others at tabindex="-1".

*Why:* Tab moves between widgets and arrow keys move within them. If every item is tabbable, a fifty-item tree costs fifty Tab presses to pass, and the arrow-key model the role advertises becomes redundant.

*Exceptions:*
- Lists of links or ordinary form fields, which are not composite widgets and correctly place each element in the tab sequence.

Incorrect:

```tsx
tabs.map((t) => <button role="tab" key={t.id}>{t.label}</button>)
```

Correct:

```tsx
tabs.map((t) => <button role="tab" key={t.id} tabIndex={t.id === activeId ? 0 : -1}>{t.label}</button>)
```

### MUST — When using aria-activedescendant, scroll the active option into view and style it explicitly, and keep the referenced id valid on every list change.

*Why:* aria-activedescendant moves virtual focus only. No focus event fires, so the browser neither scrolls the option into view nor matches :focus against it, and a stale reference to a filtered-out option leaves the widget with no discoverable active item.

Incorrect:

```ts
input.setAttribute('aria-activedescendant', option.id)
```

Correct:

```ts
input.setAttribute('aria-activedescendant', option.id)
option.scrollIntoView({ block: 'nearest' })
```

### MUST — Every focusable element must show a focus indicator with at least 3:1 contrast against both the component and the adjacent background.

*Why:* The focus indicator is the only signal a sighted keyboard user has for their position in the document. Removing the user-agent outline without replacing it makes the interface unusable without a mouse; an indicator below 3:1 is present in the DOM but not perceivable.

*Source:* [WCAG 2.2 SC 2.4.7 Focus Visible (AA) and SC 1.4.11 Non-text Contrast (AA)](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html)

Incorrect:

```css
.btn:focus { outline: none; }
```

Correct:

```css
.btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
```

### MUST — Ensure a focused element is never entirely hidden behind sticky headers, footers, or floating panels.

*Why:* Scrolling by focus does not account for author-created overlays, so a sticky header can cover the element the browser just scrolled to. Setting scroll-padding on the scroll container equal to the overlay height makes the browser reserve that space.

*Source:* [WCAG 2.2 SC 2.4.11 Focus Not Obscured (Minimum), Level AA](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html)

Incorrect:

```css
header { position: sticky; top: 0; height: 64px; }
```

Correct:

```css
header { position: sticky; top: 0; height: 64px; }
html { scroll-padding-top: 64px; }
```

### MUST — Content revealed on hover or focus must be dismissible with Escape, hoverable without disappearing, and persistent until dismissed or invalidated.

*Why:* Screen magnifier users must move the pointer into the revealed content to read text that extends beyond their viewport; content that hides on mouseout of the trigger is unreadable to them, and content that cannot be dismissed can permanently obscure what is beneath it.

*Source:* [WCAG 2.2 SC 1.4.13 Content on Hover or Focus, Level AA](https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html)

Incorrect:

```tsx
<span onMouseEnter={show} onMouseLeave={hide}>?</span>
```

Correct:

```tsx
<button aria-describedby="tip" onMouseEnter={show} onMouseLeave={scheduleHide} onFocus={show} onBlur={hide}>?</button>
```

### MUST — The accessible name of a control must contain its visible label text, in the same order.

*Why:* Speech-input users issue commands by speaking the label they can see. When aria-label replaces rather than extends the visible text, the spoken command matches nothing, and the control becomes operable only by pointer.

*Source:* [WCAG 2.2 SC 2.5.3 Label in Name, Level A](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html)

Incorrect:

```tsx
<button aria-label="Submit your application">Send</button>
```

Correct:

```tsx
<button aria-label="Send application">Send</button>
```

### MUST — Render a live region container into the DOM before the content it will announce changes, rather than inserting the region and its message together.

*Why:* Assistive technology registers live regions when they enter the accessibility tree and then reports subsequent mutations. A region inserted in the same update as its text has no prior state to be compared against, so in most screen readers nothing is announced at all.

*Source:* [WAI-ARIA 1.2, live region attributes](https://www.w3.org/TR/wai-aria-1.2/#aria-live)

Incorrect:

```tsx
{message && <div role="status">{message}</div>}
```

Correct:

```tsx
<div role="status" className="sr-only">{message}</div>
```

### MUST — Associate validation messages with their field using aria-describedby, and set aria-invalid on the field when it is in error.

*Why:* A screen reader reads a field name, role, value, and description on focus. An error rendered as a nearby paragraph is not part of that computation, so a user tabbing to the field hears no indication that anything is wrong.

*Source:* [WCAG 2.2 SC 3.3.1 Error Identification, Level A](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html)

Incorrect:

```tsx
<input id="email" />
<p className="error">Enter a valid email</p>
```

Correct:

```tsx
<input id="email" aria-invalid="true" aria-describedby="email-err" />
<p id="email-err" className="error">Enter a valid email</p>
```

### MUST — Operate every new interactive component with the keyboard alone and with at least one screen reader before declaring it complete.

*Why:* Automated rule engines evaluate statically expressible properties of the DOM. They cannot judge whether focus landed somewhere sensible, whether an announcement was comprehensible, or whether a keyboard model matches the role advertised — which is where the majority of component defects live.

### SHOULD NOT — Do not use aria-live="assertive" or role="alert" for routine confirmations, toasts, or progress updates.

*Why:* Assertive regions interrupt speech in progress, discarding whatever the user was in the middle of hearing. Used for non-urgent updates, they make the interface unusable by repeatedly cutting off the content the user is trying to read.

*Exceptions:*
- Information requiring immediate action, such as a session about to expire or a failed submission that discards user input.

### SHOULD — Give every pointer target a hit area of at least 24 by 24 CSS pixels, or space undersized targets so that 24px circles centred on them do not intersect.

*Why:* Pointer accuracy varies with motor control, tremor, and input device. Below roughly 24 CSS pixels, mis-taps rise sharply and adjacent-target activation becomes likely, which is why the criterion permits spacing as an alternative to size.

*Source:* [WCAG 2.2 SC 2.5.8 Target Size (Minimum), Level AA](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)

*Exceptions:*
- Targets inline within a sentence, whose size is constrained by the line-height of surrounding text.
- Targets whose size is determined by the user agent and not modified by the author.
- An equivalent control meeting the size requirement exists elsewhere on the same page.

### SHOULD — Use a native select element for simple option lists, and build a custom listbox or combobox only when filtering, multi-column options, or rich option content is genuinely required.

*Why:* A native select delegates rendering to the operating system, which supplies platform-correct touch, keyboard, typeahead, and screen-reader behaviour on every device, including mobile pickers that no custom implementation reproduces.

*Exceptions:*
- Options requiring search, grouping with descriptions, images, or multi-select with tokens.

## Before reporting completion

Run these checks against your own output. Answer each question explicitly rather than
assuming the answer, because the point of the exercise is to notice what you did not
notice while building.

### Confirm the component satisfies its keyboard contract. (blocking)

- Which ARIA pattern does this component claim to be, and have you implemented every required key in that pattern?
- Can every action performed with the mouse also be performed with the keyboard alone?
- Is the widget a single tab stop with arrow-key navigation inside it, or does Tab step through every item?
- Does Escape dismiss every transient layer this component can open?

### Confirm focus is owned, trapped, and restored correctly. (blocking)

- Where does focus go when this component opens, and why is that the right place?
- Can Tab or Shift+Tab escape an open modal to content behind it?
- Where does focus go when it closes, and what happens if that element was removed while it was open?
- After every interaction, would document.activeElement ever be body?
- If aria-activedescendant is used, is the active option scrolled into view and visibly styled?

### Confirm every control is correctly named. (blocking)

- Does every interactive element have a non-empty accessible name?
- For each aria-label, does the name begin with the visible label text?
- Does every aria-labelledby and aria-describedby reference an id that exists in the rendered DOM?
- Is any aria-label applied to a div or span that has no role?
- Do icon-only buttons have names describing the action rather than the icon?

### Confirm dynamic changes are announced.

- Is the live region container present in the DOM before the first message is written into it?
- Is the region hidden with a clipping technique rather than display:none or visibility:hidden?
- Is anything assertive that is not genuinely urgent?
- If the same message can occur twice in a row, will the second occurrence be announced?

### Check the WCAG 2.2 criteria component libraries most often fail. (blocking)

- 2.4.7 and 1.4.11: does every focus indicator reach 3:1 against both the component and the surrounding background?
- 2.4.11: can a sticky header or footer completely cover a focused element at any scroll position?
- 2.5.8: is any pointer target smaller than 24 by 24 CSS pixels without qualifying spacing?
- 1.4.13: is hover or focus content dismissible with Escape, hoverable, and free of timeouts?
- 1.4.11: do input borders, unchecked control outlines, and meaningful icons reach 3:1?

### Confirm a manual assistive-technology pass was performed.

- Was the component operated end to end with the keyboard only?
- Was it operated with a screen reader, and did the announced role match what it actually does?
- Was every state change — expansion, selection, error, completion — announced?
- Did the automated checker pass, and which categories of defect could it not have detected here?

### Run the project accessibility linter or axe test suite. Passing is necessary but far from sufficient.

```bash
npm run test:a11y
```

## Further reference

These are not loaded by default. Read one only when its question is the question you
currently have.

- `references/keyboard-contracts.md` — Exactly which keys must do what, and which ARIA attributes are required, for each composite widget a component library ships?
- `references/focus-recipes.md` — How do I actually implement a focus trap, restore focus safely, choose between roving tabindex and aria-activedescendant, and handle focus on route changes and deletions?

