by supercent-io
由于实施无障碍功能不当,网页应用程序常常将残障用户排斥在外。本技能提供 WCAG 2.1 指南,帮助创建具有包容性、屏幕阅读器友好且支持键盘导航的界面。
1. 打开 Claude 聊天界面
2. 点击下方 "📋 复制" 按钮
3. 粘贴到 Claude 聊天框中并发送
4. 输入 "使用 web-accessibility 技能" 开始使用
=== web-accessibility 技能 === 作者: supercent-io 描述: 由于实施无障碍功能不当,网页应用程序常常将残障用户排斥在外。本技能提供 WCAG 2.1 指南,帮助创建具有包容性、屏幕阅读器友好且支持键盘导航的界面。 使用方法: 1. 调用技能: "使用 web-accessibility 技能" 2. 提供相关信息: 根据技能要求提供必要参数 3. 查看结果: 技能会返回处理结果 示例: "使用 web-accessibility 技能,帮我分析一下这段代码"
这种方法适用于所有 Claude 用户,不需要安装额外工具。
design
safe
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
View Count
0
Download Count
0
Favorite Count
0
Quality Score
73