SUMITSUBO

skill · sumi · v0.6.1

Accessibility — WCAG 2.2 AA

Accessibility to WCAG 2.2 AA as an acceptance criterion — semantics, keyboard and focus management, names and descriptions, contrast and non-color cues, motion, forms and errors, dynamic updates, target size, and testing with axe plus manual keyboard and screen reader passes. Use when building or reviewing any UI component, page, form, modal, menu, carousel or interactive widget in any stack, and before shipping visual work.

Accessibility is part of "done", not a later audit. Native HTML gets you most of the way (sumi:html); this skill covers the rest. Widget recipes are in references/patterns.md.

The five questions for every component

  1. What is it? Correct role: native element, or ARIA role that matches a known pattern. No ARIA is better than wrong ARIA.
  2. What is it called? Accessible name from visible text, <label>, aria-labelledby, or aria-label (last resort; must match visible text when there is some — WCAG 2.5.3).
  3. What state is it in? aria-expanded, aria-pressed, aria-selected, aria-current, aria-invalid, aria-busy, disabled — kept in sync with the UI.
  4. Can I operate it with a keyboard alone? Tab reaches it, Enter/Space activates, Esc dismisses, arrows move within composite widgets, focus is always visible and never trapped (except inside modals).
  5. Will I notice when it changes? Focus moves deliberately or a live region announces the change.

Keyboard and focus

  • Logical tab order = DOM order. Never tabindex > 0. tabindex="-1" only for programmatic focus targets.
  • Visible :focus-visible indicator (2.4.7). House rule, stricter than AA and aligned with AAA 2.4.13: ≥ 2px and ≥ 3:1 against adjacent colors. Never obscured by sticky headers (2.4.11 — use scroll-padding-top).
  • Modals: focus moves in on open, is contained, Esc closes, focus returns to the trigger. Native <dialog>.showModal() does this; otherwise apply inert to the rest of the page.
  • After route changes in SPAs, move focus to the main heading (or announce the page title) and update document.title.
  • After deleting or filtering items, place focus somewhere sensible (next item, list heading), never on <body>.
  • Hover-only content must also appear on focus, be dismissible (Esc) and hoverable (1.4.13).

Perceivable

  • Text contrast ≥ 4.5:1 (≥ 3:1 for ≥ 24px or ≥ 18.66px bold); UI component boundaries, icons and focus rings ≥ 3:1. Check every token pair in light and dark themes, including text on images and gradients.
  • Never color alone for meaning (errors, links in body text, chart series, status): add text, icons or patterns.
  • Text resizes to 200% and reflows at 320 CSS px with no loss (1.4.10). No text in images.
  • Media: captions for video, transcripts for audio, no autoplay with sound, pause control for anything moving > 5 s (2.2.2).
  • Motion: honor prefers-reduced-motion; nothing flashes more than 3 times per second.

Operable (WCAG 2.2 additions to remember)

  • Target size ≥ 24×24 CSS px or sufficient spacing (2.5.8); aim for 44×44 on touch-first UI.
  • Dragging has a single-pointer alternative (2.5.7): reorder lists also via buttons or keyboard.
  • Focus not obscured by sticky bars or cookie banners (2.4.11).
  • Consistent help: contact/help links in the same place across pages (3.2.6).
  • Redundant entry: don't ask for the same info twice in a flow; autofill it (3.3.7).
  • Accessible authentication: no cognitive tests; allow paste and password managers (3.3.8).

Forms

  • Labels visible and programmatically associated; required fields indicated in text (not only *).
  • Errors: describe the problem and the fix in text, link via aria-describedby, set aria-invalid="true", and on submit move focus to an error summary or the first invalid field.
  • Group radios/checkboxes with fieldset/legend. Use autocomplete tokens for personal data.
  • Don't validate on every keystroke with announcements; validate on blur/submit.

Dynamic content

  • Status messages ("Added to cart", "3 results") via a persistent role="status" (polite) region that exists before the update. role="alert" only for urgent errors.
  • Loading: aria-busy="true" on the updating region and a text status; skeletons are aria-hidden.
  • Infinite scroll needs a reachable footer and a "load more" alternative.

Content

  • One <h1>, no skipped levels, descriptive headings and link text ("View pricing plans", not "Click here"; repeated "Read more" gets context via visually hidden text).
  • Images: alt describes purpose. Decorative → alt="". Functional (inside links/buttons) → describe the action. Complex (charts) → short alt + longer text nearby.
  • Language set on <html> and on passages in another language (lang attribute).
  • Visually hidden utility (.sr-only) for extra context; never hide focusable elements with it.

Testing (required evidence)

  1. Automated: axe (browser extension, @axe-core/playwright, or Lighthouse a11y) → 0 critical/serious on affected views. Automation catches ~30–40%; it is the floor, not the bar.
  2. Keyboard pass: complete the main task with Tab/Shift+Tab/Enter/Space/Esc/arrows only; focus always visible and logical.
  3. Zoom/reflow: 200% zoom and 320px width.
  4. Screen reader smoke test for new widgets: VoiceOver (Safari) or NVDA (Firefox/Chrome) — name, role and state announced correctly.
  5. Contrast: check new token pairs (sumi-design contrast.mjs).

