HTML Semantics
Rules for choosing native HTML elements and ARIA attributes in design system components.
Native HTML elements first. ARIA only when no native element fits the interaction pattern. This is not a preference — it's the first rule of ARIA: "If you can use a native HTML element with the semantics and behaviour you require already built in, do so."
Link vs Button Decision Tree
Does activating it navigate to a new URL?
├── YES → <a href="...">
│ Does it open in a new tab?
│ └── YES → <a href="..." target="_blank" rel="noopener noreferrer">
│ (Note: modern browsers apply rel="noopener" by default for target="_blank",
│ but adding it explicitly ensures compatibility with older browsers)
└── NO → Does it submit a form?
├── YES → <button type="submit">
└── NO → <button type="button">Common mistakes:
<a>withonClickand nohref— use<button>instead<div onClick>— use<button>instead<button>wrapping a link — nest<a>inside, or choose one
Container Semantics
| Element | Use when | Not for |
|---|---|---|
<article> | Self-contained content that makes sense out of context (card, comment, blog post) | Layout wrappers |
<section> | Thematic grouping of content with a heading | Generic containers |
<div> | No semantic meaning needed — pure layout | Content with meaning |
<aside> | Tangentially related content (sidebar, callout) | Primary content |
<main> | Dominant content of the page (one per page) | Nested sections |
<header> | Introductory content for its nearest sectioning ancestor | Page-level only (it's not) |
<footer> | Footer for its nearest sectioning ancestor | Page-level only (it's not) |
<nav> | Major navigation block | Every list of links |
Navigation vs Menu
These are different widgets with different keyboard models:
| Pattern | Element/Role | Keyboard model | Use for |
|---|---|---|---|
| Navigation | <nav> with <a> links | Tab between links | Site navigation, breadcrumbs, pagination |
| Menu | role="menu" with role="menuitem" | Arrow keys between items, Tab exits | Action menus, context menus, dropdowns of actions |
Do not use role="menu" for navigation. A menu widget expects arrow-key navigation and confuses screen reader users when used for site links.
Dialog Patterns
Prefer the native <dialog> element:
<dialog aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm deletion</h2>
<p>This action cannot be undone.</p>
<button type="button">Cancel</button>
<button type="button">Delete</button>
</dialog>Native <dialog> provides:
- Built-in
showModal()/close()API - Backdrop via
::backdroppseudo-element - Focus trapping (in
showModal()mode) - Escape key to close
- Correct
role="dialog"andaria-modal="true"implicitly
When native <dialog> doesn't meet your needs (e.g., custom positioning or animation requirements beyond what the native API supports), add manually:
role="dialog"andaria-modal="true"- Focus trap (first focusable element on open)
- Focus restoration (return to trigger on close)
- Escape key handler
Form Semantics
| Element | Required for |
|---|---|
<label> | Every visible input — use htmlFor (React) pointing to input id |
<fieldset> + <legend> | Groups of related inputs (radio group, address fields) |
aria-describedby | Help text, error messages, format hints |
aria-invalid | Invalid form controls |
aria-required | Required fields (alongside the required attribute) |
<div>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
required
aria-required="true"
aria-describedby="email-hint email-error"
aria-invalid={hasError || undefined}
/>
<span id="email-hint">We'll never share your email.</span>
{hasError && <span id="email-error" role="alert">Please enter a valid email.</span>}
</div>Common ARIA Patterns
Toggle / Switch
<button
role="switch"
aria-checked={isOn}
onClick={toggle}
>
Dark mode
</button>Native <button> with role="switch" is exempt from keyboard handler rules — the browser fires click on Space and Enter for all native buttons.
Tabs
<div role="tablist" aria-label="Settings">
<button role="tab" aria-selected={activeTab === 0} aria-controls="panel-0">General</button>
<button role="tab" aria-selected={activeTab === 1} aria-controls="panel-1">Advanced</button>
</div>
<div role="tabpanel" id="panel-0" aria-labelledby="tab-0">
{/* Panel content */}
</div>Keyboard: Arrow keys move between tabs, Tab moves into the panel.
Disclosure / Accordion
<button aria-expanded={isOpen} aria-controls="content-1">
Section title
</button>
<div id="content-1" role="region" aria-labelledby="trigger-1" hidden={!isOpen}>
{/* Expandable content */}
</div>Live Regions
<!-- Polite: announced after current speech finishes -->
<div role="status" aria-live="polite">
{statusMessage}
</div>
<!-- Assertive: interrupts current speech -->
<div role="alert" aria-live="assertive">
{errorMessage}
</div>Rules Summary
- Use native elements first —
<button>,<a>,<input>,<dialog>,<details> - Don't add ARIA to override native semantics —
<button role="link">is wrong; use<a> - Every interactive element needs a keyboard path — focusable, operable, visible focus indicator
- Every image needs alt text — informative images get descriptive text, decorative images get
alt="" - Every form control needs a label — visible
<label>,aria-label, oraria-labelledby - Don't use
tabindex > 0— it breaks the natural tab order - Use
aria-hidden="true"only on non-focusable elements — never hide something that can receive focus
See Also
- Accessibility Audit — the full WCAG 2.2 AA audit framework
- Component API — how component inputs map to dimensions
- Component Scaffolding — ARIA reference in the scaffold workflow