Foundations
Semantic HTML
The most powerful accessibility tool is already built into HTML. Using the right element for the right job gives screen readers, search engines, and browsers the context they need — for free, with no ARIA required.
Document structure
<!-- Good: landmark regions give structure -->
<header>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<main id="main-content">
<article>
<h1>Page title</h1>
<section aria-labelledby="section-heading">
<h2 id="section-heading">Section title</h2>
<p>Content...</p>
</section>
</article>
</main>
<footer>
<p>© 2025 Company</p>
</footer>
Heading hierarchy
⚠Never skip heading levels. Going from h1 → h3 confuses screen reader users who navigate by headings. Use CSS to control size, not heading levels.
<!-- Bad: skipping heading levels -->
<h1>Page title</h1>
<h3>Section</h3> <!-- jumped from h1 to h3 -->
<!-- Good: logical hierarchy -->
<h1>Page title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another section</h2>
Interactive elements
<!-- Bad: div used as button -->
<div onclick="submit()">Submit</div>
<!-- Good: use the native element -->
<button type="submit">Submit</button>
<!-- If you MUST use a div as a button -->
<div role="button" tabindex="0"
onkeydown="if(e.key==='Enter'||e.key===' ')submit()"
onclick="submit()">
Submit
</div>
Foundations
ARIA Landmarks
Landmarks carve the page into navigable sections. Screen reader users rely on them to jump around quickly — similar to how sighted users scan visually.
<!-- HTML5 elements map to landmark roles automatically -->
<header> <!-- role="banner" (only when top-level) -->
<nav> <!-- role="navigation" -->
<main> <!-- role="main" -->
<aside> <!-- role="complementary" -->
<footer> <!-- role="contentinfo" (only when top-level) -->
<section> <!-- role="region" only when given a label -->
<!-- When you have multiple navs, label them -->
<nav aria-label="Main navigation">...</nav>
<nav aria-label="Breadcrumb">...</nav>
<nav aria-label="Pagination">...</nav>
<!-- Section needs a label to become a landmark -->
<section aria-labelledby="news-heading">
<h2 id="news-heading">Latest news</h2>
</section>
✓Rule of thumb: one <main> per page. Multiple <nav> elements are fine — just label each one uniquely.
Foundations
ARIA Roles
Use ARIA roles only when native HTML cannot express the pattern. ARIA helps screen readers understand custom components.
⚠First rule of ARIA: don't use ARIA when native HTML works. Adding role="button" to a div still requires you to manually handle keyboard events, focus, and state.
<!-- Widget roles -->
<div role="button"> <!-- interactive button -->
<div role="checkbox"> <!-- checkable item -->
<div role="combobox"> <!-- editable dropdown -->
<div role="listbox"> <!-- list of selectable options -->
<div role="menu"> <!-- context/action menu -->
<div role="menuitem"> <!-- item in a menu -->
<div role="option"> <!-- option in a listbox/combobox -->
<div role="progressbar"> <!-- progress indicator -->
<div role="switch"> <!-- toggle on/off -->
<div role="tab"> <!-- a tab in a tablist -->
<div role="tablist"> <!-- container of tabs -->
<div role="tabpanel"> <!-- content for a tab -->
<div role="tooltip"> <!-- tooltip -->
<!-- Document roles -->
<div role="alert"> <!-- urgent message (assertive) -->
<div role="dialog"> <!-- modal or non-modal dialog -->
<div role="status"> <!-- non-urgent status message -->
Foundations
ARIA States & Properties
States describe the current condition of a component. Properties describe its nature. Both must be updated dynamically as the UI changes.
<!-- Labelling -->
<button aria-label="Close dialog">✕</button>
<input aria-labelledby="label-id">
<input aria-describedby="help-text-id">
<!-- State -->
<button aria-expanded="false">Show more</button>
<div aria-hidden="true">Decorative, hidden from AT</div>
<li role="option" aria-selected="true">Option A</li>
<input type="checkbox" aria-checked="mixed"> <!-- indeterminate -->
<button aria-disabled="true">Can't do this</button>
<input aria-required="true">
<input aria-invalid="true" aria-describedby="err">
<span id="err" role="alert">Required field</span>
// Dynamic update in JS
const btn = document.querySelector('#toggle');
btn.addEventListener('click', () => {
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !expanded);
document.getElementById('panel').hidden = expanded;
});
Interaction
Keyboard Navigation
All functionality must be operable by keyboard alone. WCAG 2.1.1 (Level A) requires no mouse dependency.
Standard keyboard conventions
| Key | Expected action |
|---|
Tab | Move focus to next focusable element |
Shift+Tab | Move focus to previous focusable element |
Enter | Activate a button, link, or submit a form |
Space | Activate a button; toggle a checkbox |
Arrow keys | Navigate within a widget (tabs, menus, sliders) |
Escape | Close a dialog, menu, or tooltip |
Home / End | Jump to first/last item in a list |
tabindex rules
<!-- tabindex="0" — add to tab order (natural DOM position) -->
<div role="button" tabindex="0">Custom button</div>
<!-- tabindex="-1" — focusable by script, not by Tab -->
<div id="panel" tabindex="-1">Dialog panel</div>
<!-- tabindex="1+" — AVOID. Forces unnatural tab order -->
<button tabindex="3">Don't do this</button>
Keyboard event handling
// Handle both Enter and Space for custom buttons
element.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleAction();
}
});
// Arrow key navigation in a menu
const items = menu.querySelectorAll('[role="menuitem"]');
let currentIndex = 0;
menu.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
currentIndex = (currentIndex + 1) % items.length;
items[currentIndex].focus();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
currentIndex = (currentIndex - 1 + items.length) % items.length;
items[currentIndex].focus();
}
if (e.key === 'Escape') closeMenu();
});
Interaction
Focus Management
Managing focus means moving it deliberately when the UI changes — opening a modal, loading new content, or completing a task.
✕Never do this: :focus { outline: none } — this removes focus visibility for keyboard users. WCAG 2.4.11 (AA) requires visible focus indicators.
/* Good: visible and branded focus styles */
:focus-visible {
outline: 3px solid #1746a2;
outline-offset: 3px;
border-radius: 3px;
}
/* For dark backgrounds */
.dark-bg:focus-visible {
outline: 3px solid #fff;
box-shadow: 0 0 0 5px #1746a2;
}
Focus trapping (modals)
function trapFocus(element) {
const focusable = [...element.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
)];
const first = focusable[0];
const last = focusable[focusable.length - 1];
element.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) {
last.focus(); e.preventDefault();
}
} else {
if (document.activeElement === last) {
first.focus(); e.preventDefault();
}
}
});
}
Interaction
Skip Links
Skip links let keyboard users jump past repeated navigation to the main content. Required by WCAG 2.4.1 (Level A).
<!-- First element in <body> -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- The target must be focusable -->
<main id="main-content" tabindex="-1">
<h1>Page title</h1>
</main>
.skip-link {
position: absolute;
top: -100%;
left: 1rem;
padding: .5rem 1rem;
background: #1746a2;
color: #fff;
font-weight: 600;
border-radius: 0 0 8px 8px;
z-index: 9999;
transition: top 0.15s;
}
.skip-link:focus { top: 0; }
Content
Images & Alt Text
Every meaningful image needs alternative text. The quality of alt text matters as much as its presence — it should convey purpose, not just describe literally.
<!-- Meaningful image: describe purpose, not appearance -->
<img src="logo.png" alt="a11ytest.ai">
<!-- Decorative image: empty alt suppresses from screen readers -->
<img src="decoration.svg" alt="">
<!-- Functional image (inside a link): describe destination -->
<a href="/dashboard">
<img src="icon-dashboard.svg" alt="Dashboard">
</a>
<!-- Complex image: use figcaption for long description -->
<figure>
<img src="chart.png"
alt="Sales chart for Q3 2025"
aria-describedby="chart-desc">
<figcaption id="chart-desc">
Line chart showing revenue growing from £120k in July
to £180k in September.
</figcaption>
</figure>
<!-- Inline SVG: use title + desc -->
<svg role="img" aria-labelledby="svg-title svg-desc">
<title id="svg-title">Upload progress</title>
<desc id="svg-desc">65% of file uploaded</desc>
</svg>
<!-- Decorative SVG: hide completely -->
<svg aria-hidden="true" focusable="false">...</svg>
ℹDon't start alt text with "Image of" or "Photo of" — screen readers already announce "image". For logos, just use the brand name.
Content
Colour & Contrast
Roughly 300 million people have colour vision deficiency. WCAG contrast requirements ensure text is readable for users with low vision or on poor screens.
| Content type | Level AA | Level AAA |
|---|
| Normal text (under 18pt / 14pt bold) | 4.5:1 | 7:1 |
| Large text (18pt+ or 14pt+ bold) | 3:1 | 4.5:1 |
| UI components & graphics | 3:1 | — |
| Focus indicator (WCAG 2.4.11) | 3:1 | — |
⚠Don't rely on colour alone. WCAG 1.4.1: if colour is the only way to convey information (e.g. red = error), users with colour blindness will miss it. Add icons, patterns, or text labels alongside colour.
Content
Live Regions
Live regions announce dynamic content changes to screen reader users without requiring a page reload or focus change.
<!-- aria-live="polite": announces after user is idle -->
<div aria-live="polite" aria-atomic="true" id="status"></div>
<!-- role="status" = polite live region (shorthand) -->
<div role="status">3 results found</div>
<!-- role="alert" = assertive, interrupts immediately -->
<div role="alert">Error: session expired</div>
function announce(message, priority = 'polite') {
const el = document.createElement('div');
el.setAttribute('aria-live', priority);
el.setAttribute('aria-atomic', 'true');
el.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)';
document.body.appendChild(el);
setTimeout(() => { el.textContent = message; }, 100);
setTimeout(() => { el.remove(); }, 5000);
}
announce('Form submitted successfully');
announce('Error: email already in use', 'assertive');
Components
Modals & Dialogs
Accessible modals require three things: move focus in on open, trap focus inside, and return focus to the trigger on close.
<button id="open-modal">Open dialog</button>
<div role="dialog"
id="my-modal"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-desc"
hidden>
<h2 id="modal-title">Confirm deletion</h2>
<p id="modal-desc">This action cannot be undone.</p>
<button type="button" id="confirm-btn">Delete</button>
<button type="button" id="cancel-btn">Cancel</button>
<button type="button" aria-label="Close dialog">✕</button>
</div>
const modal = document.getElementById('my-modal');
let previousFocus;
function openModal() {
previousFocus = document.activeElement; // remember trigger
modal.hidden = false;
modal.querySelector('button').focus(); // move focus in
document.addEventListener('keydown', handleEscape);
}
function closeModal() {
modal.hidden = true;
document.removeEventListener('keydown', handleEscape);
previousFocus?.focus(); // return focus to trigger
}
function handleEscape(e) {
if (e.key === 'Escape') closeModal();
}
✓Use the native <dialog> element where possible — it handles focus trapping, aria-modal, and Escape for you automatically.
Components
Tabs
The ARIA tab pattern uses a tablist container, individual tab elements, and associated tabpanel regions. Arrow keys navigate between tabs.
<div role="tablist" aria-label="Account settings">
<button role="tab"
id="tab-profile"
aria-controls="panel-profile"
aria-selected="true"
tabindex="0">
Profile
</button>
<button role="tab"
id="tab-security"
aria-controls="panel-security"
aria-selected="false"
tabindex="-1">
Security
</button>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
Profile content...
</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>
Security content...
</div>
const tabs = [...tablist.querySelectorAll('[role="tab"]')];
function activateTab(tab) {
tabs.forEach(t => {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
document.getElementById(t.getAttribute('aria-controls')).hidden = true;
});
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
document.getElementById(tab.getAttribute('aria-controls')).hidden = false;
tab.focus();
}
tablist.addEventListener('keydown', (e) => {
const i = tabs.indexOf(document.activeElement);
if (e.key === 'ArrowRight') activateTab(tabs[(i + 1) % tabs.length]);
if (e.key === 'ArrowLeft') activateTab(tabs[(i - 1 + tabs.length) % tabs.length]);
});
Components
Accordion
Accordion headers are buttons with aria-expanded controlling the visibility of their associated panel.
<h3>
<button type="button"
aria-expanded="false"
aria-controls="panel-1"
id="btn-1">
What is WCAG?
</button>
</h3>
<div id="panel-1" role="region" aria-labelledby="btn-1" hidden>
<p>WCAG stands for Web Content Accessibility Guidelines...</p>
</div>
document.querySelectorAll('.accordion button').forEach(btn => {
btn.addEventListener('click', () => {
const expanded = btn.getAttribute('aria-expanded') === 'true';
const panel = document.getElementById(btn.getAttribute('aria-controls'));
btn.setAttribute('aria-expanded', !expanded);
panel.hidden = expanded;
});
});
Reference
WCAG Quick Reference
WCAG 2.2 (October 2023) has four principles: Perceivable, Operable, Understandable, and Robust. Most regulations require Level AA.
| Criterion | Level | Summary |
|---|
| 1.1.1 Non-text Content | A | All non-text content has a text alternative |
| 1.3.1 Info and Relationships | A | Structure conveyed visually is in the code |
| 1.4.1 Use of Colour | A | Colour not the sole means of conveying info |
| 1.4.3 Contrast (Minimum) | AA | 4.5:1 for normal text, 3:1 for large text |
| 1.4.4 Resize Text | AA | Text resizable to 200% without loss of content |
| 1.4.10 Reflow | AA | No horizontal scroll at 320px width |
| 1.4.11 Non-text Contrast | AA | 3:1 for UI components and graphics |
| 2.1.1 Keyboard | A | All functionality available by keyboard |
| 2.4.1 Bypass Blocks | A | Skip links to bypass repeated navigation |
| 2.4.3 Focus Order | A | Focus order preserves meaning and operability |
| 2.4.7 Focus Visible | AA | Keyboard focus indicator visible |
| 2.4.11 Focus AppearanceNew 2.2 | AA | Focus indicator meets minimum size and contrast |
| 2.5.7 Dragging MovementsNew 2.2 | AA | All drag actions have a pointer alternative |
| 2.5.8 Target SizeNew 2.2 | AA | Minimum 24×24px touch target size |
| 3.1.1 Language of Page | A | lang attribute on <html> |
| 3.3.1 Error Identification | A | Errors identified in text |
| 3.3.2 Labels or Instructions | A | Labels provided for user inputs |
| 3.3.7 Redundant EntryNew 2.2 | A | Don't ask for the same info twice in a process |
| 4.1.2 Name, Role, Value | A | UI components expose name, role, state to AT |
| 4.1.3 Status Messages | AA | Status messages programmatically determined |
Reference
Testing Checklist
A good accessibility test combines automated scanning (catches ~30–40% of issues) with manual keyboard and screen reader testing.
| # | Test | What to check |
|---|
| 1 | Keyboard only | Tab through every interactive element. Is focus always visible? Can you reach and operate everything? |
| 2 | Screen reader (NVDA/VoiceOver) | Navigate by headings, landmarks, links. Do all images, buttons, and forms make sense? |
| 3 | 200% zoom | Zoom to 200% in browser. Does content reflow? Does anything overlap or disappear? |
| 4 | 1.5× text spacing | Apply text spacing bookmarklet. Check nothing breaks. |
| 5 | High contrast mode | Enable Windows High Contrast. Custom colours should not break content. |
| 6 | Mobile + switch | Test with iOS Switch Control or Android Switch Access. |
Scan your site for accessibility issues
a11ytest.ai catches WCAG failures, generates fix suggestions, and integrates with your CI/CD pipeline.
Get started →