ARIA Patterns skill for Codex

Widgets screen readers announce correctly. Codex reaches for native HTML first (dialog, popover, details, inert) and only adds ARIA where a custom widget truly needs it. When it does, it follows the APG pattern exactly: the right roles and states, a written keyboard map, focus that returns where it should, and live regions that exist before they change. It also knows when role=menu is the wrong choice.

  • Frontend
  • $aria-patterns
  • 187 lines
  • 2 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- aria-patterns

Installs to ~/.agents/skills/aria-patterns/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $aria-patterns, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Implements interactive widgets to the WAI-ARIA Authoring Practices (APG) - modal dialogs with native dialog, disclosure, menu button, tabs with roving tabindex, combobox with listbox and aria-activedescendant, accordion, tooltip, toggle buttons and aria-live status messages - with the exact roles, states and keyboard map for each. Use when building or fixing a custom dropdown, autocomplete, tabs, modal, menu or toast, when a screen reader announces a widget wrongly, when arrow keys or Escape do not work, or when deciding whether ARIA is needed at all.

Use it when

  • Building a custom dropdown, autocomplete, tabs, accordion or modal
  • A widget's keyboard behavior or screen reader announcement is wrong
  • Deciding between native HTML elements and ARIA roles

Use something else when

How to check the result

Exercise the documented keyboard map, inspect the accessibility tree for role, name and state, and listen with VoiceOver or NVDA for announcements on focus and state change.

The mistake it prevents

Using role=menu for site navigation or adding a live region at the same moment as its text, so announcements never happen and links become unreachable.

~/.agents/skills/aria-patterns/SKILL.md

--- name: aria-patterns description: Implements interactive widgets to the WAI-ARIA Authoring Practices (APG) - modal dialogs with native dialog, disclosure, menu button, tabs with… license: MIT ---

Done well means: native HTML is used wherever it exists; where a custom widget is needed, its role, accessible name, states and keyboard behavior match the APG pattern exactly, states update in the same tick as the visuals, and the result is confirmed with a real screen reader.

Rule zero: prefer native

NeedUse firstCustom ARIA only when
Button<button type="button">Never use div role="button"
Modal dialog<dialog> + showModal()Must support very old browsers
Non-modal popup (menu panel, picker)popover attributePositioning or behavior needs exceed it
Expand/collapse<details>/<summary> or button + aria-expanded
Select one from short list<select> (customizable select is arriving in Chromium)Rich option content or filtering needed
Toggle setting<input type="checkbox"> (style it, or role="switch" on it)
Inert backgroundinert attribute

No ARIA is better than wrong ARIA: a wrong role overrides correct native semantics.

Workflow

  1. Read AGENTS.md; check if the project already has a headless library (Radix, React Aria, Headless UI, Ark). If so, use it ($radix-ui) instead of hand-rolling.
  2. Identify the APG pattern from the table below; write the keyboard map as a comment above the component before coding.
  3. Implement roles and relationships, then keyboard, then state updates, then focus management.
  4. Test with keyboard, then a screen reader (VoiceOver + Safari, NVDA + Chrome or Firefox).

Pattern quick reference

PatternRoles / attributesKeys
Disclosurebutton[aria-expanded][aria-controls]Enter, Space toggle
Modal dialogdialog (native) or role=dialog aria-modal=true, aria-labelledbyTab cycles inside, Escape closes, focus returns to opener
Menu button (actions)button[aria-haspopup=menu][aria-expanded] → role=menu → role=menuitemEnter/Space/Down open + focus first; Up/Down move; Home/End; Escape closes to button; type-ahead
Tabsrole=tablist → role=tab[aria-selected][aria-controls], role=tabpanel[aria-labelledby]Left/Right move (wrap); Home/End; Tab moves into panel
Accordionheading > button[aria-expanded][aria-controls], region optionalEnter/Space; Tab between headers
Combobox (autocomplete)input[role=combobox][aria-expanded][aria-controls][aria-autocomplete=list][aria-activedescendant] → role=listbox → role=option[aria-selected]Down opens/moves, Up moves, Enter selects, Escape closes then clears, Alt+Down opens
Listboxrole=listbox[aria-multiselectable] → role=option[aria-selected]Arrows, Home/End, Space toggles (multi)
Tooltiptrigger aria-describedby → role=tooltipEscape dismisses; shows on focus and hover
Toggle buttonbutton[aria-pressed]Enter, Space
Switchrole=switch[aria-checked] (on button or checkbox)Space (and Enter on button)
Status messagerole=status (polite) or role=alert (assertive)none

“Menu” in ARIA means an application-style action menu. Site navigation is a nav with a list of links and disclosure buttons, not role=menu.

<button type="button" id="open-settings">Settings</button>
<dialog id="settings" aria-labelledby="settings-title">
  <h2 id="settings-title">Settings</h2>
  <form method="dialog">
    <!-- fields -->
    <button value="cancel">Cancel</button>
    <button value="save">Save</button>
  </form>
