~/.claude/skills/ or any Agent-Skills-compatible runtime for AI-assisted Accessibility work.
Accessibility
- Ensuring web content is usable by people with disabilities
- Semantic HTML, ARIA attributes, and keyboard navigation
- Screen reader compatibility and alternative content
Key Insight
Accessibility (a11y) isn’t an “add-on” feature—it’s fundamental architecture. Using semantic HTML (<button> not <div onclick>) gives you keyboard navigation, focus management, and screen reader support for free. ARIA attributes don’t add functionality; they add semantics for assistive technology when semantic HTML doesn’t exist (<div role="button"> tells screen readers it’s a button, but you still need to add keyboard handlers). The four POUR principles guide everything: Perceivable (alternative text for images), Operable (keyboard navigation), Understandable (clear labels, error messages), Robust (works with assistive technology). Most accessibility bugs stem from three mistakes: non-semantic HTML (<div> instead of <button>), missing ARIA labels, and forgetting keyboard users exist.
Detailed Description
Web accessibility means designing and developing websites, tools, and technologies so that people with disabilities can use them — auditory, cognitive, neurological, physical, speech, or visual. The same work also benefits people without disabilities: mobile users, older users, and anyone with a temporary or situational limitation (broken arm, bright sunlight, a baby in the other hand). The cross-template Input atom shown below — a <label class="sr-only"> paired with a real <input> — is the smallest unit of that work: one element with a real role, one label the eye can’t see but a screen reader can.
Why Accessibility Matters:
- Legal compliance: Many countries require accessibility (ADA in US, EAA in EU)
- Larger audience: 15% of world population has some form of disability
- Better UX for everyone: Captions help in noisy environments, high contrast helps in sunlight
- SEO benefits: Semantic HTML and alt text improve search rankings
- Ethical responsibility: The web should be accessible to all
Core Principles (POUR):
- Perceivable: Information must be presentable to users in ways they can perceive
- Alternative text for images
- Captions for videos
- Sufficient color contrast
- Operable: UI components must be operable
- Keyboard accessible
- Enough time to read/use content
- No content that causes seizures
- Understandable: Information and UI operation must be understandable
- Readable text
- Predictable behavior
- Input assistance (error messages, labels)
- Robust: Content must work with current and future assistive technologies
- Valid HTML
- ARIA attributes
- Progressive enhancement
Key Technologies:
- Semantic HTML: Use the right element for the job
- ARIA (Accessible Rich Internet Applications): Adds semantics when HTML falls short
- Keyboard Navigation: Tab, Enter, Space, Arrow keys
- Screen Readers: NVDA, JAWS, VoiceOver, TalkBack
- Color Contrast: WCAG AA (4.5:1 normal text, 3:1 large text)
Code Examples
Basic Example: Accessible atoms across frameworks
Every chota-* template ships the same two accessibility patterns: native semantics for interactive controls (<button>, <a> — never <div onclick>), and a screen-reader-only <label> paired to every <input>. Here’s how each framework expresses both, lifted from the real templates.
The labelled-input pattern is the most interesting, because the four frameworks each spell “visually hidden but accessible” differently while producing the same DOM output.
React
// templates/chota-react-redux/src/ui/atoms/Input/Input.component.jsx
// Native <input> + <label htmlFor=...> + className="sr-only" so the label
// is read by AT but invisible. The atom auto-generates an id if the
// caller didn't supply one, so the htmlFor/id link is always intact.
export default function Input(props) {
let id = props.id;
if (!id) id = Math.random();
return (
<>
<label htmlFor={id} className="sr-only">
{props.name || "Some Label"}
</label>
<input {...props} id={id} />
</>
);
}
Angular
<!-- templates/chota-angular-ngrx/src/ui/atoms/Input/Input.component.html
Same DOM shape: <label class="sr-only" [for]="id"> wraps a unique id
onto the input. Angular makes the for/id binding explicit. -->
<label [for]="id" class="sr-only"></label>
<input
[id]="id"
[type]="type"
[value]="value"
[checked]="checked"
[disabled]="disabled"
[placeholder]="placeholder"
(input)="onInput($event)"
/>
Vue
<!-- templates/chota-vue-pinia/src/ui/atoms/Input/Input.component.vue
Vue uses :for with v-bind. The label sits above the input in the
template and inherits the same sr-only class scoped to the SFC. -->
<template>
<label :for="id" class="sr-only">{{ name || 'Some Label' }}</label>
<input
:id="id"
:name="name"
:type="type || 'text'"
:disabled="disabled"
:placeholder="placeholder"
:value="value"
:checked="checked"
@input="$emit('onInput', $event)"
/>
</template>
Web Components
// templates/chota-wc-saga/src/ui/atoms/Input/Input.component.js
// Lit-html builds the same <label> + <input>. Note the for-attribute is
// spelled htmlFor here for consistency with the React tab, but `for`
// also works in Lit — both produce the same attribute on the rendered
// element.
import { html } from "lit";
import emit from "../../../utils/events/emit";
export default function Input(props) {
let id = props.id;
if (!id) id = Math.random();
return html`
<label htmlFor=${id} class="sr-only">
${props.name || "Some Label"}
</label>
<input
type=${props.type}
.value=${props.value}
.checked=${props.checked}
id=${id}
name=${props.name}
placeholder=${props.placeholder}
@input=${(e) => emit(this, "onInput", e)}
/>
`;
}
A few accessibility-relevant things to read across the tabs:
- Native
<button>everywhere. Every template’sButtonatom renders a real<button>(not a click-handled<div>), which inherits keyboard activation, focus visibility, and thebuttonrole for free. Same for<a href>for links. - Every input gets a label. None of the templates ship an
<input>with no associated label — even when the visual design has none, thesr-onlyclass plushtmlFor/forkeeps the input accessible to screen readers and voice control. - Alt text is mandatory on the Image atom. All four implementations propagate
props.altstraight to the underlying<img alt>. TheIconButtonmolecule passes a meaningful alt through ("close","search", etc.) so the icon-only button gets an accessible name. disabledis a property, not a class. All four templates set the realdisabledproperty on<button>and<input>rather than fading it visually, so AT correctly announces the disabled state and Tab navigation skips it.- The
Math.random()id is a placeholder, not a recommendation. It guarantees thehtmlFor/idlink exists when the caller forgot to pass one, but production code should swap it foruseId(React 18+), Angular’sinject(IdGeneratorService)pattern, orcrypto.randomUUID()so SSR markup stays stable across hydrations.
The framework wrappers diverge a lot. The DOM doesn’t.
Practical Example: ARIA Patterns for Custom Components
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Accessible Custom Components</title>
<style>
/* Focus visible styles */
*:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* Skip to main content link */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
}
.skip-link:focus {
top: 0;
}
/* Visually hidden but accessible to screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>
</head>
<body>
<!-- Skip link for keyboard users -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav aria-label="Main navigation">
<!-- Dropdown menu -->
<button
id="menu-button"
aria-expanded="false"
aria-controls="menu-list"
aria-haspopup="true">
Menu
<span aria-hidden="true">▼</span>
</button>
<ul id="menu-list" role="menu" hidden>
<li role="none">
<a href="/home" role="menuitem">Home</a>
</li>
<li role="none">
<a href="/about" role="menuitem">About</a>
</li>
</ul>
</nav>
<main id="main-content">
<!-- Accessible modal dialog -->
<button id="open-dialog">Open Dialog</button>
<div
id="dialog"
role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
aria-modal="true"
hidden>
<h2 id="dialog-title">Confirm Action</h2>
<p id="dialog-desc">Are you sure you want to proceed?</p>
<button id="confirm-btn">Confirm</button>
<button id="cancel-btn">Cancel</button>
</div>
<!-- Accessible tabs -->
<div class="tabs">
<div role="tablist" aria-label="Content sections">
<button
role="tab"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
tabindex="0">
Tab 1
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
tabindex="-1">
Tab 2
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
Content for tab 1
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
Content for tab 2
</div>
</div>
<!-- Live region for announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only" id="announcements"></div>
</main>
<script>
// Accessible dropdown menu
const menuButton = document.getElementById('menu-button');
const menuList = document.getElementById('menu-list');
menuButton.addEventListener('click', () => {
const expanded = menuButton.getAttribute('aria-expanded') === 'true';
menuButton.setAttribute('aria-expanded', !expanded);
menuList.hidden = expanded;
if (!expanded) {
menuList.querySelector('[role="menuitem"]').focus();
}
});
// Close menu on Escape
menuList.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
menuButton.setAttribute('aria-expanded', 'false');
menuList.hidden = true;
menuButton.focus();
}
});
// Accessible modal dialog
const openDialog = document.getElementById('open-dialog');
const dialog = document.getElementById('dialog');
const confirmBtn = document.getElementById('confirm-btn');
const cancelBtn = document.getElementById('cancel-btn');
let previousFocus;
openDialog.addEventListener('click', () => {
previousFocus = document.activeElement; // Save focus
dialog.hidden = false;
confirmBtn.focus(); // Move focus to dialog
// Trap focus inside dialog
document.addEventListener('keydown', trapFocus);
});
function closeDialog() {
dialog.hidden = true;
previousFocus.focus(); // Restore focus
document.removeEventListener('keydown', trapFocus);
}
confirmBtn.addEventListener('click', closeDialog);
cancelBtn.addEventListener('click', closeDialog);
function trapFocus(e) {
if (e.key === 'Tab') {
const focusableElements = dialog.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
} else if (e.key === 'Escape') {
closeDialog();
}
}
// Accessible tabs with arrow key navigation
const tabs = document.querySelectorAll('[role="tab"]');
const panels = document.querySelectorAll('[role="tabpanel"]');
tabs.forEach((tab, index) => {
tab.addEventListener('click', () => {
activateTab(index);
});
tab.addEventListener('keydown', (e) => {
let newIndex = index;
if (e.key === 'ArrowRight') {
newIndex = (index + 1) % tabs.length;
} else if (e.key === 'ArrowLeft') {
newIndex = (index - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
newIndex = 0;
} else if (e.key === 'End') {
newIndex = tabs.length - 1;
} else {
return;
}
e.preventDefault();
activateTab(newIndex);
tabs[newIndex].focus();
});
});
function activateTab(index) {
tabs.forEach((t, i) => {
t.setAttribute('aria-selected', i === index);
t.tabIndex = i === index ? 0 : -1;
});
panels.forEach((p, i) => {
p.hidden = i !== index;
});
// Announce to screen readers
document.getElementById('announcements').textContent = `Switched to ${tabs[index].textContent}`;
}
</script>
</body>
</html>
Advanced Example: Accessible Data Table and Form Validation
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Accessible Table and Forms</title>
<style>
/* Error styling */
.error {
color: #d32f2f;
font-size: 0.875rem;
margin-top: 0.25rem;
}
[aria-invalid="true"] {
border-color: #d32f2f;
}
/* Table styling */
table {
border-collapse: collapse;
width: 100%;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f0f0f0;
}
/* Sortable column indicator */
th[aria-sort] {
cursor: pointer;
user-select: none;
}
th[aria-sort]::after {
content: ' ⇅';
color: #999;
}
th[aria-sort="ascending"]::after {
content: ' ↑';
color: #000;
}
th[aria-sort="descending"]::after {
content: ' ↓';
color: #000;
}
</style>
</head>
<body>
<!-- Accessible form with validation -->
<form id="registration-form" novalidate>
<h2>Registration Form</h2>
<!-- Required field with pattern validation -->
<div>
<label for="email">Email <span aria-label="required">*</span></label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid="false"
aria-describedby="email-error">
<div id="email-error" role="alert" class="error"></div>
</div>
<!-- Password with requirements -->
<div>
<label for="password">
Password <span aria-label="required">*</span>
</label>
<input
type="password"
id="password"
name="password"
required
aria-required="true"
aria-invalid="false"
aria-describedby="password-help password-error"
minlength="8">
<div id="password-help" class="help-text">
Must be at least 8 characters
</div>
<div id="password-error" role="alert" class="error"></div>
</div>
<!-- Fieldset for related inputs -->
<fieldset>
<legend>Contact Preference</legend>
<label>
<input type="radio" name="contact" value="email" required>
Email
</label>
<label>
<input type="radio" name="contact" value="phone">
Phone
</label>
</fieldset>
<button type="submit">Register</button>
<!-- Form-level error summary -->
<div id="error-summary" role="alert" aria-live="assertive" hidden>
<h3>Please correct the following errors:</h3>
<ul id="error-list"></ul>
</div>
</form>
<!-- Accessible data table -->
<table>
<caption>User List (sortable)</caption>
<thead>
<tr>
<th scope="col" aria-sort="none" data-column="name">
Name
</th>
<th scope="col" aria-sort="none" data-column="email">
Email
</th>
<th scope="col" aria-sort="none" data-column="role">
Role
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Alice Johnson</th>
<td>alice@example.com</td>
<td>Admin</td>
</tr>
<tr>
<th scope="row">Bob Smith</th>
<td>bob@example.com</td>
<td>User</td>
</tr>
<tr>
<th scope="row">Carol White</th>
<td>carol@example.com</td>
<td>Editor</td>
</tr>
</tbody>
</table>
<script>
// Form validation
const form = document.getElementById('registration-form');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const errorSummary = document.getElementById('error-summary');
const errorList = document.getElementById('error-list');
form.addEventListener('submit', (e) => {
e.preventDefault();
const errors = [];
// Validate email
if (!emailInput.value) {
errors.push({ field: emailInput, message: 'Email is required' });
} else if (!emailInput.validity.valid) {
errors.push({ field: emailInput, message: 'Email must be valid' });
}
// Validate password
if (!passwordInput.value) {
errors.push({ field: passwordInput, message: 'Password is required' });
} else if (passwordInput.value.length < 8) {
errors.push({ field: passwordInput, message: 'Password must be at least 8 characters' });
}
// Clear previous errors
document.querySelectorAll('.error').forEach(el => el.textContent = '');
document.querySelectorAll('[aria-invalid="true"]').forEach(el => {
el.setAttribute('aria-invalid', 'false');
});
if (errors.length > 0) {
// Show errors
errorList.innerHTML = '';
errors.forEach(({ field, message }) => {
// Set field error
const errorEl = document.getElementById(`${field.id}-error`);
errorEl.textContent = message;
field.setAttribute('aria-invalid', 'true');
// Add to summary
const li = document.createElement('li');
const link = document.createElement('a');
link.href = `#${field.id}`;
link.textContent = message;
link.addEventListener('click', (e) => {
e.preventDefault();
field.focus();
});
li.appendChild(link);
errorList.appendChild(li);
});
errorSummary.hidden = false;
errorSummary.focus();
} else {
errorSummary.hidden = true;
console.log('Form submitted successfully');
}
});
// Real-time validation on blur
[emailInput, passwordInput].forEach(input => {
input.addEventListener('blur', () => {
const errorEl = document.getElementById(`${input.id}-error`);
if (input.validity.valid) {
input.setAttribute('aria-invalid', 'false');
errorEl.textContent = '';
}
});
});
// Sortable table
const headers = document.querySelectorAll('th[aria-sort]');
headers.forEach(header => {
header.addEventListener('click', () => {
sortTable(header);
});
header.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
sortTable(header);
}
});
});
function sortTable(header) {
const currentSort = header.getAttribute('aria-sort');
const newSort = currentSort === 'ascending' ? 'descending' : 'ascending';
// Reset other headers
headers.forEach(h => h.setAttribute('aria-sort', 'none'));
header.setAttribute('aria-sort', newSort);
// Sort logic would go here
console.log(`Sorting ${header.dataset.column} ${newSort}`);
}
</script>
</body>
</html>
Common Mistakes
1. Using <div> or <span> Instead of Semantic Elements
Mistake: Creating fake buttons/links with divs.
<!-- ❌ BAD: No keyboard support, focus, or screen reader semantics -->
<div class="button" onclick="submit()">Submit</div>
<span class="link" onclick="navigate()">Read more</span>
<!-- User can't Tab to these, Enter doesn't work, screen reader says "Submit clickable" not "Submit button" -->
<!-- ✅ GOOD: Use semantic HTML -->
<button onclick="submit()">Submit</button>
<a href="/article">Read more</a>
<!-- If you MUST use div (rare), add all accessibility manually -->
<div
role="button"
tabindex="0"
onclick="submit()"
onkeydown="if(event.key==='Enter'||event.key===' ')submit()">
Submit
</div>
Why it matters: Semantic HTML provides keyboard navigation, focus management, and screen reader support automatically.
2. Missing Alternative Text for Images
Mistake: No alt text or meaningless alt text.
<!-- ❌ BAD: Screen reader says "image" (no context) -->
<img src="chart.png">
<!-- ❌ BAD: Meaningless alt text -->
<img src="chart.png" alt="image">
<img src="chart.png" alt="chart.png">
<!-- ❌ BAD: Redundant "image of" or "picture of" -->
<img src="chart.png" alt="Image of sales chart">
<!-- ✅ GOOD: Descriptive alt text -->
<img src="chart.png" alt="Sales increased 25% in Q4 2024">
<!-- ✅ GOOD: Decorative images (screen reader skips) -->
<img src="decoration.png" alt="" role="presentation">
<!-- ✅ GOOD: Complex images with longdesc -->
<img src="complex-chart.png" alt="Detailed sales data" aria-describedby="chart-desc">
<div id="chart-desc" class="sr-only">
Sales data showing 25% increase in Q4, with largest growth in Europe region...
</div>
Why it matters: Screen reader users rely on alt text to understand images.
3. Missing ARIA Labels on Custom Components
Mistake: Interactive elements without accessible names.
<!-- ❌ BAD: Screen reader says "button" (no label) -->
<button onclick="closeDialog()">
<svg>...</svg> <!-- Only an icon, no text -->
</button>
<!-- ✅ GOOD: aria-label for icon buttons -->
<button onclick="closeDialog()" aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
<!-- ✅ GOOD: Visually hidden text -->
<button onclick="closeDialog()">
<span class="sr-only">Close dialog</span>
<svg aria-hidden="true">...</svg>
</button>
<!-- ❌ BAD: No label for custom select -->
<div role="combobox" aria-expanded="false">
<div class="selected-option">Choose...</div>
</div>
<!-- ✅ GOOD: Label association -->
<label id="country-label">Country</label>
<div
role="combobox"
aria-labelledby="country-label"
aria-expanded="false">
<div class="selected-option">Choose...</div>
</div>
Why it matters: Screen readers can’t announce unlabeled interactive elements meaningfully.