accessibility-a11y
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAccessibility (a11y)
无障碍访问(a11y)
Semantic HTML
语义化HTML
tsx
// Use semantic elements
<header> {/* Site header */}
<nav> {/* Navigation */}
<main> {/* Main content - one per page */}
<article> {/* Self-contained content */}
<section> {/* Thematic grouping with heading */}
<aside> {/* Sidebar content */}
<footer> {/* Site footer */}
// Correct heading hierarchy
<h1>Page Title</h1> {/* One per page */}
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
// Lists for navigation
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>tsx
// 使用语义化元素
<header> {/* 站点页眉 */}
<nav> {/* 导航栏 */}
<main> {/* 主内容 - 每页一个 */}
<article> {/* 独立内容块 */}
<section> {/* 带标题的主题分组 */}
<aside> {/* 侧边栏内容 */}
<footer> {/* 站点页脚 */}
// 正确的标题层级
<h1>页面标题</h1> {/* 每页一个 */}
<h2>章节</h2>
<h3>子章节</h3>
<h2>另一章节</h2>
// 导航使用列表
<nav>
<ul>
<li><a href="/">首页</a></li>
<li><a href="/about">关于我们</a></li>
</ul>
</nav>Skip Link
跳转链接
tsx
// components/layout/SkipLink.tsx
export function SkipLink() {
return (
<a
href="#main-content"
className="
sr-only focus:not-sr-only
focus:absolute focus:top-4 focus:left-4
focus:z-50 focus:px-4 focus:py-2
focus:bg-primary focus:text-primary-foreground
focus:rounded
"
>
Skip to main content
</a>
);
}
// In layout
<body>
<SkipLink />
<Header />
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>tsx
// components/layout/SkipLink.tsx
export function SkipLink() {
return (
<a
href="#main-content"
className="
sr-only focus:not-sr-only
focus:absolute focus:top-4 focus:left-4
focus:z-50 focus:px-4 focus:py-2
focus:bg-primary focus:text-primary-foreground
focus:rounded
"
>
跳转到主内容
</a>
);
}
// 在布局中使用
<body>
<SkipLink />
<Header />
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>Focus Management
焦点管理
tsx
// Visible focus states (Tailwind)
<button className="
focus:outline-none
focus-visible:ring-2
focus-visible:ring-ring
focus-visible:ring-offset-2
">
// Focus trap for modals
import { useEffect, useRef } from 'react';
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const container = containerRef.current;
if (!container) return;
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
};
firstElement?.focus();
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
return containerRef;
}tsx
// 可见的焦点状态(Tailwind)
<button className="
focus:outline-none
focus-visible:ring-2
focus-visible:ring-ring
focus-visible:ring-offset-2
">
// 模态框的焦点陷阱
import { useEffect, useRef } from 'react';
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const container = containerRef.current;
if (!container) return;
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
};
firstElement?.focus();
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
return containerRef;
}ARIA Labels
ARIA标签
tsx
// Buttons with icons only
<button aria-label="Close menu">
<X className="w-5 h-5" />
</button>
// Loading states
<button disabled aria-busy="true">
<Spinner aria-hidden="true" />
<span>Submitting...</span>
</button>
// Live regions for dynamic content
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
// Form errors
<input
id="email"
aria-invalid={hasError}
aria-describedby={hasError ? 'email-error' : undefined}
/>
{hasError && (
<p id="email-error" role="alert">
Please enter a valid email
</p>
)}
// Current page in navigation
<nav aria-label="Main navigation">
<a href="/" aria-current={isHome ? 'page' : undefined}>Home</a>
<a href="/about" aria-current={isAbout ? 'page' : undefined}>About</a>
</nav>tsx
// 仅含图标按钮
<button aria-label="关闭菜单">
<X className="w-5 h-5" />
</button>
// 加载状态
<button disabled aria-busy="true">
<Spinner aria-hidden="true" />
<span>提交中...</span>
</button>
// 动态内容的实时区域
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
// 表单错误
<input
id="email"
aria-invalid={hasError}
aria-describedby={hasError ? 'email-error' : undefined}
/>
{hasError && (
<p id="email-error" role="alert">
请输入有效的邮箱地址
</p>
)}
// 导航中的当前页
<nav aria-label="主导航">
<a href="/" aria-current={isHome ? 'page' : undefined}>首页</a>
<a href="/about" aria-current={isAbout ? 'page' : undefined}>关于我们</a>
</nav>Keyboard Navigation
键盘导航
tsx
// Custom keyboard handlers
function handleKeyDown(e: React.KeyboardEvent) {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
handleSelect();
break;
case 'Escape':
handleClose();
break;
case 'ArrowDown':
e.preventDefault();
focusNext();
break;
case 'ArrowUp':
e.preventDefault();
focusPrevious();
break;
}
}
// Roving tabindex for menu items
function MenuItem({ isSelected, ...props }) {
return (
<button
role="menuitem"
tabIndex={isSelected ? 0 : -1}
{...props}
/>
);
}tsx
// 自定义键盘事件处理器
function handleKeyDown(e: React.KeyboardEvent) {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
handleSelect();
break;
case 'Escape':
handleClose();
break;
case 'ArrowDown':
e.preventDefault();
focusNext();
break;
case 'ArrowUp':
e.preventDefault();
focusPrevious();
break;
}
}
// 菜单项的移动tabindex
function MenuItem({ isSelected, ...props }) {
return (
<button
role="menuitem"
tabIndex={isSelected ? 0 : -1}
{...props}
/>
);
}Color Contrast
颜色对比度
tsx
// WCAG AA requirements:
// - Normal text: 4.5:1 ratio
// - Large text (18px+ or 14px+ bold): 3:1 ratio
// - UI components: 3:1 ratio
// Use contrast-safe color combinations
// ✅ Good
<p className="text-foreground bg-background"> {/* High contrast */}
<p className="text-muted-foreground bg-background"> {/* Adequate for large text */}
// ❌ Avoid
<p className="text-gray-400 bg-gray-100"> {/* Poor contrast */}
// Test with browser DevTools → Accessibility paneltsx
// WCAG AA标准要求:
// - 普通文本: 4.5:1 对比度
// - 大文本(18px以上或14px以上粗体): 3:1 对比度
// - UI组件: 3:1 对比度
// 使用对比度安全的配色组合
// ✅ 良好
<p className="text-foreground bg-background"> {/* 高对比度 */}
<p className="text-muted-foreground bg-background"> {/* 大文本足够 */}
// ❌ 避免
<p className="text-gray-400 bg-gray-100"> {/* 低对比度 */}
// 使用浏览器开发者工具 → 无障碍面板测试Screen Reader Text
屏幕阅读器文本
tsx
// Visually hidden but announced
<span className="sr-only">
Opens in new tab
</span>
// Icon with hidden label
<a href="/facebook" aria-label="Facebook">
<Facebook aria-hidden="true" />
</a>
// Decorative images
<img src="/decoration.svg" alt="" aria-hidden="true" />
// Meaningful images
<img src="/team.jpg" alt="Our team of certified instructors" />tsx
// 视觉隐藏但可被朗读
<span className="sr-only">
在新标签页打开
</span>
// 带隐藏标签的图标
<a href="/facebook" aria-label="Facebook">
<Facebook aria-hidden="true" />
</a>
// 装饰性图片
<img src="/decoration.svg" alt="" aria-hidden="true" />
// 有意义的图片
<img src="/team.jpg" alt="我们的认证讲师团队" />Form Accessibility
表单无障碍访问
tsx
// Complete accessible form
<form onSubmit={handleSubmit} aria-labelledby="form-title">
<h2 id="form-title">Contact Us</h2>
<div>
<label htmlFor="name">
Name <span aria-hidden="true">*</span>
<span className="sr-only">(required)</span>
</label>
<input
id="name"
name="name"
type="text"
required
aria-required="true"
aria-invalid={errors.name ? 'true' : 'false'}
aria-describedby={errors.name ? 'name-error' : 'name-hint'}
/>
<p id="name-hint" className="text-sm text-muted-foreground">
Enter your full name
</p>
{errors.name && (
<p id="name-error" role="alert" className="text-destructive">
{errors.name}
</p>
)}
</div>
<button type="submit">
Submit
</button>
</form>tsx
// 完整的无障碍表单
<form onSubmit={handleSubmit} aria-labelledby="form-title">
<h2 id="form-title">联系我们</h2>
<div>
<label htmlFor="name">
姓名 <span aria-hidden="true">*</span>
<span className="sr-only">(必填)</span>
</label>
<input
id="name"
name="name"
type="text"
required
aria-required="true"
aria-invalid={errors.name ? 'true' : 'false'}
aria-describedby={errors.name ? 'name-error' : 'name-hint'}
/>
<p id="name-hint" className="text-sm text-muted-foreground">
请输入您的全名
</p>
{errors.name && (
<p id="name-error" role="alert" className="text-destructive">
{errors.name}
</p>
)}
</div>
<button type="submit">
提交
</button>
</form>Accordion Accessibility
折叠面板无障碍访问
tsx
// Accessible accordion pattern
function Accordion({ items }) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<div>
{items.map((item, index) => (
<div key={index}>
<h3>
<button
id={`accordion-header-${index}`}
aria-expanded={openIndex === index}
aria-controls={`accordion-panel-${index}`}
onClick={() => setOpenIndex(openIndex === index ? null : index)}
className="w-full text-left"
>
{item.title}
</button>
</h3>
<div
id={`accordion-panel-${index}`}
role="region"
aria-labelledby={`accordion-header-${index}`}
hidden={openIndex !== index}
>
{item.content}
</div>
</div>
))}
</div>
);
}tsx
// 无障碍折叠面板模式
function Accordion({ items }) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<div>
{items.map((item, index) => (
<div key={index}>
<h3>
<button
id={`accordion-header-${index}`}
aria-expanded={openIndex === index}
aria-controls={`accordion-panel-${index}`}
onClick={() => setOpenIndex(openIndex === index ? null : index)}
className="w-full text-left"
>
{item.title}
</button>
</h3>
<div
id={`accordion-panel-${index}`}
role="region"
aria-labelledby={`accordion-header-${index}`}
hidden={openIndex !== index}
>
{item.content}
</div>
</div>
))}
</div>
);
}Reduced Motion
减少动画
tsx
// Respect user preferences
import { useReducedMotion } from 'framer-motion';
function AnimatedComponent() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
animate={{ y: 0, opacity: 1 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.5,
}}
>
Content
</motion.div>
);
}
// CSS approach
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}tsx
// 尊重用户偏好
import { useReducedMotion } from 'framer-motion';
function AnimatedComponent() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
animate={{ y: 0, opacity: 1 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.5,
}}
>
内容
</motion.div>
);
}
// CSS实现方式
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}Testing Checklist
测试清单
- Navigate entire site with keyboard only (Tab, Enter, Escape, Arrows)
- Test with screen reader (VoiceOver, NVDA)
- Check color contrast ratios
- Verify focus indicators are visible
- Test at 200% zoom
- Check heading hierarchy
- Verify form labels and error messages
- Test with reduced motion preference
- 仅使用键盘(Tab、Enter、Escape、方向键)导航整个站点
- 使用屏幕阅读器测试(VoiceOver、NVDA)
- 检查颜色对比度
- 验证焦点指示器可见
- 测试200%缩放效果
- 检查标题层级
- 验证表单标签和错误提示
- 测试减少动画偏好设置