Working patterns for developers. Each example includes a live demo and the complete code. All examples are tested with VoiceOver on macOS Safari.
1. Skip Navigation Link
A visually hidden link that appears on keyboard focus, letting screen reader and keyboard users jump past repeated navigation directly to main content. Required for WCAG 2.4.1.
Test with VoiceOver: Tab once from the top of the page. The skip link should be announced and become visible. Activate it and confirm focus lands on main content.
The .sr-only pattern visually hides text while keeping it in the accessibility tree. Use it to add context that sighted users get from visual layout but screen reader users would otherwise miss.
Test with VoiceOver: Navigate the buttons below. VoiceOver should announce "Edit Justin Mann" and "Delete Justin Mann", not just "Edit" and "Delete".
Landmark roles let screen reader users jump between major page regions. VoiceOver users press W or use the Rotor to navigate landmarks. Every page should have at least banner, main, and contentinfo.
Test with VoiceOver: Open the Rotor (VO + U), navigate to Landmarks, and confirm all major regions of the page are listed by their label.
role="banner" — site header
role="navigation" — labeled nav
role="main" — primary content
role="complementary" — sidebar
role="contentinfo" — footer
View code
<!-- Use native HTML elements where possible -->
<header role="banner">
<nav aria-label="Primary">...</nav>
</header>
<main id="main">
<!-- page content -->
</main>
<aside aria-label="Related links">
<!-- sidebar -->
</aside>
<footer role="contentinfo">
<!-- footer -->
</footer>
<!-- Multiple navs need unique labels -->
<nav aria-label="Primary">...</nav>
<nav aria-label="Breadcrumb">...</nav>
<nav aria-label="Pagination">...</nav>
4. Icon Button
Buttons that contain only an icon have no accessible name by default. Always provide an aria-label. Never use <div> or <span> for clickable controls — use <button type="button">.
Test with VoiceOver: Tab to each button. The first should announce "button" with no name (broken). The second should announce "Close dialog, button". The third should announce "Search, button".
Broken:
Fixed:
View code
<!-- Broken: no accessible name -->
<button type="button">
<svg ...></svg>
</button>
<!-- Fixed: aria-label provides the name -->
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true" focusable="false" ...></svg>
</button>
<!-- Or: visually hidden text inside the button -->
<button type="button">
<svg aria-hidden="true" focusable="false" ...></svg>
<span class="sr-only">Close dialog</span>
</button>
<!-- Never do this -->
<div onclick="..."></div> <!-- not keyboard accessible -->
<span onclick="..."></span> <!-- not keyboard accessible -->
5. Disclosure Widget (Accordion)
The native HTML <details> and <summary> elements provide a fully accessible show/hide widget with no JavaScript required. VoiceOver announces the expanded/collapsed state automatically.
Test with VoiceOver: Navigate to the summary elements. VoiceOver should announce the heading text and "collapsed" or "expanded". Space or Enter should toggle the state.
What is WCAG?
The Web Content Accessibility Guidelines (WCAG) are a set of recommendations for making web content more accessible, primarily to people with disabilities. WCAG 2.2 is the current standard, with levels A, AA, and AAA.
What is VoiceOver?
VoiceOver is Apple's built-in screen reader, available on macOS, iOS, iPadOS, tvOS, and watchOS. Enable it with Command+F5 on Mac or triple-click the side button on iPhone.
What is ARIA?
Accessible Rich Internet Applications (ARIA) is a set of HTML attributes that define ways to make web content more accessible. The first rule of ARIA: if you can use a native HTML element instead, do that.
Live regions announce dynamic content changes to screen reader users who aren't currently focused on that area. Use aria-live="polite" for non-urgent updates and aria-live="assertive" (or role="alert") for errors and critical messages.
Test with VoiceOver: Click the buttons below. VoiceOver should announce the status message without you moving focus.
View code
<!-- Polite: announced when user is idle -->
<div
id="status"
role="status"
aria-live="polite"
aria-atomic="true"
></div>
<!-- Assertive: announced immediately, interrupts -->
<div
id="error"
role="alert"
aria-live="assertive"
aria-atomic="true"
></div>
<script>
// Polite notification
document.getElementById('status').textContent = 'File saved.';
// Error announcement
document.getElementById('error').textContent = 'Connection failed.';
</script>
<!-- Rules:
1. The live region must exist in the DOM before content is injected.
2. aria-atomic="true" announces the full region, not just the changed part.
3. Use role="status" for confirmations, role="alert" for errors.
4. Never use assertive for non-errors — it interrupts screen reader output.
-->
7. Accessible Form
Every input needs a visible <label> associated via for and id. Error messages must be programmatically associated with their field via aria-describedby. Required fields need aria-required="true".
Test with VoiceOver: Navigate to each field. VoiceOver should announce the label, the input type, and any hint text. The error state should announce "invalid" and read the error message.
View code
<form>
<!-- Basic labeled input -->
<div>
<label for="name">
Full name
<span aria-hidden="true">*</span>
</label>
<input
type="text"
id="name"
name="name"
aria-required="true"
aria-describedby="name-hint"
autocomplete="name"
>
<div id="name-hint">Enter your first and last name.</div>
</div>
<!-- Invalid state with error message -->
<div>
<label for="email">Email</label>
<input
type="email"
id="email"
aria-required="true"
aria-invalid="true"
aria-describedby="email-error"
>
<div id="email-error" role="alert">
Enter a valid email address.
</div>
</div>
<!-- Radio group uses fieldset + legend -->
<fieldset>
<legend>Preferred contact method</legend>
<label><input type="radio" name="contact" value="email"> Email</label>
<label><input type="radio" name="contact" value="phone"> Phone</label>
</fieldset>
</form>
8. Focus-Managed Dialog
Dialogs must trap focus while open, move focus to the dialog on open, and return focus to the trigger on close. The native <dialog> element handles this automatically in modern browsers.
Test with VoiceOver: Click "Open dialog". Focus should land inside the dialog and VoiceOver should announce the dialog role and heading. Tab should cycle only within the dialog. Escape or the close button should dismiss it and return focus to the trigger.
View code
<!-- Trigger -->
<button type="button" id="dialog-trigger">
Open dialog
</button>
<!-- Dialog -->
<dialog
id="my-dialog"
aria-labelledby="dialog-title"
>
<h2 id="dialog-title">Confirm action</h2>
<p>Are you sure? This cannot be undone.</p>
<div>
<button type="button" id="cancel-btn">Cancel</button>
<button type="button">Confirm</button>
</div>
</dialog>
<script>
const trigger = document.getElementById('dialog-trigger');
const dialog = document.getElementById('my-dialog');
const cancel = document.getElementById('cancel-btn');
// Open: showModal() handles focus trap automatically
trigger.addEventListener('click', () => dialog.showModal());
// Close: focus returns to trigger
dialog.addEventListener('close', () => trigger.focus());
cancel.addEventListener('click', () => dialog.close());
// Escape key is handled by the browser automatically
</script>
<!-- Note: dialog.showModal() creates a native focus trap.
dialog.show() (no focus trap) is for non-modal panels. -->
9. Accessible Data Table
Tables need a <caption> describing the data, scope="col" or scope="row" on header cells, and <thead> / <tbody> structure. Never use tables for layout.
Test with VoiceOver: Navigate into the table. VoiceOver should announce "table, [caption], [column count] columns". Moving through cells should announce the cell value and the associated column header.