agentleFS
dylantarredylantarre/design-system-skills/skills/accessibility/aria-patterns/SKILL.md

aria-patterns

Skill50 starsChanged 11 months agoMITView on GitHub

Provides ARIA roles, states, and properties for interactive components. Use when building custom widgets, fixing screen reader issues, or implementing modals, tabs, accordions, menus, or dialogs accessibly.

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 3 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Don't change native semantics: - Don't put role="button" on a heading

    line 22
  2. Distinctive:

    Test with screen reader: Verify announcements

    line 33
  3. Distinctive:

    Screen reader: Test with VoiceOver (Mac), NVDA (Windows), or JAWS

    line 404
  4. Distinctive:

    axe DevTools: Run automated accessibility audit

    line 406

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

ARIA Patterns Guide

Overview

Implement accessible interactive components using correct ARIA roles, states, and properties. Provides copy-paste patterns for common widgets that work with screen readers and keyboard navigation.

When to Use

  • Building custom interactive components
  • Making dynamic content accessible
  • Fixing screen reader issues
  • Adding keyboard support to custom widgets

Quick Reference: First Rules of ARIA

  1. Don't use ARIA if native HTML works - <button> over <div role="button">
  2. Don't change native semantics - Don't put role="button" on a heading
  3. All interactive ARIA elements must be keyboard accessible
  4. Don't use role="presentation" or aria-hidden="true" on focusable elements
  5. All interactive elements must have accessible names

The Process

  1. Identify component type: What widget pattern matches?
  2. Check native HTML first: Can a semantic element do this?
  3. Apply ARIA pattern: Roles, states, properties
  4. Add keyboard support: Expected keys for the pattern
  5. Test with screen reader: Verify announcements

Component Patterns

Button

Native (preferred):

<button type="button">Click me</button>

Custom (when necessary):

<div
  role="button"
  tabindex="0"
  aria-pressed="false"
  onkeydown="handleKeyDown(event)"
>
  Toggle
</div>

<script>
function handleKeyDown(e) {
  if (e.key === 'Enter' || e.key === ' ') {
    e.preventDefault();
    e.target.click();
  }
}
</script>

Toggle Button

<button
  type="button"
  aria-pressed="false"
  onclick="this.setAttribute('aria-pressed', this.getAttribute('aria-pressed') === 'true' ? 'false' : 'true')"
>
  <span class="sr-only">Enable</span> Dark Mode
</button>

Modal Dialog

<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  aria-describedby="modal-desc"
>
  <h2 id="modal-title">Confirm Action</h2>
  <p id="modal-desc">Are you sure you want to proceed?</p>

  <button type="button">Cancel</button>
  <button type="button">Confirm</button>
</div>

Required behavior:

  • Focus moves to dialog on open
  • Focus trapped within dialog
  • Escape key closes dialog
  • Focus returns to trigger on close
