From ui-excellence
Use when building or reviewing UI components to ensure WCAG 2.1 compliance, keyboard navigation, semantic HTML, ARIA attributes, color contrast, and assistive technology support.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ui-excellence:accessibilityThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill guides implementation and review of accessible UI components following **WCAG 2.1 Level AA standards**. Accessibility ensures your interface is usable by everyone—keyboard users, screen reader users, users with low vision, and users with cognitive disabilities.
This skill guides implementation and review of accessible UI components following WCAG 2.1 Level AA standards. Accessibility ensures your interface is usable by everyone—keyboard users, screen reader users, users with low vision, and users with cognitive disabilities.
Core principle: Semantic HTML first, ARIA last. Keyboard navigation and screen reader support are non-negotiable.
Foundation: Use meaningful HTML elements to convey structure and purpose.
<!-- ✅ CORRECT: Semantic structure -->
<header role="banner">
<nav aria-label="Main navigation">
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<main id="main-content">
<article>
<h1>Page Title</h1>
<section>
<h2>Section Heading</h2>
<p>Content...</p>
</section>
</article>
</main>
<footer>
<p>© 2026 Company</p>
</footer>
<!-- ✅ CORRECT: Proper heading hierarchy -->
<h1>Main Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
<!-- ❌ WRONG: Non-semantic div soup -->
<div class="header">
<div class="nav">
<div>Home</div>
<div>About</div>
</div>
</div>
<div class="main">
<div class="title">Page Title</div>
</div>
<!-- ❌ WRONG: Skipping heading hierarchy -->
<h1>Main Title</h1>
<h3>Jumps to h3 (breaks hierarchy)</h3>
Label Association Rules:
<!-- ✅ CORRECT: Explicit label-input association -->
<label for="email-input">Email Address</label>
<input id="email-input" type="email" required />
<!-- ✅ CORRECT: Implicit label wrapping -->
<label>
Email Address
<input type="email" required />
</label>
<!-- ❌ WRONG: Placeholder alone (insufficient) -->
<input type="email" placeholder="Enter email" />
<!-- Screen readers read placeholder only on focus; users don't see it in context -->
<!-- ❌ WRONG: No association -->
<label>Email Address</label>
<input type="email" />
Principle: All interactive features usable via keyboard—Tab, Shift+Tab, Enter, Space, Arrow keys, Escape.
Key Bindings:
Focus Management:
<!-- ✅ CORRECT: Explicit focus for interactive elements -->
<button tabindex="0">Click me</button>
<!-- ✅ CORRECT: Programmatic focus (not in tab order) -->
<div tabindex="-1" id="skip-target">Skip to main content</div>
<!-- ❌ WRONG: Positive tabindex > 0 (breaks tab order) -->
<button tabindex="1">First</button>
<button tabindex="2">Second</button>
<!-- Order now: tabindex 1, 2, then remaining DOM order (confusing!) -->
<!-- ❌ WRONG: Removed focus outline -->
button:focus {
outline: none; /* NEVER DO THIS */
}
Skip Links (Focus Management):
<!-- Place at TOP of page, hidden by default -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<style>
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
}
.skip-link:focus {
left: 0;
top: 0;
background: #000;
color: #fff;
padding: 8px 12px;
}
</style>
<main id="main-content" tabindex="-1">
<!-- Content -->
</main>
Golden Rule: Semantic HTML first. Use ARIA only when native HTML doesn't exist or to enhance semantics.
| Attribute | Purpose | Example |
|---|---|---|
aria-label | Names element (for icons, buttons without text) | <button aria-label="Close menu">×</button> |
aria-labelledby | Links to another element as label | <h2 id="dialog-title">Confirm Delete</h2><div aria-labelledby="dialog-title">... |
aria-describedby | Adds descriptive text (error, hint) | <input aria-describedby="pwd-hint" /><p id="pwd-hint">Min 8 chars |
aria-live | Announces dynamic content ("polite" or "assertive") | <div aria-live="polite">Item added to cart</div> |
aria-hidden | Hides from screen readers (decorative only) | <span aria-hidden="true">→</span> |
role | Semantic role | <div role="dialog">... |
aria-expanded | Indicates expanded/collapsed state | <button aria-expanded="false">Menu</button> |
aria-haspopup | Signals a popup exists | <button aria-haspopup="menu">Options</button> |
aria-selected | Current selection in list/tabs | <button role="tab" aria-selected="true">Tab 1</button> |
aria-modal | Marks modal dialogs | <div role="dialog" aria-modal="true">... |
aria-required | Flags required form fields | <input aria-required="true" /> |
aria-invalid | Indicates validation error | <input aria-invalid="true" aria-describedby="error-msg" /> |
aria-atomic | Reads entire content block | <div aria-live="polite" aria-atomic="true">Status: Updated</div> |
WCAG Contrast Ratios:
| Level | Normal Text | Large Text (18pt+) |
|---|---|---|
| AA (minimum) | 4.5:1 | 3:1 |
| AAA (enhanced) | 7:1 | 4.5:1 |
Visual Focus Indicators (Required):
/* ✅ CORRECT: Visible focus indicator */
button:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* ✅ CORRECT: Alternative (background change) */
button:focus {
background-color: #e6f2ff;
box-shadow: inset 0 0 0 2px #0066cc;
}
/* ❌ WRONG: Removing focus outline */
button:focus {
outline: none; /* FORBIDDEN */
}
/* ❌ WRONG: Low contrast focus */
button:focus {
outline: 1px solid #ccc; /* Too subtle */
}
Never Convey Info by Color Alone:
<!-- ❌ WRONG: Color only -->
<span style="color: red;">This field is required</span>
<!-- ✅ CORRECT: Color + icon/text -->
<span style="color: red;">
<span aria-hidden="true">*</span> This field is required
</span>
<!-- ✅ CORRECT: Color + pattern -->
<input type="checkbox" style="border: 2px solid red;" />
<label>I agree (required)</label>
Automated Tools:
Manual Testing:
Acceptance Criteria:
import { useState, useRef, useEffect } from 'react';
export function AccessibleDropdown() {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const buttonRef = useRef(null);
const menuRef = useRef(null);
const options = ['Edit', 'Duplicate', 'Delete'];
// Close on Escape
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
buttonRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
// Focus management for arrow keys
const handleMenuKeyDown = (e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((i) => (i + 1) % options.length);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((i) => (i - 1 + options.length) % options.length);
break;
case 'Home':
e.preventDefault();
setSelectedIndex(0);
break;
case 'End':
e.preventDefault();
setSelectedIndex(options.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
handleSelect(options[selectedIndex]);
break;
default:
break;
}
};
const handleSelect = (option) => {
console.log('Selected:', option);
setIsOpen(false);
buttonRef.current?.focus();
};
return (
<div className="dropdown">
<button
ref={buttonRef}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls="dropdown-menu"
onClick={() => setIsOpen(!isOpen)}
>
Options
</button>
{isOpen && (
<ul
id="dropdown-menu"
ref={menuRef}
role="menu"
onKeyDown={handleMenuKeyDown}
>
{options.map((option, i) => (
<li key={option} role="none">
<button
role="menuitem"
aria-selected={i === selectedIndex}
onMouseEnter={() => setSelectedIndex(i)}
onClick={() => handleSelect(option)}
>
{option}
</button>
</li>
))}
</ul>
)}
</div>
);
}
import { useEffect, useRef } from 'react';
export function AccessibleModal({ isOpen, onClose, title, children }) {
const dialogRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
// Store the element that opened the modal for focus restore
const previouslyFocused = document.activeElement;
// Focus the modal
dialogRef.current?.focus();
// Focus trap: trap focus inside modal
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose();
previouslyFocused?.focus();
return;
}
// Trap Tab inside modal
if (e.key === 'Tab') {
const focusableElements = dialogRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previouslyFocused?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="modal-backdrop"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
className="modal"
tabIndex="-1"
>
<h2 id="modal-title">{title}</h2>
<div id="modal-description">{children}</div>
<button onClick={onClose}>Close</button>
</div>
</>
);
}
export function AccessibleAlert({ type, message, onDismiss }) {
// type: 'error' | 'success' | 'warning' | 'info'
const isError = type === 'error';
return (
<div
role="alert"
aria-live={isError ? 'assertive' : 'polite'}
aria-atomic="true"
className={`alert alert-${type}`}
>
<span aria-hidden="true">
{type === 'error' && '⚠️'}
{type === 'success' && '✓'}
{type === 'warning' && '⚡'}
{type === 'info' && 'ℹ️'}
</span>
{message}
{onDismiss && (
<button
aria-label="Dismiss notification"
onClick={onDismiss}
>
×
</button>
)}
</div>
);
}
import { useState } from 'react';
export function AccessibleForm() {
const [formData, setFormData] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Email must follow format: [email protected]';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validate()) {
console.log('Form submitted:', formData);
}
};
return (
<form onSubmit={handleSubmit} noValidate>
{/* Email Field */}
<div className="form-group">
<label htmlFor="email-input">
Email Address <span aria-hidden="true">*</span>
</label>
<input
id="email-input"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
{/* Password Field */}
<div className="form-group">
<label htmlFor="password-input">
Password <span aria-hidden="true">*</span>
</label>
<input
id="password-input"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
aria-required="true"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : 'password-hint'}
/>
<p id="password-hint" className="hint">
Minimum 8 characters
</p>
{errors.password && (
<span id="password-error" role="alert" className="error">
{errors.password}
</span>
)}
</div>
<button type="submit">Sign In</button>
</form>
);
}
import { useState } from 'react';
export function AccessibleTabs() {
const [activeTab, setActiveTab] = useState(0);
const tabs = [
{ label: 'Overview', content: 'Overview content here' },
{ label: 'Details', content: 'Details content here' },
{ label: 'Related', content: 'Related items here' },
];
const handleTabKeyDown = (e, index) => {
let nextIndex = index;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
nextIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'ArrowRight':
e.preventDefault();
nextIndex = (index + 1) % tabs.length;
break;
case 'Home':
e.preventDefault();
nextIndex = 0;
break;
case 'End':
e.preventDefault();
nextIndex = tabs.length - 1;
break;
default:
return;
}
setActiveTab(nextIndex);
// Focus the newly active tab
document.getElementById(`tab-${nextIndex}`)?.focus();
};
return (
<div className="tabs">
{/* Tablist */}
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, i) => (
<button
key={i}
id={`tab-${i}`}
role="tab"
aria-selected={activeTab === i}
aria-controls={`tabpanel-${i}`}
tabIndex={activeTab === i ? 0 : -1}
onClick={() => setActiveTab(i)}
onKeyDown={(e) => handleTabKeyDown(e, i)}
>
{tab.label}
</button>
))}
</div>
{/* Tabpanels */}
{tabs.map((tab, i) => (
<div
key={i}
id={`tabpanel-${i}`}
role="tabpanel"
aria-labelledby={`tab-${i}`}
hidden={activeTab !== i}
>
{tab.content}
</div>
))}
</div>
);
}
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { AccessibleForm } from './AccessibleForm';
expect.extend(toHaveNoViolations);
describe('Accessibility Tests', () => {
test('form has no accessibility violations', async () => {
const { container } = render(<AccessibleForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('form is keyboard navigable', () => {
render(<AccessibleForm />);
const emailInput = screen.getByLabelText('Email Address');
const passwordInput = screen.getByLabelText('Password');
const submitButton = screen.getByRole('button', { name: /sign in/i });
// Tab focus order
emailInput.focus();
expect(document.activeElement).toBe(emailInput);
// Tab to password
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab' });
emailInput.dispatchEvent(tabEvent);
// Can activate button with Space/Enter
submitButton.focus();
const spaceEvent = new KeyboardEvent('keydown', { key: ' ' });
submitButton.dispatchEvent(spaceEvent);
});
test('modal traps focus', () => {
const { rerender } = render(
<AccessibleModal isOpen={true} onClose={() => {}} title="Test Modal">
<button>Action 1</button>
<button>Action 2</button>
</AccessibleModal>
);
const buttons = screen.getAllByRole('button');
const lastButton = buttons[buttons.length - 1];
// Focus trap: Tab on last button should go to first
lastButton.focus();
const tabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: false,
});
expect(lastButton === document.activeElement);
});
test('alert is announced by screen readers', () => {
render(
<AccessibleAlert
type="success"
message="Item saved successfully"
/>
);
const alert = screen.getByRole('alert');
expect(alert).toHaveAttribute('aria-live', 'polite');
expect(alert).toHaveAttribute('aria-atomic', 'true');
});
});
| User Action | Key(s) | Component | Purpose |
|---|---|---|---|
| Navigate | Tab / Shift+Tab | All | Focus previous/next |
| Activate | Enter / Space | Button, Checkbox, Radio | Trigger action |
| Navigate list | ↑↓ | Menu, Listbox, Select | Move between items |
| Navigate horizontal | ← → | Tabs, Slider, Menu | Move between options |
| Go to start | Home | Tabs, List, Slider | Jump to first item |
| Go to end | End | Tabs, List, Slider | Jump to last item |
| Close/Cancel | Escape | Modal, Dropdown, Menu | Dismiss overlay |
| Alphabetic | a–z | Listbox, Combobox | Jump to matching item |
| Role | Element | Usage |
|---|---|---|
button | <div>, <span> (non-button) | Make non-button interactive |
menu / menuitem | <ul>, <li> | Popup/context menu |
listbox / option | Custom dropdown | Accessible select replacement |
tab / tablist / tabpanel | Tab UI | Tab container and panels |
dialog | Modal / overlay | Modal dialog (+ aria-modal) |
alert | Notification | Assertive/polite announcement |
region | Generic section | Landmark region (aria-label required) |
navigation | Nav links | Main/secondary navigation |
| Scenario | Min Ratio | Example |
|---|---|---|
| Body text (AA) | 4.5:1 | #333 on #fff = 12.6:1 ✓ |
| Large text (AA) | 3:1 | #666 on #fff = 5.74:1 ✓ |
| Icon/UI (AA) | 3:1 | #0066cc on #fff = 8.59:1 ✓ |
| Body text (AAA) | 7:1 | #555 on #fff = 9.26:1 ✓ |
Use this checklist before marking a component accessible:
<button> for buttons, not <div onclick><nav>, <main>, <header>, <footer> for landmarks<h1> → <h2> → <h3>, no skips)<label> or aria-labelalt or decorative alt=""outline: none without replacement focus indicatoraria-live used for dynamic announcementsaria-hidden only on decorative elementsrole="dialog" + aria-modal="true"aria-describedbyaria-required// WRONG: Placeholder disappears on focus
<input type="email" placeholder="Enter your email" />
// CORRECT: Explicit label
<label htmlFor="email">Email</label>
<input id="email" type="email" />
Why: Placeholders are hidden when users focus the input. Screen reader users won't hear what the field is for.
outline: none Without Replacement/* WRONG: Keyboard users can't see focus */
button:focus {
outline: none;
}
/* CORRECT: Provide visible focus */
button:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
Why: Keyboard users rely on focus indicators to navigate. Removing it breaks navigation.
tabindex// WRONG: Breaks natural tab order
<button tabindex="2">Second (confusing!)</button>
<button tabindex="1">First</button>
<button>Third (where does it go?)</button>
// CORRECT: Use tabindex 0 or -1 only
<button>First</button> // tabindex 0 (implicit)
<button>Second</button>
<button tabindex="-1">Hidden from tab order</button>
Why: Positive tabindex disrupts the natural DOM order. Screen reader and keyboard users get confused.
<!-- WRONG: Only color indicates required -->
<input style="border: 1px solid red;" />
<!-- CORRECT: Color + icon/text -->
<label>
Email <span aria-hidden="true">*</span>
</label>
<input />
Why: Users with color blindness can't see the distinction. Pair color with icons or text.
// WRONG: Focus can escape the modal
<div role="dialog">
<button>Close</button>
</div>
// CORRECT: Focus is trapped
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Tab') {
// Trap focus inside modal
}
};
document.addEventListener('keydown', handleKeyDown);
}, []);
Why: Keyboard users can accidentally tab out of a modal, losing context.
<!-- WRONG: No alt text -->
<img src="chart.png" />
<!-- CORRECT: Meaningful alt -->
<img src="chart.png" alt="Sales increased 25% in Q3" />
<!-- CORRECT: Decorative image -->
<img src="spacer.png" alt="" aria-hidden="true" />
Why: Screen reader users hear "image, chart-dot-png" with no context. Provide descriptive alt text.
// WRONG: Error exists but not linked
<input type="email" />
<span>Invalid email format</span>
// CORRECT: Error linked with aria-describedby
<input
id="email"
type="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<span id="email-error" role="alert">Invalid email format: [email protected]</span>
Why: Screen reader users won't know the error is related to the input. Link it explicitly.
// WRONG: Item added silently
<div>Items in cart: 5</div>
// CORRECT: Announce change
<div aria-live="polite" aria-atomic="true">
Item added to cart. Items: 5
</div>
Why: Screen reader users don't hear dynamic updates. Use aria-live to announce.
Accessibility is not a feature—it's a requirement. Every component must be:
Start with semantic HTML. Add ARIA only when needed. Test with real tools and real users.
Last updated: 2026-03-30 Version: 1.0.0
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub fernando-bertholdo/4-successful-ai-life --plugin ui-excellence