Development Practices
Accessible code is not a separate layer added on top of working code — it is the result of applying correct HTML semantics, well-considered focus management, and appropriate ARIA usage from the start. Most accessibility failures in code fall into a small number of patterns. This page addresses each one with concrete do/don’t examples.
Semantic HTML
DeveloperThe single most effective accessibility technique is using the correct HTML element for the job. Native HTML elements carry built-in roles, states, and keyboard behaviours that you get for free — and would have to re-implement entirely if you used a <div> instead.
Use native elements first
<!-- Bad: div with click handler, no keyboard support, no role -->
<div class="btn" onclick="submit()">Submit</div>
<!-- Good: native button — keyboard accessible, correct role, focusable by default -->
<button type="submit">Submit</button>
<!-- Bad: span as a link -->
<span class="link" onclick="navigate('/about')">About us</span>
<!-- Good: anchor with href — keyboard accessible, right-clickable, correct role -->
<a href="/about">About us</a>
Landmark elements
HTML landmark elements divide the page into navigable regions. Screen reader users can jump between landmarks without reading every element:
| HTML element | ARIA role equivalent | Purpose |
|---|---|---|
<header> | banner | Site-wide header (when top-level) |
<nav> | navigation | Navigation links |
<main> | main | Primary page content |
<aside> | complementary | Secondary content |
<footer> | contentinfo | Site-wide footer (when top-level) |
<section aria-labelledby="..."> | region | Named content region |
<form aria-labelledby="..."> | form | Named form |
<search> | search | Search functionality (HTML 5.4+) |
<!-- Page structure using landmarks -->
<body>
<header>
<a href="/">CanAccess</a>
<nav aria-label="Main navigation">
<ul>
<li><a href="/getting-started">Getting started</a></li>
<!-- ... -->
</ul>
</nav>
</header>
<main id="main-content">
<h1>Page title</h1>
<!-- primary content -->
</main>
<aside aria-labelledby="related-heading">
<h2 id="related-heading">Related pages</h2>
<!-- ... -->
</aside>
<footer>
<!-- site footer -->
</footer>
</body>
Heading hierarchy
Headings communicate document structure. Screen reader users frequently navigate by headings — jumping from heading to heading to find relevant content.
<!-- Bad: visual headings created with font-size, not semantic elements -->
<p style="font-size: 2rem; font-weight: bold;">Main topic</p>
<p style="font-size: 1.5rem;">Subtopic</p>
<!-- Bad: heading levels skipped -->
<h1>Page title</h1>
<h3>Section (skipped h2)</h3>
<!-- Good: logical hierarchy -->
<h1>Accessible Canada Act</h1>
<h2>Who must comply</h2>
<h3>Federal organizations</h3>
<h3>Regulated industries</h3>
<h2>Enforcement</h2>
Do not choose heading levels based on their default visual size — use CSS to style them. A visually small <h2> is correct if the content is a second-level section.
Lists
Use list markup for genuine lists. Screen readers announce list type and item count:
<!-- Navigation links -->
<nav>
<ul>
<li><a href="/vision">Vision</a></li>
<li><a href="/hearing">Hearing</a></li>
</ul>
</nav>
<!-- Step-by-step process -->
<ol>
<li>Submit the request form</li>
<li>Wait for confirmation email</li>
<li>Complete the interview</li>
</ol>
<!-- Description list (term/definition pairs) -->
<dl>
<dt>WCAG</dt>
<dd>Web Content Accessibility Guidelines — the international standard for web accessibility</dd>
<dt>AODA</dt>
<dd>Accessibility for Ontarians with Disabilities Act</dd>
</dl>
Forms
DeveloperForms are where the most common accessibility failures occur. Every form control must have a label, errors must be descriptive, and the form must be fully operable by keyboard.
Labels
Every form control needs a programmatically associated label:
<!-- Bad: placeholder as only label (disappears on focus) -->
<input type="email" placeholder="Email address">
<!-- Bad: visible text not associated with the input -->
<p>Email address</p>
<input type="email">
<!-- Good: explicit label association -->
<label for="email">Email address</label>
<input type="email" id="email" name="email" autocomplete="email">
<!-- Good: wrapping label (implicit association) -->
<label>
Email address
<input type="email" name="email" autocomplete="email">
</label>
Required fields
<!-- Bad: visual asterisk only — screen readers don't know it means required -->
<label for="name">Name *</label>
<input type="text" id="name">
<!-- Good: aria-required communicates the requirement, visible * explained -->
<p><small>Fields marked with * are required.</small></p>
<label for="name">Name <span aria-hidden="true">*</span></label>
<input type="text" id="name" required aria-required="true" autocomplete="name">
Note: required and aria-required="true" are redundant — required alone communicates the requirement to screen readers. Both are acceptable but required alone is sufficient.
Error handling
Errors must identify which field failed and explain how to fix it. Moving focus to the error is critical for keyboard and screen reader users.
<!-- Bad: generic error with no guidance -->
<div class="error">Invalid entry</div>
<input type="email" id="email">
<!-- Good: associated error with specific guidance -->
<label for="email">Email address</label>
<input
type="email"
id="email"
aria-describedby="email-error"
aria-invalid="true"
>
<p id="email-error" class="error" role="alert">
Enter a valid email address (example: name@domain.ca)
</p>
Error summary for multi-field forms:
<!-- On form submit with errors: inject this, move focus to it -->
<div
role="alert"
id="error-summary"
tabindex="-1"
aria-labelledby="error-summary-heading"
>
<h2 id="error-summary-heading">There are 2 errors in this form</h2>
<ul>
<li><a href="#email">Email address: enter a valid email</a></li>
<li><a href="#postal">Postal code: enter a valid Canadian postal code (A1A 1A1)</a></li>
</ul>
</div>
// Move focus to error summary after form submission fails
document.getElementById('error-summary').focus();
Fieldsets and legends
Group related controls with <fieldset> and <legend>. This is required for radio buttons, checkboxes, and any group of related inputs:
<!-- Radio group without fieldset — screen readers cannot associate the question -->
<p>Preferred contact method</p>
<input type="radio" id="contact-email" name="contact" value="email">
<label for="contact-email">Email</label>
<input type="radio" id="contact-phone" name="contact" value="phone">
<label for="contact-phone">Phone</label>
<!-- Radio group with fieldset/legend — screen readers read "Preferred contact method: Email" -->
<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>
Autocomplete
Add autocomplete attributes to personal information fields. This enables browser autofill and reduces the cognitive and motor load of form completion — especially important for users with memory difficulties and motor disabilities:
<input type="text" name="given-name" autocomplete="given-name">
<input type="text" name="family-name" autocomplete="family-name">
<input type="email" name="email" autocomplete="email">
<input type="tel" name="phone" autocomplete="tel">
<input type="text" name="address-line1" autocomplete="street-address">
<input type="text" name="city" autocomplete="address-level2">
<input type="text" name="province" autocomplete="address-level1">
<input type="text" name="postal-code" autocomplete="postal-code">
<input type="text" name="country" autocomplete="country-name">
<input type="password" name="new-password" autocomplete="new-password">
<input type="password" name="current-password" autocomplete="current-password">
Never use autocomplete="off" on personal information fields — this is a WCAG 1.3.5 failure and creates significant barriers.
Tables
DeveloperData tables must communicate their structure — which cells are headers and what they describe. Without this structure, screen reader users hear a stream of data with no context.
Basic accessible table
<table>
<caption>AODA Compliance Deadlines by Organization Size</caption>
<thead>
<tr>
<th scope="col">Organization type</th>
<th scope="col">Deadline</th>
<th scope="col">Standard</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Large organizations (50+ employees)</th>
<td>January 1, 2021</td>
<td>WCAG 2.0 Level AA</td>
</tr>
<tr>
<th scope="row">Small organizations (1–49 employees)</th>
<td>January 1, 2023</td>
<td>WCAG 2.0 Level AA</td>
</tr>
</tbody>
</table>
Key requirements:
<caption>: descriptive table name — screen readers announce it before reading the table<thead>/<tbody>/<tfoot>: group rows semantically<th scope="col">: column headers with explicit scope<th scope="row">: row headers with explicit scope- Never use
<td>for header cells
Complex tables
For tables with merged cells or multi-level headers, use headers and id attributes to explicitly associate cells with their headers:
<table>
<caption>Compliance rates by province and organization size</caption>
<thead>
<tr>
<th id="province" scope="col">Province</th>
<th id="large" scope="col">Large orgs</th>
<th id="small" scope="col">Small orgs</th>
</tr>
</thead>
<tbody>
<tr>
<th id="ontario" scope="row">Ontario</th>
<td headers="ontario large">72%</td>
<td headers="ontario small">41%</td>
</tr>
</tbody>
</table>
Responsive tables
Data tables with many columns require horizontal scrolling on small screens. Wrap the table in a scrollable container and label it:
<div
role="region"
aria-label="AODA compliance deadlines — scroll to see all columns"
tabindex="0"
style="overflow-x: auto;"
>
<table><!-- ... --></table>
</div>
tabindex="0" makes the scrollable region focusable by keyboard, allowing keyboard users to scroll it with arrow keys.
Keyboard navigation
DeveloperAll functionality must be operable by keyboard alone (WCAG 2.1.1, Level A). This means:
- Every interactive element must be reachable by Tab
- Every interactive element must be activatable by Enter or Space
- Hover-only functionality must also respond to keyboard focus
- No keyboard traps
Skip links
Provide a skip link as the first interactive element on every page, so keyboard users can bypass repeated navigation:
<!-- In the <head>, add this to your CSS -->
<style>
.skip-link {
position: absolute;
top: -100%;
left: 0;
padding: 0.5rem 1rem;
background: var(--color-primary);
color: #fff;
font-weight: bold;
z-index: 9999;
text-decoration: none;
}
.skip-link:focus {
top: 0;
}
</style>
<!-- First element in <body> -->
<a class="skip-link" href="#main-content">Skip to main content</a>
<!-- Target -->
<main id="main-content" tabindex="-1">
<!-- page content -->
</main>
tabindex="-1" on the <main> target allows focus to be moved programmatically without adding it to the Tab order.
Custom interactive components
Non-native interactive elements must implement keyboard behaviour matching their visual role. The ARIA Authoring Practices Guide (APG) defines expected patterns:
| Component | Open/Activate | Navigate items | Close |
|---|---|---|---|
| Menu button | Enter / Space | Arrow keys | Escape |
| Modal dialog | (triggered by button) | Tab cycles within modal | Escape |
| Tabs | Arrow keys switch tabs | Tab moves to tab panel | — |
| Accordion | Enter / Space | Tab between accordions | — |
| Combobox | Down arrow / typing | Arrow keys | Escape |
| Slider | Left/Right arrows | Home/End for min/max | — |
| Date picker | Enter to open | Arrow keys on calendar | Escape |
| Tree view | Left/Right expand/collapse | Up/Down traverse nodes | — |
Click handler on non-interactive elements
If you must make a non-interactive element clickable, add role, tabindex, and keyboard support:
<!-- Bad: div with only click — not keyboard accessible -->
<div class="card" onclick="openDetail(id)">
<h3>Article title</h3>
</div>
<!-- Option 1: Make the heading a link (usually best) -->
<div class="card">
<h3><a href="/articles/123">Article title</a></h3>
</div>
<!-- Option 2: Full card click area using anchor -->
<a href="/articles/123" class="card">
<h3>Article title</h3>
</a>
<!-- Option 3: Button role (if not a navigation link) -->
<div
class="card"
role="button"
tabindex="0"
onclick="openDetail(id)"
onkeydown="if(e.key==='Enter'||e.key===' '){openDetail(id);e.preventDefault()}"
>
<h3>Article title</h3>
</div>
Focus management
DeveloperWhen content appears or disappears dynamically, focus must move to the right place. Without explicit focus management, keyboard and screen reader users lose orientation.
Modal dialogs
class AccessibleModal {
constructor(modalEl, triggerEl) {
this.modal = modalEl;
this.trigger = triggerEl;
this.focusable = null;
}
open() {
// Prevent background scroll
document.body.style.overflow = 'hidden';
// Show the modal
this.modal.removeAttribute('hidden');
this.modal.setAttribute('aria-modal', 'true');
// Move focus into the modal
const firstFocusable = this.modal.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
// Set up focus trap
this.modal.addEventListener('keydown', this._handleKeydown.bind(this));
// Close on Escape
document.addEventListener('keydown', this._handleEscape.bind(this));
}
close() {
this.modal.setAttribute('hidden', '');
document.body.style.overflow = '';
// Return focus to the trigger
this.trigger.focus();
this.modal.removeEventListener('keydown', this._handleKeydown.bind(this));
document.removeEventListener('keydown', this._handleEscape.bind(this));
}
_handleEscape(e) {
if (e.key === 'Escape') this.close();
}
_handleKeydown(e) {
if (e.key !== 'Tab') return;
const focusableEls = Array.from(this.modal.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), [tabindex="0"]'
));
const first = focusableEls[0];
const last = focusableEls[focusableEls.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
Route changes (single-page applications)
In SPAs, navigating between routes does not trigger a full page load — the browser does not automatically move focus or announce a new page title. Handle this explicitly:
// After each route change:
function onRouteChange(pageTitle) {
// 1. Update the document title
document.title = `${pageTitle} — CanAccess`;
// 2. Move focus to the main content area or new page heading
const main = document.getElementById('main-content');
if (main) {
main.focus(); // main must have tabindex="-1"
}
}
Some SPA frameworks (Next.js, Nuxt) handle this automatically — verify in your specific framework.
Expanding content
When a button expands or reveals content (accordion, disclosure, drawer), the revealed content does not always need to receive focus — but the user must be made aware of it:
<!-- Accordion pattern -->
<h3>
<button
aria-expanded="false"
aria-controls="section-1-content"
id="section-1-header"
>
Who must comply with the AODA?
</button>
</h3>
<div
id="section-1-content"
role="region"
aria-labelledby="section-1-header"
hidden
>
<p>Organizations with one or more employees in Ontario...</p>
</div>
button.addEventListener('click', () => {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
panel.hidden = expanded;
// Focus does not need to move — aria-expanded communicates the change
});
ARIA
DeveloperARIA (Accessible Rich Internet Applications) adds semantic information to HTML when native semantics are insufficient. The most important rule of ARIA is:
ARIA roles
Roles communicate what an element is:
<!-- Only use role when no native HTML alternative exists -->
<div role="alert">Your session will expire in 2 minutes.</div>
<!-- <p> would be better for static text, but role="alert" triggers live announcement -->
<div role="status" aria-live="polite">
12 results found.
</div>
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm deletion</h2>
<!-- ... -->
</div>
ARIA states and properties
States describe the current condition of an element; properties describe characteristics:
<!-- Expanded/collapsed state -->
<button aria-expanded="true" aria-controls="panel">Toggle panel</button>
<!-- Selected state (tabs, listbox items) -->
<button role="tab" aria-selected="true">Vision</button>
<button role="tab" aria-selected="false">Hearing</button>
<!-- Disabled state -->
<button aria-disabled="true">Submit (form incomplete)</button>
<!-- Invalid state -->
<input aria-invalid="true" aria-describedby="field-error">
<!-- Required -->
<input aria-required="true">
<!-- Hidden from accessibility tree -->
<span aria-hidden="true">→</span>
<!-- Described by another element -->
<input aria-describedby="hint postal-error">
<p id="hint">Format: A1A 1A1</p>
<p id="postal-error" role="alert">Enter a valid postal code</p>
Live regions
Live regions announce dynamic content changes to screen readers without moving focus:
<!-- Polite: waits for user to finish current task before announcing -->
<div aria-live="polite" aria-atomic="true">
<!-- Injecting content here will be announced -->
<p>File uploaded successfully.</p>
</div>
<!-- Assertive: interrupts current screen reader speech (use sparingly) -->
<div aria-live="assertive" role="alert">
<!-- Use only for critical errors or urgent notifications -->
<p>Error: your session has expired.</p>
</div>
role="alert" is shorthand for aria-live="assertive" aria-atomic="true". Use it for error messages and urgent notices. Do not use it for routine status updates — it interrupts the screen reader.
Common ARIA mistakes
| Mistake | Correct approach |
|---|---|
role="button" on a <button> | Remove — it is redundant |
aria-label on a <div> with no role | Add a role, or use semantic HTML |
aria-hidden="true" on a focused element | Never hide a focusable element from the accessibility tree |
Positive tabindex values (1, 2, 3…) | Use 0 or -1 only |
<a> without href | Use a <button> for non-navigation actions |
aria-label overriding visible text | aria-label should match or extend visible text — not contradict it |
Adding role="presentation" to a table with data | Only use on truly layout tables |
Navigation patterns
DeveloperPrimary navigation
<nav aria-label="Main navigation">
<ul>
<li>
<a href="/getting-started" aria-current="page">Getting started</a>
</li>
<li>
<a href="/aoda">AODA</a>
</li>
</ul>
</nav>
aria-current="page" communicates the active page link to screen readers. Do not rely on visual styling alone.
Multiple navigation regions
When a page has more than one <nav>, each needs a distinct accessible name:
<nav aria-label="Main navigation"><!-- header nav --></nav>
<nav aria-label="Page contents"><!-- in-page TOC --></nav>
<nav aria-label="Footer links"><!-- footer nav --></nav>
Dropdown menus
Dropdown menus that open on hover must also open on keyboard focus and be navigable by keyboard:
<nav aria-label="Main navigation">
<ul>
<li>
<button
aria-expanded="false"
aria-haspopup="true"
aria-controls="standards-menu"
>
Standards
<span aria-hidden="true">▼</span>
</button>
<ul id="standards-menu" role="menu" hidden>
<li role="none">
<a href="/wcag-overview" role="menuitem">WCAG Overview</a>
</li>
<li role="none">
<a href="/wcag-levels" role="menuitem">WCAG Levels</a>
</li>
</ul>
</li>
</ul>
</nav>
// Keyboard behaviour for disclosure navigation
button.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleMenu();
}
});
menu.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeMenu();
button.focus();
}
// Arrow key navigation within menu items
if (e.key === 'ArrowDown') {
e.preventDefault();
focusNextItem();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
focusPreviousItem();
}
});
Breadcrumbs
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/disability-types">Disability types</a></li>
<li><a href="/vision" aria-current="page">Vision disabilities</a></li>
</ol>
</nav>
Use <ol> (ordered list) because breadcrumb order is meaningful. Use aria-current="page" on the current page item.
Images in code
DeveloperSee Images and Media for full guidance. Key implementation reminders:
<!-- Informative image -->
<img src="aoda-logo.png" alt="AODA — Accessibility for Ontarians with Disabilities Act">
<!-- Decorative image -->
<img src="divider.svg" alt="" role="presentation">
<!-- SVG icon used as button label -->
<button aria-label="Close">
<svg aria-hidden="true" focusable="false"><!-- paths --></svg>
</button>
<!-- Informative inline SVG -->
<svg role="img" aria-labelledby="svg-title">
<title id="svg-title">Bar chart: AODA compliance by sector</title>
<!-- paths -->
</svg>
Code quality and tooling
Linting
Add accessibility linting to your development workflow to catch issues early:
- eslint-plugin-jsx-a11y — for React/JSX projects
- axe-core — programmatic accessibility testing; integrates with Jest, Cypress, Playwright
- Storybook a11y addon — accessibility checks in component development
Browser DevTools
Every major browser includes accessibility tools:
- Chrome/Edge: DevTools → Elements → Accessibility panel (inspect the accessibility tree for any element)
- Firefox: DevTools → Accessibility panel (full accessibility tree with issues listed)
- Safari: Develop → Show Web Inspector → Node tab → Accessibility
The accessibility tree view shows exactly what a screen reader encounters — use it to verify that your roles, names, and states are correct.
Related pages
- Testing and Evaluation — how to test accessibility across automated, keyboard, and screen reader methods
- Design Principles — focus states, colour, motion, and responsive design guidance
- Images and Media — alt text, captions, transcripts, and audio descriptions
- Mobility and Dexterity Disabilities — keyboard and switch access requirements
- Cognitive and Learning Disabilities — forms and cognitive load reduction