// Focus trap example
function trapFocus(dialog) {
  const focusable = dialog.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  dialog.addEventListener('keydown', (e) => {
    if (e.key === 'Tab') {
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  });
}

Dropdown Menu

<div class="dropdown">
  <button
    type="button"
    aria-haspopup="menu"
    aria-expanded="false"
    aria-controls="dropdown-menu"
    id="dropdown-trigger"
  >
    Options
  </button>

  <ul
    role="menu"
    id="dropdown-menu"
    aria-labelledby="dropdown-trigger"
    hidden
  >
    <li role="none">
      <button role="menuitem" tabindex="-1">Edit</button>
    </li>
    <li role="none">
      <button role="menuitem" tabindex="-1">Duplicate</button>
    </li>
    <li role="none">
      <button role="menuitem" tabindex="-1">Delete</button>
    </li>
  </ul>
</div>

Keyboard:

  • Enter/Space: Open menu, activate item
  • Arrow Down: Next item (or first if closed)
  • Arrow Up: Previous item
  • Escape: Close menu
  • Home: First item
  • End: Last item

Tabs

<div class="tabs">
  <div role="tablist" aria-label="Account settings">
    <button
      role="tab"
      aria-selected="true"
      aria-controls="panel-1"
      id="tab-1"
      tabindex="0"
    >
      Profile
    </button>
    <button
      role="tab"
      aria-selected="false"
      aria-controls="panel-2"
      id="tab-2"
      tabindex="-1"
    >
      Security
    </button>
    <button
      role="tab"
      aria-selected="false"
      aria-controls="panel-3"
      id="tab-3"
      tabindex="-1"
    >
      Billing
    </button>
  </div>

  <div role="tabpanel" id="panel-1" aria-labelledby="tab-1" tabindex="0">
    Profile content...
  </div>
  <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" tabindex="0" hidden>
    Security content...
  </div>
  <div role="tabpanel" id="panel-3" aria-labelledby="tab-3" tabindex="0" hidden>
    Billing content...
  </div>
</div>

Keyboard:

  • Arrow Left/Right: Move between tabs
  • Home: First tab
  • End: Last tab
  • Tab: Move into panel content

Accordion

<div class="accordion">
  <h3>
    <button
      type="button"
      aria-expanded="true"
      aria-controls="section-1"
      id="accordion-header-1"
    >
      Section 1
    </button>
  </h3>
  <div
    role="region"
    id="section-1"
    aria-labelledby="accordion-header-1"
  >
    Section 1 content...
  </div>

  <h3>
    <button
      type="button"
      aria-expanded="false"
      aria-controls="section-2"
      id="accordion-header-2"
    >
      Section 2
    </button>
  </h3>
  <div
    role="region"
    id="section-2"
    aria-labelledby="accordion-header-2"
    hidden
  >
    Section 2 content...
  </div>
</div>

Tooltip

<button
  type="button"
  aria-describedby="tooltip-1"
>
  Help
</button>

<div
  role="tooltip"
  id="tooltip-1"
  hidden
>
  Click here for more information
</div>

Note: For interactive content, use a disclosure or dialog instead.


Alert / Status Messages

<!-- Alert (important, interruptive) -->
<div role="alert">
  Error: Please enter a valid email address.
</div>

<!-- Status (polite update) -->
<div role="status" aria-live="polite">
  3 items in cart
</div>

<!-- Live region (for dynamic content) -->
<div aria-live="polite" aria-atomic="true">
  <!-- Content updates announced to screen readers -->
</div>

Combobox (Autocomplete)

<div class="combobox">
  <label for="search-input">Search</label>
  <input
    type="text"
    id="search-input"
    role="combobox"
    aria-autocomplete="list"
    aria-expanded="false"
    aria-controls="search-listbox"
    aria-activedescendant=""
  >

  <ul
    role="listbox"
    id="search-listbox"
    hidden
  >
    <li role="option" id="option-1">Option 1</li>
    <li role="option" id="option-2">Option 2</li>
    <li role="option" id="option-3" aria-selected="true">Option 3</li>
  </ul>
</div>

Update aria-activedescendant to the ID of the highlighted option.


Progress / Loading

<!-- Determinate progress -->
<div
  role="progressbar"
  aria-valuenow="75"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-label="Upload progress"
>
  75%
</div>

<!-- Indeterminate loading -->
<div
  role="status"
  aria-label="Loading"
>
  <span class="spinner" aria-hidden="true"></span>
  <span class="sr-only">Loading...</span>
</div>

Common ARIA Attributes

AttributePurposeExample
aria-labelAccessible namearia-label="Close"
aria-labelledbyName from elementaria-labelledby="heading-1"
aria-describedbyDescriptionaria-describedby="hint-1"
aria-expandedOpen/closed statearia-expanded="true"
aria-controlsControlled elementaria-controls="menu-1"
aria-hiddenHide from ATaria-hidden="true"
aria-liveAnnounce updatesaria-live="polite"
aria-pressedToggle statearia-pressed="false"
aria-selectedSelection statearia-selected="true"
aria-currentCurrent itemaria-current="page"

Screen Reader Only Text

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

Testing

  1. Keyboard only: Tab through, use arrows, Enter, Escape
  2. Screen reader: Test with VoiceOver (Mac), NVDA (Windows), or JAWS
  3. Check announcements: Are labels, states, and changes announced?
  4. axe DevTools: Run automated accessibility audit

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.