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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- aria-patterns Installs to ~/.agents/skills/aria-patterns/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install aria-patterns The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project aria-patterns Installs to .agents/skills/aria-patterns/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/aria-patterns.zipunzip -o aria-patterns.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) aria-patterns PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The project already uses a headless library for this widget: use radix-ui
- A full page or product assessment against WCAG: use wcag-accessibility-audit
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.
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
| Need | Use first | Custom 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 attribute | Positioning 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 background | inert attribute |
No ARIA is better than wrong ARIA: a wrong role overrides correct native semantics.
Workflow
- 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. - Identify the APG pattern from the table below; write the keyboard map as a comment above the component before coding.
- Implement roles and relationships, then keyboard, then state updates, then focus management.
- Test with keyboard, then a screen reader (VoiceOver + Safari, NVDA + Chrome or Firefox).
Pattern quick reference
| Pattern | Roles / attributes | Keys |
|---|---|---|
| Disclosure | button[aria-expanded][aria-controls] | Enter, Space toggle |
| Modal dialog | dialog (native) or role=dialog aria-modal=true, aria-labelledby | Tab cycles inside, Escape closes, focus returns to opener |
| Menu button (actions) | button[aria-haspopup=menu][aria-expanded] → role=menu → role=menuitem | Enter/Space/Down open + focus first; Up/Down move; Home/End; Escape closes to button; type-ahead |
| Tabs | role=tablist → role=tab[aria-selected][aria-controls], role=tabpanel[aria-labelledby] | Left/Right move (wrap); Home/End; Tab moves into panel |
| Accordion | heading > button[aria-expanded][aria-controls], region optional | Enter/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 |
| Listbox | role=listbox[aria-multiselectable] → role=option[aria-selected] | Arrows, Home/End, Space toggles (multi) |
| Tooltip | trigger aria-describedby → role=tooltip | Escape dismisses; shows on focus and hover |
| Toggle button | button[aria-pressed] | Enter, Space |
| Switch | role=switch[aria-checked] (on button or checkbox) | Space (and Enter on button) |
| Status message | role=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.
Modal dialog (native)
<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 stableids. aria-expandedreflects whether the listbox is visible; keep the listbox in the DOM withhiddenwhen closed soaria-controlspoints 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+preventDefaulton 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-livewithoutrole="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. Useinertfor backgrounds instead.aria-expandedbelongs on the button that controls the popup, not on the popup.aria-selectedis for tabs, options and grid cells;aria-pressedfor toggle buttons;aria-checkedfor 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
tabindexvalues break tab order; only0and-1. - Hiding with
display: noneorhiddenremoves from AT;visibility: hiddentoo; 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
bodyafter 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.yaml
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.