defuss-shadcn / Guides / accessibility
Accessibility
Every component follows WAI-ARIA Authoring Practices. Semantic HTML first, ARIA attributes where native semantics don't exist, full keyboard support throughout.
On this page (11)
§Semantic HTML first
Components start from the most semantic native element available. A <button> is already a button - no role="button" needed. A <dialog> already traps focus and handles Escape. ARIA is only added when the native element doesn't express the full semantics of the widget.
<!-- Don't add ARIA roles to native elements --><div role="button" tabindex="0" onclick="..."> Click me</div><!-- Native button: focusable, keyboard activatable, announced correctly --><button class="btn"> Click me</button>§ARIA roles
When no native element expresses the widget semantics, ARIA roles tell assistive technology what the element represents. These follow the WAI-ARIA Authoring Practices Guide (APG).
| Role | Component | Purpose |
|---|---|---|
menu | Dropdown, Context Menu | A list of actions or options |
menuitem | Dropdown, Context Menu | A single action in a menu |
menuitemcheckbox | Dropdown | A togglable menu item |
menuitemradio | Dropdown | A mutually exclusive menu option |
tablist | Tabs | Container for tab triggers |
tab | Tabs | A single tab trigger |
tabpanel | Tabs | Content area associated with a tab |
listbox | Combobox | A list of selectable options |
option | Combobox | A single option in a listbox |
toolbar | Toolbar | A container for grouped actions |
treeitem | Tree View | A node in a hierarchical tree |
dialog | Dialog, Sheet | A modal or non-modal overlay (native via <dialog>) |
alertdialog | Alert Dialog | A dialog requiring acknowledgment |
alert | Alert | An important, time-sensitive message |
status | Toast | A polite status notification |
§State attributes
ARIA state attributes communicate dynamic state changes to assistive technology. These are managed by JavaScript at runtime.
| Attribute | Values | Usage |
|---|---|---|
aria-expanded | true / false | Dropdown triggers, popover triggers, accordion headers - indicates whether the controlled content is visible |
aria-selected | true / false | Tab triggers, combobox options - indicates the active selection |
aria-checked | true / false / mixed | Menu item checkboxes, menu item radios, toggle buttons |
aria-pressed | true / false | Toggle buttons - indicates pressed/active state |
aria-disabled | true | Non-native disabled elements (menu items, links) where the HTML disabled attribute isn't available |
aria-invalid | true | Form inputs with validation errors |
aria-busy | true | Loading states - tells screen readers content is still loading |
§Relationship attributes
These attributes create semantic connections between elements, allowing assistive technology to navigate related content.
| Attribute | Purpose | Example |
|---|---|---|
aria-controls | Links a trigger to the element it controls | Tab trigger → tab panel |
aria-labelledby | Labels an element using another element's text | Tab panel labeled by its tab trigger |
aria-describedby | Provides additional descriptive text | Input described by its help text |
aria-activedescendant | Points to the visually focused item in a composite widget | Combobox input → highlighted option |
aria-haspopup | Indicates a trigger will open a popup | menu, dialog, or listbox |
aria-owns | Creates parent-child relationship in the accessibility tree | Combobox input owns the listbox |
<div role="tablist" aria-label="Settings"> <button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1" tabindex="0"> General </button> <button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1"> Advanced </button></div><div role="tabpanel" id="panel-1" aria-labelledby="tab-1"> General settings content</div><div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden> Advanced settings content</div>§Keyboard navigation
Every interactive component is fully keyboard accessible. The keyboard patterns follow WAI-ARIA APG conventions so users get consistent behavior across the web.
| Key | Menus & Dropdowns | Tabs | Dialogs |
|---|---|---|---|
| ↑↓ | Navigate between menu items | Navigate tabs (vertical) | — |
| ←→ | — | Navigate tabs (horizontal) | — |
| Home | Jump to first item | Jump to first tab | — |
| End | Jump to last item | Jump to last tab | — |
| Enter / Space | Activate item | Activate tab | Activate focused button |
| Escape | Close menu, return focus to trigger | — | Close dialog, return focus to trigger |
| Tab | Close menu and move to next focusable | Move into tab panel | Trapped within dialog |
| Type-ahead | Jump to item starting with typed character | — | — |
§Roving tabindex
Composite widgets (tabs, toolbars, menus) use roving tabindex so the entire widget acts as a single tab stop. Only one item has tabindex="0" at a time - all others have tabindex="-1". Arrow keys move focus between items.
<!-- Only the active tab is in the tab order --><div role="tablist"> <button role="tab" tabindex="0" aria-selected="true">Tab 1</button> <button role="tab" tabindex="-1" aria-selected="false">Tab 2</button> <button role="tab" tabindex="-1" aria-selected="false">Tab 3</button></div><!-- User presses → - focus moves to Tab 2: Tab 1: tabindex="-1", Tab 2: tabindex="0" -->// Move focus to the item at `index` - df$ writes the attributesfunction focusItem(items, index) { df$(items).attr('tabindex', '-1'); df$(items[index]).attr('tabindex', '0'); items[index].focus(); // focus stays a native call}Used by: Tabs, Toolbar, Toggle Group, Dropdown Menu, Tree View
§Focus management
When overlays open and close, focus must move predictably. Dialogs trap focus. When a modal closes, focus returns to the trigger that opened it.
<dialog> with .showModal() traps focus natively - Tab cycles within the dialog only. No JS focus-trap library needed.Focus restorationWhen a dialog or menu closes, focus returns to the trigger element that opened it. JS stores a reference to the trigger before opening.Focus-visible:focus-visible shows focus rings only on keyboard navigation, not mouse clicks. Applied to every interactive component.// Store the trigger before openingdialog._trigger = triggerButton;dialog.showModal();// Restore focus when the dialog closesdf$(dialog).on('close', () => dialog._trigger?.focus());§Live regions
Live regions announce dynamic content changes to screen readers without moving focus. Toast notifications and alerts use these to ensure users are informed of updates.
aria-live="polite"Announced after the screen reader finishes its current output. Used for info and success toasts.aria-live="assertive"Interrupts the screen reader immediately. Used for error/destructive toasts and alerts.aria-atomic="true"The entire region is announced as a whole, not just the changed parts.<!-- Success toast: polite announcement --><div class="toast" role="status" aria-live="polite" aria-atomic="true"> Settings saved successfully.</div><!-- Error toast: assertive (immediate) announcement --><div class="toast" data-variant="destructive" role="alert" aria-live="assertive" aria-atomic="true"> Failed to save. Please try again.</div>§Screen reader considerations
A few patterns help screen readers present clean, meaningful content.
aria-hidden="true" so they're not announced redundantly.Icon-only buttonsButtons with no visible text need aria-label to provide an accessible name: <button aria-label="Close">.Visually hidden textText that should be announced but not visible uses the shipped .sr-only utility (see below).Form labelsEvery form input has a visible <label> element with a matching for/id pair, or aria-label for unlabeled inputs.<!-- Icon-only button: needs aria-label --><button class="btn" data-size="icon" aria-label="Close"> <svg aria-hidden="true">...</svg></button><!-- Button with text: the icon is decorative --><button class="btn"> <svg aria-hidden="true">...</svg> Save Changes</button>§Screen-reader-only content
Some text belongs to the accessibility tree but not to the visual design: an extra word of context on a link, the unit a <meter> omits, the label an icon-only control needs. .sr-only ships in the optional theme/utils/accessibility.css module and clips content to a 1px box - CSS the browser still hands to assistive technology (unlike display:none or visibility:hidden, which hide it from screen readers too). .not-sr-only reverses it - the classic skip-link pattern. This very page links the module, so the demo below is the real utility, not a replica.
<a class="btn" data-variant="link" href="#">
Read the changelog<span class="sr-only"> (opens the release history)</span>
</a>The link reads identically on screen - a screen reader announces the extra phrase. Never put the only accessible name of a focusable control inside .sr-only; use aria-label for that.
§Load the accessibility module
<link rel="stylesheet" href="../theme/utils/default-semantic-tokens.css">
<!-- optional module: .sr-only / .not-sr-only -->
<link rel="stylesheet" href="../theme/utils/accessibility.css"> Each component's component-skill.md includes a dedicated ARIA section listing the exact attributes required for that component. See the WAI-ARIA APG for the full specifications these patterns implement.
Comments, ideas or improvements? Edit this page's source on GitHub