Install
$ agentstack add skill-fernando-bertholdo-4-successful-ai-life-accessibility ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
UI Accessibility (WCAG 2.1)
Overview
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.
When to Use / When NOT to Use
✅ Use This Skill When
- Building new UI components (buttons, modals, dropdowns, forms, tabs)
- Adding keyboard navigation or focus management
- Reviewing code for accessibility violations
- Testing with screen readers or keyboard-only
- Ensuring color contrast and visual focus indicators
- Adding ARIA attributes to dynamic content
❌ Do NOT Use When
- Component is purely decorative (no interaction needed)
- Audience explicitly doesn't include users with accessibility needs (internally impossible—always assume diverse users)
- You're skipping automated testing due to time constraints (violates DoD)
Core Concepts
1. Semantic HTML Structure
Foundation: Use meaningful HTML elements to convey structure and purpose.
Home
About
Page Title
Section Heading
Content...
© 2026 Company
Main Title
Section
Subsection
Another Section
Home
About
Page Title
Main Title
Jumps to h3 (breaks hierarchy)
Label Association Rules:
Email Address
Email Address
Email Address
2. Keyboard Navigation
Principle: All interactive features usable via keyboard—Tab, Shift+Tab, Enter, Space, Arrow keys, Escape.
Key Bindings:
- Tab / Shift+Tab: Move focus forward/backward
- Enter / Space: Activate buttons, toggle checkboxes
- Arrow keys: Navigate lists, menus, sliders, tabs (up/down for vertical; left/right for horizontal)
- Home / End: Jump to first/last item in list
- Escape: Close modal, dropdown, or menu
Focus Management:
Click me
Skip to main content
First
Second
button:focus {
outline: none; /* NEVER DO THIS */
}
Skip Links (Focus Management):
Skip to main content
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
}
.skip-link:focus {
left: 0;
top: 0;
background: #000;
color: #fff;
padding: 8px 12px;
}
3. ARIA Attributes
Golden Rule: Semantic HTML first. Use ARIA only when native HTML doesn't exist or to enhance semantics.
Common ARIA Attributes
| Attribute | Purpose | Example | |-----------|---------|---------| | aria-label | Names element (for icons, buttons without text) | × | | aria-labelledby | Links to another element as label | Confirm Delete... | | aria-describedby | Adds descriptive text (error, hint) | Min 8 chars | | aria-live | Announces dynamic content ("polite" or "assertive") | Item added to cart | | aria-hidden | Hides from screen readers (decorative only) | → | | role | Semantic role | ... | | aria-expanded | Indicates expanded/collapsed state | Menu | | aria-haspopup | Signals a popup exists | Options | | aria-selected | Current selection in list/tabs | Tab 1 | | aria-modal | Marks modal dialogs | ... | | aria-required | Flags required form fields | ` | | aria-invalid | Indicates validation error | | | aria-atomic | Reads entire content block | Status: Updated` |
4. Color Contrast & Visual Focus
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:
This field is required
* This field is required
I agree (required)
5. Testing & Validation
Automated Tools:
- axe DevTools: Browser extension for violations (Chrome, Firefox)
- Lighthouse: Chrome DevTools → Lighthouse → Accessibility (target: 90+)
- Pa11y: CLI tool for automated scanning
- jest-axe: Automated testing in Jest
Manual Testing:
- Keyboard-only: Unplug mouse; navigate using Tab, Enter, Space, arrows
- Screen readers: NVDA (Windows), JAWS (Windows), VoiceOver (macOS/iOS)
- Color contrast: WebAIM Contrast Checker
- Zoom: Test at 200% zoom; ensure nothing breaks
Acceptance Criteria:
- Zero axe DevTools violations
- Lighthouse Accessibility ≥ 90
- All features functional via keyboard
- All images have alt text
- All form inputs have labels
Component Patterns
Pattern 1: Accessible Dropdown Menu
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 (
setIsOpen(!isOpen)}
>
Options
{isOpen && (
{options.map((option, i) => (
setSelectedIndex(i)}
onClick={() => handleSelect(option)}
>
{option}
))}
)}
);
}
Pattern 2: Accessible Modal Dialog
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 */}
{/* Modal */}
{title}
{children}
Close
);
}
Pattern 3: Accessible Alert/Notification
export function AccessibleAlert({ type, message, onDismiss }) {
// type: 'error' | 'success' | 'warning' | 'info'
const isError = type === 'error';
return (
{type === 'error' && '⚠️'}
{type === 'success' && '✓'}
{type === 'warning' && '⚡'}
{type === 'info' && 'ℹ️'}
{message}
{onDismiss && (
×
)}
);
}
Pattern 4: Accessible Form with Error Handling
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: user@domain.com';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length {
e.preventDefault();
if (validate()) {
console.log('Form submitted:', formData);
}
};
return (
{/* Email Field */}
Email Address *
setFormData({ ...formData, email: e.target.value })}
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
{errors.email}
)}
{/* Password Field */}
Password *
setFormData({ ...formData, password: e.target.value })}
aria-required="true"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : 'password-hint'}
/>
Minimum 8 characters
{errors.password && (
{errors.password}
)}
Sign In
);
}
Pattern 5: Accessible Tab Component
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 (
{/* Tablist */}
{tabs.map((tab, i) => (
setActiveTab(i)}
onKeyDown={(e) => handleTabKeyDown(e, i)}
>
{tab.label}
))}
{/* Tabpanels */}
{tabs.map((tab, i) => (
{tab.content}
))}
);
}
Pattern 6: Testing with axe-core and Jest
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();
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('form is keyboard navigable', () => {
render();
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(
{}} title="Test Modal">
Action 1
Action 2
);
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(
);
const alert = screen.getByRole('alert');
expect(alert).toHaveAttribute('aria-live', 'polite');
expect(alert).toHaveAttribute('aria-atomic', 'true');
});
});
Quick Reference Tables
Keyboard Events Mapping
| 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 |
ARIA Role Reference (Common)
| Role | Element | Usage | |------|---------|-------| | button | `, (non-button) | Make non-button interactive | | menu / menuitem | , | 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 |
Contrast Ratio Quick Check
| 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 ✓ |
Review Checklist
Use this checklist before marking a component accessible:
##
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: fernando-bertholdo
- Source: fernando-bertholdo/4-successful-AI-life
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.