Slop tells

  • aria-label on everything, often duplicating visible text or contradicting it.
  • role="menu" for site navigation (use a disclosure pattern); role="button" on divs.
  • outline: none with no replacement; focus lost after closing a modal.
  • Icon-only buttons with no accessible name; "Click here" links.
  • Carousels that autoplay without pause; toasts that vanish before they can be read.

Done checklist

  • Five questions answered for every new interactive element.
  • axe clean (0 serious/critical) on affected views — evidence recorded.
  • Full keyboard path works; focus visible, logical, never lost or obscured.
  • Contrast verified for all new color pairs in all themes.
  • Reduced motion, 200% zoom and 320px reflow checked.

Accessible widget recipes

Use the native element when one exists. These recipes cover the rest. Keyboard columns list required keys.

Disclosure (show/hide, FAQ, mobile nav toggle)

  • Prefer <details><summary>. Custom: <button aria-expanded="false" aria-controls="panel-id"> + panel with hidden.
  • Keys: Enter/Space toggles. Optional Esc closes when the panel overlays content.

Accordion

  • Each header is a heading containing a button (<h3><button aria-expanded aria-controls>). Panels optionally role="region" + aria-labelledby (only when few panels).
  • Exclusive groups: native <details name="group">.

Site navigation with dropdowns

  • <nav aria-label="Main"> + list of links; submenus toggled by a <button aria-expanded> next to (or as) the parent item. NOT role="menu" / menubar — those are for application menus.
  • Keys: Tab moves through items; Enter/Space opens; Esc closes and returns focus to the toggle. Close on focus leaving the submenu.
  • Mark the current page with aria-current="page".

Tabs

  • role="tablist" (labelled) > role="tab" buttons with aria-selected, aria-controls; panels role="tabpanel" + aria-labelledby, tabindex="0" if the panel has no focusable content.
  • Roving tabindex: only the active tab is in the tab order. Keys: ←/→ move (wrap), Home/End, Enter/Space activates (or automatic activation if panels are cheap).
  • If tabs change the URL or are long content sections, consider plain links + headings instead.

Modal dialog / drawer (cart, filters, menus on mobile)

  • <dialog> + showModal(); label with aria-labelledby to its heading. Close button with an accessible name.
  • On open: focus the first meaningful control (or the dialog heading for content-heavy dialogs). On close: return focus to the trigger.
  • Background is inert (native with showModal). Body scroll locked. Esc closes. Avoid nested modals.
  • Drawers are dialogs that slide; the motion must respect reduced motion.

Non-modal popover (tooltips, quick menus)

  • popover attribute: popover="auto" for light-dismiss menus, popover="hint"/manual for tooltips where supported.
  • Tooltips: trigger has aria-describedby to the tooltip text; show on hover AND focus; Esc hides; never put interactive content in a tooltip.

Menu button (actions menu, not navigation)

  • <button aria-haspopup="menu" aria-expanded> + role="menu" with role="menuitem" children. Keys: ↑/↓, Home/End, Enter activates, Esc closes, type-ahead optional. Only for app-like action lists.

Combobox / autocomplete / predictive search

  • Input role="combobox" aria-expanded aria-controls="listbox-id" aria-autocomplete="list"; popup role="listbox" with role="option" items; aria-activedescendant tracks the highlighted option while focus stays in the input.
  • Keys: ↓/↑ move, Enter selects, Esc closes (second Esc clears), typing filters. Announce result counts in a polite live region.
  • Prefer a well-tested library (React Aria, Headless UI, Radix, Reka UI) for complex comboboxes.

Carousel / slider of content

  • Container role="region" + aria-roledescription="carousel" + label; slides role="group" + aria-roledescription="slide" + "n of N" labels.
  • Previous/next buttons with names; visible pause/play if it autoplays (prefer no autoplay). Pause on hover and on focus within.
  • Off-screen slides are inert or hidden from AT. Scroll-snap based carousels must keep keyboard access to every slide.

Range slider

  • Native <input type="range"> with label and aria-valuetext for human values ("$40"). Dual-thumb price filters: two range inputs or a tested library, plus number inputs as alternative.

Toast / snackbar / status

  • Persistent role="status" container in the DOM from page load; inject message text. Errors use role="alert".
  • Stay ≥ 5 s plus reading time, pause on hover/focus, dismiss button for persistent ones. Never put the only path to an action inside a toast.

Product card (e-commerce)

  • <article> labelled by the product title. One primary link (the title) stretched over the card with a pseudo-element; secondary actions (quick add, wishlist) are real buttons placed above the stretched link in stacking order, visible on focus as well as hover.
  • Price: sale + original announced clearly ("Sale price $40, regular price $60"), not only strikethrough.

Filters (faceted search)

  • Each facet: disclosure button + fieldset/legend of checkboxes/radios. Apply immediately with a polite live region announcing the result count, or with an explicit Apply button on mobile drawers.
  • Active filters listed as removable buttons ("Remove filter: Blue").

Data table

  • Real <table> with <caption>, <th scope="col|row">. Sortable headers: button inside <th> + aria-sort on the active column. Responsive: horizontal scroll container with tabindex="0", role="region" and a label — don't turn tables into div grids.

Skip link

  • First focusable element; visible on focus; targets <main id="main" tabindex="-1">.