web-accessibility by supercent-io/skills-template
npx skills add https://github.com/supercent-io/skills-template --skill web-accessibilityMake a React modal component accessible:
- Framework: React + TypeScript
- WCAG Level: AA
- Requirements:
- Focus trap (focus stays inside the modal)
- Close with ESC key
- Close by clicking the background
- Title/description read by screen readers
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
使用有意义的 HTML 元素使结构清晰。
任务 :
<button>, <nav>, <main>, <header>, <footer> 等<div> 和 <span><h1> ~ <h6>)<label> 与 <input> 关联示例 (❌ 错误 vs ✅ 正确):
<!-- ❌ Bad example: using only div and span -->
<div class="header">
<span class="title">My App</span>
<div class="nav">
<div class="nav-item" onclick="navigate()">Home</div>
<div class="nav-item" onclick="navigate()">About</div>
</div>
</div>
<!-- ✅ Good example: semantic HTML -->
<header>
<h1>My App</h1>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
表单示例 :
<!-- ❌ Bad example: no label -->
<input type="text" placeholder="Enter your name">
<!-- ✅ Good example: label connected -->
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
<!-- Or wrap with label -->
<label>
Email:
<input type="email" name="email" required>
</label>
确保所有功能无需鼠标即可使用。
任务 :
tabindex决策标准 :
tabindex="0" (可聚焦)tabindex="-1" (仅编程式聚焦)tabindex="1+"示例 (React 下拉菜单):
import React, { useState, useRef, useEffect } from 'react';
interface DropdownProps {
label: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
}
function AccessibleDropdown({ label, options, onChange }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const buttonRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Keyboard handler
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setSelectedIndex((prev) => (prev + 1) % options.length);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setSelectedIndex((prev) => (prev - 1 + options.length) % options.length);
}
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen) {
onChange(options[selectedIndex].value);
setIsOpen(false);
buttonRef.current?.focus();
} else {
setIsOpen(true);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
buttonRef.current?.focus();
break;
}
};
return (
<div className="dropdown">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby="dropdown-label"
>
{label}
</button>
{isOpen && (
<ul
ref={listRef}
role="listbox"
aria-labelledby="dropdown-label"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
{options.map((option, index) => (
<li
key={option.value}
role="option"
aria-selected={index === selectedIndex}
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
为屏幕阅读器提供额外的上下文。
任务 :
aria-label: 定义元素的名称aria-labelledby: 引用另一个元素作为标签aria-describedby: 提供额外描述aria-live: 宣布动态内容变化aria-hidden: 对屏幕阅读器隐藏检查清单 :
示例 (模态框):
function AccessibleModal({ isOpen, onClose, title, children }) {
const modalRef = useRef<HTMLDivElement>(null);
// Focus trap when modal opens
useEffect(() => {
if (isOpen) {
modalRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
ref={modalRef}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onClose();
}
}}
>
<div className="modal-overlay" onClick={onClose} aria-hidden="true" />
<div className="modal-content">
<h2 id="modal-title">{title}</h2>
<div id="modal-description">
{children}
</div>
<button onClick={onClose} aria-label="Close modal">
<span aria-hidden="true">×</span>
</button>
</div>
</div>
);
}
aria-live 示例 (通知):
function Notification({ message, type }: { message: string; type: 'success' | 'error' }) {
return (
<div
role="alert"
aria-live="assertive" // Immediate announcement (error), "polite" announces in turn
aria-atomic="true" // Read the entire content
className={`notification notification-${type}`}
>
{type === 'error' && <span aria-label="Error">⚠️</span>}
{type === 'success' && <span aria-label="Success">✅</span>}
{message}
</div>
);
}
确保为视障用户提供足够的对比度。
任务 :
示例 (CSS):
/* ✅ Sufficient contrast (text #000 on #FFF = 21:1) */
.button {
background-color: #0066cc;
color: #ffffff; /* contrast ratio 7.7:1 */
}
/* ✅ Focus indicator */
button:focus,
a:focus {
outline: 3px solid #0066cc;
outline-offset: 2px;
}
/* ❌ outline: none is forbidden! */
button:focus {
outline: none; /* Never use this */
}
/* ✅ Indicate state with color + icon */
.error-message {
color: #d32f2f;
border-left: 4px solid #d32f2f;
}
.error-message::before {
content: '⚠️';
margin-right: 8px;
}
通过自动化和手动测试验证可访问性。
任务 :
示例 (Jest + axe-core):
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import AccessibleButton from './AccessibleButton';
expect.extend(toHaveNoViolations);
describe('AccessibleButton', () => {
it('should have no accessibility violations', async () => {
const { container } = render(
<AccessibleButton onClick={() => {}}>
Click Me
</AccessibleButton>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should be keyboard accessible', () => {
const handleClick = jest.fn();
const { getByRole } = render(
<AccessibleButton onClick={handleClick}>
Click Me
</AccessibleButton>
);
const button = getByRole('button');
// Enter key
button.focus();
fireEvent.keyDown(button, { key: 'Enter' });
expect(handleClick).toHaveBeenCalled();
// Space key
fireEvent.keyDown(button, { key: ' ' });
expect(handleClick).toHaveBeenCalledTimes(2);
});
});
## Accessibility Checklist
### Semantic HTML
- [x] Use semantic HTML tags (`<button>`, `<nav>`, `<main>`, etc.)
- [x] Heading hierarchy is correct (h1 → h2 → h3)
- [x] All form labels are connected
### Keyboard Navigation
- [x] All interactive elements accessible via Tab
- [x] Buttons activated with Enter/Space
- [x] Modals/dropdowns closed with ESC
- [x] Focus indicator is clear (outline)
### ARIA
- [x] `role` used appropriately
- [x] `aria-label` or `aria-labelledby` provided
- [x] `aria-live` used for dynamic content
- [x] Decorative elements use `aria-hidden="true"`
### Visual
- [x] Color contrast meets WCAG AA (4.5:1)
- [x] Information not conveyed by color alone
- [x] Text size can be adjusted
- [x] Responsive design
### Testing
- [x] 0 axe DevTools violations
- [x] Lighthouse Accessibility score 90+
- [x] Keyboard test passed
- [x] Screen reader test completed
键盘可访问性 : 所有功能必须无需鼠标即可使用
替代文本 : 所有图像必须具有 alt 属性
alt="" (屏幕阅读器忽略)清晰标签 : 所有表单输入必须有关联的标签
<label for="..."> 或 aria-label不要移除轮廓线 : 切勿使用 outline: none
不要使用 tabindex > 0 : 避免改变焦点顺序
不要仅通过颜色传达信息 : 配合图标或文本使用
function AccessibleContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
return (
<form onSubmit={handleSubmit} noValidate>
<h2 id="form-title">Contact Us</h2>
<p id="form-description">Please fill out the form below to get in touch.</p>
{/* Name */}
<div className="form-group">
<label htmlFor="name">
Name <span aria-label="required">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<span id="name-error" role="alert" className="error">
{errors.name}
</span>
)}
</div>
{/* Email */}
<div className="form-group">
<label htmlFor="email">
Email <span aria-label="required">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
/>
<span id="email-hint" className="hint">
We'll never share your email.
</span>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
{/* Submit button */}
<button type="submit" disabled={submitStatus === 'loading'}>
{submitStatus === 'loading' ? 'Submitting...' : 'Submit'}
</button>
{/* Success/failure messages */}
{submitStatus === 'success' && (
<div role="alert" aria-live="polite" className="success">
✅ Form submitted successfully!
</div>
)}
{submitStatus === 'error' && (
<div role="alert" aria-live="assertive" className="error">
⚠️ An error occurred. Please try again.
</div>
)}
</form>
);
}
function AccessibleTabs({ tabs }: { tabs: { id: string; label: string; content: React.ReactNode }[] }) {
const [activeTab, setActiveTab] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
setActiveTab((index + 1) % tabs.length);
break;
case 'ArrowLeft':
e.preventDefault();
setActiveTab((index - 1 + tabs.length) % tabs.length);
break;
case 'Home':
e.preventDefault();
setActiveTab(0);
break;
case 'End':
e.preventDefault();
setActiveTab(tabs.length - 1);
break;
}
};
return (
<div>
{/* Tab List */}
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === index}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{/* Tab Panels */}
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== index}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
语义化 HTML 优先 : ARIA 是最后的手段
<button> vs <div role="button">焦点管理 : 在 SPA 的页面转换中管理焦点
错误消息 : 清晰且有用的错误消息
#accessibility #a11y #WCAG #ARIA #screen-reader #keyboard-navigation #frontend
每周安装量
12.6K
仓库
GitHub 星标数
88
首次出现
Jan 24, 2026
安全审计
安装于
codex12.5K
gemini-cli12.4K
opencode12.4K
cursor12.4K
github-copilot12.4K
cline12.4K
Make a React modal component accessible:
- Framework: React + TypeScript
- WCAG Level: AA
- Requirements:
- Focus trap (focus stays inside the modal)
- Close with ESC key
- Close by clicking the background
- Title/description read by screen readers
Use meaningful HTML elements to make the structure clear.
Tasks :
<button>, <nav>, <main>, <header>, <footer>, etc.<div> and <span><h1> ~ <h6>) correctly<label> with <input>Example (❌ Bad vs ✅ Good):
<!-- ❌ Bad example: using only div and span -->
<div class="header">
<span class="title">My App</span>
<div class="nav">
<div class="nav-item" onclick="navigate()">Home</div>
<div class="nav-item" onclick="navigate()">About</div>
</div>
</div>
<!-- ✅ Good example: semantic HTML -->
<header>
<h1>My App</h1>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
Form Example :
<!-- ❌ Bad example: no label -->
<input type="text" placeholder="Enter your name">
<!-- ✅ Good example: label connected -->
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
<!-- Or wrap with label -->
<label>
Email:
<input type="email" name="email" required>
</label>
Ensure all features are usable without a mouse.
Tasks :
tabindex appropriatelyDecision Criteria :
tabindex="0" (focusable)tabindex="-1" (programmatic focus only)tabindex="1+"Example (React Dropdown):
import React, { useState, useRef, useEffect } from 'react';
interface DropdownProps {
label: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
}
function AccessibleDropdown({ label, options, onChange }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const buttonRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Keyboard handler
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setSelectedIndex((prev) => (prev + 1) % options.length);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setSelectedIndex((prev) => (prev - 1 + options.length) % options.length);
}
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen) {
onChange(options[selectedIndex].value);
setIsOpen(false);
buttonRef.current?.focus();
} else {
setIsOpen(true);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
buttonRef.current?.focus();
break;
}
};
return (
<div className="dropdown">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby="dropdown-label"
>
{label}
</button>
{isOpen && (
<ul
ref={listRef}
role="listbox"
aria-labelledby="dropdown-label"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
{options.map((option, index) => (
<li
key={option.value}
role="option"
aria-selected={index === selectedIndex}
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
Provide additional context for screen readers.
Tasks :
aria-label: Define the element's namearia-labelledby: Reference another element as a labelaria-describedby: Provide additional descriptionaria-live: Announce dynamic content changesaria-hidden: Hide from screen readersChecklist :
Example (Modal):
function AccessibleModal({ isOpen, onClose, title, children }) {
const modalRef = useRef<HTMLDivElement>(null);
// Focus trap when modal opens
useEffect(() => {
if (isOpen) {
modalRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
ref={modalRef}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onClose();
}
}}
>
<div className="modal-overlay" onClick={onClose} aria-hidden="true" />
<div className="modal-content">
<h2 id="modal-title">{title}</h2>
<div id="modal-description">
{children}
</div>
<button onClick={onClose} aria-label="Close modal">
<span aria-hidden="true">×</span>
</button>
</div>
</div>
);
}
aria-live Example (Notifications):
function Notification({ message, type }: { message: string; type: 'success' | 'error' }) {
return (
<div
role="alert"
aria-live="assertive" // Immediate announcement (error), "polite" announces in turn
aria-atomic="true" // Read the entire content
className={`notification notification-${type}`}
>
{type === 'error' && <span aria-label="Error">⚠️</span>}
{type === 'success' && <span aria-label="Success">✅</span>}
{message}
</div>
);
}
Ensure sufficient contrast ratios for users with visual impairments.
Tasks :
Example (CSS):
/* ✅ Sufficient contrast (text #000 on #FFF = 21:1) */
.button {
background-color: #0066cc;
color: #ffffff; /* contrast ratio 7.7:1 */
}
/* ✅ Focus indicator */
button:focus,
a:focus {
outline: 3px solid #0066cc;
outline-offset: 2px;
}
/* ❌ outline: none is forbidden! */
button:focus {
outline: none; /* Never use this */
}
/* ✅ Indicate state with color + icon */
.error-message {
color: #d32f2f;
border-left: 4px solid #d32f2f;
}
.error-message::before {
content: '⚠️';
margin-right: 8px;
}
Validate accessibility with automated and manual testing.
Tasks :
Example (Jest + axe-core):
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import AccessibleButton from './AccessibleButton';
expect.extend(toHaveNoViolations);
describe('AccessibleButton', () => {
it('should have no accessibility violations', async () => {
const { container } = render(
<AccessibleButton onClick={() => {}}>
Click Me
</AccessibleButton>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should be keyboard accessible', () => {
const handleClick = jest.fn();
const { getByRole } = render(
<AccessibleButton onClick={handleClick}>
Click Me
</AccessibleButton>
);
const button = getByRole('button');
// Enter key
button.focus();
fireEvent.keyDown(button, { key: 'Enter' });
expect(handleClick).toHaveBeenCalled();
// Space key
fireEvent.keyDown(button, { key: ' ' });
expect(handleClick).toHaveBeenCalledTimes(2);
});
});
## Accessibility Checklist
### Semantic HTML
- [x] Use semantic HTML tags (`<button>`, `<nav>`, `<main>`, etc.)
- [x] Heading hierarchy is correct (h1 → h2 → h3)
- [x] All form labels are connected
### Keyboard Navigation
- [x] All interactive elements accessible via Tab
- [x] Buttons activated with Enter/Space
- [x] Modals/dropdowns closed with ESC
- [x] Focus indicator is clear (outline)
### ARIA
- [x] `role` used appropriately
- [x] `aria-label` or `aria-labelledby` provided
- [x] `aria-live` used for dynamic content
- [x] Decorative elements use `aria-hidden="true"`
### Visual
- [x] Color contrast meets WCAG AA (4.5:1)
- [x] Information not conveyed by color alone
- [x] Text size can be adjusted
- [x] Responsive design
### Testing
- [x] 0 axe DevTools violations
- [x] Lighthouse Accessibility score 90+
- [x] Keyboard test passed
- [x] Screen reader test completed
Keyboard Accessibility : All features must be usable without a mouse
Alternative Text : All images must have an alt attribute
alt="" (screen reader ignores)Clear Labels : All form inputs must have an associated label
<label for="..."> or aria-labelDo Not Remove Outline : Never use outline: none
Do Not Use tabindex > 0: Avoid changing focus order
Do Not Convey Information by Color Alone : Accompany with icons or text
function AccessibleContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
return (
<form onSubmit={handleSubmit} noValidate>
<h2 id="form-title">Contact Us</h2>
<p id="form-description">Please fill out the form below to get in touch.</p>
{/* Name */}
<div className="form-group">
<label htmlFor="name">
Name <span aria-label="required">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<span id="name-error" role="alert" className="error">
{errors.name}
</span>
)}
</div>
{/* Email */}
<div className="form-group">
<label htmlFor="email">
Email <span aria-label="required">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
/>
<span id="email-hint" className="hint">
We'll never share your email.
</span>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
{/* Submit button */}
<button type="submit" disabled={submitStatus === 'loading'}>
{submitStatus === 'loading' ? 'Submitting...' : 'Submit'}
</button>
{/* Success/failure messages */}
{submitStatus === 'success' && (
<div role="alert" aria-live="polite" className="success">
✅ Form submitted successfully!
</div>
)}
{submitStatus === 'error' && (
<div role="alert" aria-live="assertive" className="error">
⚠️ An error occurred. Please try again.
</div>
)}
</form>
);
}
function AccessibleTabs({ tabs }: { tabs: { id: string; label: string; content: React.ReactNode }[] }) {
const [activeTab, setActiveTab] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
setActiveTab((index + 1) % tabs.length);
break;
case 'ArrowLeft':
e.preventDefault();
setActiveTab((index - 1 + tabs.length) % tabs.length);
break;
case 'Home':
e.preventDefault();
setActiveTab(0);
break;
case 'End':
e.preventDefault();
setActiveTab(tabs.length - 1);
break;
}
};
return (
<div>
{/* Tab List */}
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === index}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{/* Tab Panels */}
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== index}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
Semantic HTML First : ARIA is a last resort
<button> vs <div role="button">Focus Management : Manage focus on page transitions in SPAs
Error Messages : Clear and helpful error messages
#accessibility #a11y #WCAG #ARIA #screen-reader #keyboard-navigation #frontend
Weekly Installs
12.6K
Repository
GitHub Stars
88
First Seen
Jan 24, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex12.5K
gemini-cli12.4K
opencode12.4K
cursor12.4K
github-copilot12.4K
cline12.4K
agentation - AI智能体可视化UI反馈工具,连接人眼与代码的桥梁
5,400 周安装