</dialog>
<script>
  const dlg = document.getElementById("settings");
  document.getElementById("open-settings").addEventListener("click", () => dlg.showModal());
  // closedby="any" enables light dismiss where supported; Escape works natively
</script>

showModal() gives the top layer, inert background, Escape to close and focus return to the opener in current browsers. Set autofocus on the element that should get initial focus (first field, or the least destructive button in a confirm). Do not use dialog.show() for modals; it is non-modal.

Tabs with roving tabindex

function Tabs({ tabs }: { tabs: { id: string; label: string; panel: ReactNode }[] }) {
  const [active, setActive] = useState(0);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const move = (i: number) => {
    const next = (i + tabs.length) % tabs.length;
    setActive(next);
    refs.current[next]?.focus();
  };
  return (
    <>
      <div role="tablist" aria-label="Account sections"
        onKeyDown={(e) => {
          if (e.key === "ArrowRight") move(active + 1);
          else if (e.key === "ArrowLeft") move(active - 1);
          else if (e.key === "Home") move(0);
          else if (e.key === "End") move(tabs.length - 1);
          else return;
          e.preventDefault();
        }}>
        {tabs.map((t, i) => (
          <button key={t.id} ref={(el) => { refs.current[i] = el; }}
            role="tab" id={`tab-${t.id}`} aria-selected={i === active}
            aria-controls={`panel-${t.id}`} tabIndex={i === active ? 0 : -1}
            onClick={() => setActive(i)}>
            {t.label}
          </button>
        ))}
      </div>
      {tabs.map((t, i) => (
        <div key={t.id} role="tabpanel" id={`panel-${t.id}`}
          aria-labelledby={`tab-${t.id}`} hidden={i !== active} tabIndex={0}>
          {t.panel}
        </div>
      ))}
    </>
  );
}

Selection follows focus here (automatic activation); if switching panels is slow (data fetch), use manual activation: arrows move focus only, Enter/Space selects.

Combobox essentials

  • Focus stays in the input; the highlighted option is conveyed with aria-activedescendant="opt-3" on the input, and option elements need stable ids.
  • aria-expanded reflects whether the listbox is visible; keep the listbox in the DOM with hidden when closed so aria-controls points at something.
  • Announce result counts in a separate polite live region (“8 results”), debounced.
  • Never move DOM focus into the options; clicking an option must not blur-close the list before the click registers (use mousedown + preventDefault on the listbox).
  • Prefer a tested implementation (React Aria useComboBox, Downshift, Ariakit) for production unless the project bans dependencies.

Live regions

<div id="status" role="status" aria-live="polite" class="visually-hidden"></div>
function announce(msg) {
  const el = document.getElementById("status");
  el.textContent = "";
  requestAnimationFrame(() => { el.textContent = msg; });
}
  • The region must exist in the DOM before its content changes; injecting a new element that already contains text is often not announced.
  • role="alert" interrupts; reserve it for errors that need immediate attention.
  • Do not wrap whole lists or chat logs in aria-live without role="log"; every re-render would be read.

Accessible names

Priority order: visible text content, then aria-labelledby (references visible text), then aria-label (invisible string, not translated by some tools), then title (avoid). Icon-only buttons need aria-label; the visible label must be contained in the name (WCAG 2.5.3).

Gotchas

  • aria-hidden="true" on an ancestor of a focused element hides the focus from screen readers. Use inert for backgrounds instead.
  • aria-expanded belongs on the button that controls the popup, not on the popup.
  • aria-selected is for tabs, options and grid cells; aria-pressed for toggle buttons; aria-checked for checkboxes, radios, switches, menuitemcheckbox. Mixing them confuses AT.
  • role="menu" makes screen readers enter a mode where only arrow keys work; links inside a nav “menu” become unreachable by Tab. Do not use it for navigation.
  • Positive tabindex values break tab order; only 0 and -1.
  • Hiding with display: none or hidden removes from AT; visibility: hidden too; visually hidden text needs a clip-based utility class.

Verification

  • Keyboard map from the table works exactly, including Home/End and Escape.
  • Screen reader announces role, name and state on focus, and state changes when toggled (e.g. “expanded”, “selected, 2 of 4”).
  • Focus is never lost to body after opening, closing or deleting something.
  • Accessibility tree in DevTools (Elements > Accessibility) shows the intended role and name.
  • axe reports nothing for the open and closed states ($accessibility-testing).

Output

For each widget: the APG pattern used, keyboard map implemented, ARIA attributes and which state drives each, focus management on open/close, and screen reader(s) tested.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "ARIA Patterns"
  short_description: "Widgets screen readers announce correctly"
  default_prompt: "$aria-patterns Make our custom autocomplete accessible with proper combobox ARIA"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem