web-accessibility
npx skills add https://github.com/supercent-io/skills-template --skill web-accessibility
Agent 安装分布
Skill 文档
Web Accessibility (A11y)
When to use this skill
- ì UI ì»´í¬ëí¸ ê°ë°: ì ê·¼ ê°ë¥í ì»´í¬ëí¸ ì¤ê³
- ì ê·¼ì± ê°ì¬: 기존 ì¬ì´í¸ì ì ê·¼ì± ë¬¸ì ìë³ ë° ìì
- í¼ êµ¬í: ì¤í¬ë¦° 리ë ì¹íì ì¸ í¼ ìì±
- 모ë¬/ëë¡ë¤ì´: í¬ì»¤ì¤ ê´ë¦¬ ë° í¤ë³´ë í¸ë© ë°©ì§
- WCAG ì¤ì: ë²ì ì구ì¬í ëë íì¤ ì¤ì
ì ë ¥ íì (Input Format)
íì ì ë³´
- íë ììí¬: React, Vue, Svelte, Vanilla JS ë±
- ì»´í¬ëí¸ ì í: Button, Form, Modal, Dropdown, Navigation ë±
- WCAG ë 벨: A, AA, AAA (기본ê°: AA)
ì í ì ë³´
- ì¤í¬ë¦° 리ë: NVDA, JAWS, VoiceOver (í ì¤í¸ì©)
- ìë í ì¤í¸ ë구: axe-core, Pa11y, Lighthouse (기본ê°: axe-core)
- ë¸ë¼ì°ì : Chrome, Firefox, Safari (기본ê°: Chrome)
ì ë ¥ ìì
React ëª¨ë¬ ì»´í¬ëí¸ë¥¼ ì ê·¼ ê°ë¥íê² ë§ë¤ì´ì¤:
- íë ììí¬: React + TypeScript
- WCAG ë 벨: AA
- ì구ì¬í:
- í¬ì»¤ì¤ í¸ë© (ëª¨ë¬ ë´ë¶ìë§ í¬ì»¤ì¤)
- ESC í¤ë¡ ë«ê¸°
- ë°°ê²½ í´ë¦ì¼ë¡ ë«ê¸°
- ì¤í¬ë¦° 리ëìì ì 목/ì¤ëª
ì½ê¸°
Instructions
Step 1: Semantic HTML ì¬ì©
ì미ìë HTML ìì를 ì¬ì©íì¬ êµ¬ì¡°ë¥¼ ëª íí í©ëë¤.
ìì ë´ì©:
<button>,<nav>,<main>,<header>,<footer>ë± ìë§¨í± íê·¸ ì¬ì©<div>,<span>ë¨ì© ì§ì- ì 목 ê³ì¸µ 구조 (
<h1>~<h6>) ì¬ë°ë¥´ê² ì¬ì© <label>ê³¼<input>ì°ê²°
ìì (â ëì ì vs â ì¢ì ì):
<!-- â ëì ì: divì 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>
<!-- â
ì¢ì ì: ìë§¨í± 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>
í¼ ìì:
<!-- â ëì ì: label ìì -->
<input type="text" placeholder="Enter your name">
<!-- â
ì¢ì ì: label ì°ê²° -->
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
<!-- ëë labelë¡ ê°ì¸ê¸° -->
<label>
Email:
<input type="email" name="email" required>
</label>
Step 2: í¤ë³´ë ë¤ë¹ê²ì´ì 구í
ë§ì°ì¤ ìì´ë 모ë ê¸°ë¥ ì¬ì© ê°ë¥íëë¡ í©ëë¤.
ìì ë´ì©:
- Tab, Shift+Tabì¼ë¡ í¬ì»¤ì¤ ì´ë
- Enter/Spaceë¡ ë²í¼ íì±í
- íì´í í¤ë¡ 리ì¤í¸/ë©ë´ íì
- ESCë¡ ëª¨ë¬/ëë¡ë¤ì´ ë«ê¸°
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);
// í¤ë³´ë í¸ë¤ë¬
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>
);
}
Step 3: ARIA ìì± ì¶ê°
ì¤í¬ë¦° 리ëìê² ì¶ê° 컨í ì¤í¸ë¥¼ ì ê³µí©ëë¤.
ìì ë´ì©:
aria-label: ììì ì´ë¦ ì ìaria-labelledby: ë¤ë¥¸ ìì를 ë¼ë²¨ë¡ 참조aria-describedby: ì¶ê° ì¤ëª ì ê³µaria-live: ëì ì½í ì¸ ë³ê²½ ì림aria-hidden: ì¤í¬ë¦° 리ëìì ì¨ê¸°ê¸°
íì¸ ì¬í:
- 모ë ì¸í°ëí°ë¸ ììì ëª íí ë¼ë²¨
- ë²í¼ 목ì ì´ ëª í (ì: “Submit form” not “Click”)
- ìí ë³í ì림 (aria-live)
- ì¥ìì© ì´ë¯¸ì§ë alt=”” ëë aria-hidden=”true”
ìì (모ë¬):
function AccessibleModal({ isOpen, onClose, title, children }) {
const modalRef = useRef<HTMLDivElement>(null);
// ëª¨ë¬ ì´ë¦´ ë í¬ì»¤ì¤ í¸ë©
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" // ì¦ì ì림 (error), "polite"ë ììëë¡ ì림
aria-atomic="true" // ì ì²´ ë´ì© ì½ê¸°
className={`notification notification-${type}`}
>
{type === 'error' && <span aria-label="Error">â ï¸</span>}
{type === 'success' && <span aria-label="Success">â
</span>}
{message}
</div>
);
}
Step 4: ìì ëë¹ ë° ìê°ì ì ê·¼ì±
ìê° ì¥ì ì¸ì ìí ì¶©ë¶í ëë¹ì¨ì ë³´ì¥í©ëë¤.
ìì ë´ì©:
- WCAG AA: í ì¤í¸ 4.5:1, í° í ì¤í¸ 3:1
- WCAG AAA: í ì¤í¸ 7:1, í° í ì¤í¸ 4.5:1
- ììë§ì¼ë¡ ì ë³´ ì ë¬ ê¸ì§ (ìì´ì½, í¨í´ ë³í)
- í¬ì»¤ì¤ íì ëª íí (outline)
ìì (CSS):
/* â
ì¶©ë¶í ëë¹ (í
ì¤í¸ #000 on #FFF = 21:1) */
.button {
background-color: #0066cc;
color: #ffffff; /* ëë¹ì¨ 7.7:1 */
}
/* â
í¬ì»¤ì¤ íì */
button:focus,
a:focus {
outline: 3px solid #0066cc;
outline-offset: 2px;
}
/* â outline: none ê¸ì§! */
button:focus {
outline: none; /* ì ë ì¬ì© ê¸ì§ */
}
/* â
ìì + ìì´ì½ì¼ë¡ ìí íì */
.error-message {
color: #d32f2f;
border-left: 4px solid #d32f2f;
}
.error-message::before {
content: 'â ï¸';
margin-right: 8px;
}
Step 5: ì ê·¼ì± í ì¤í¸
ìë ë° ìë í ì¤í¸ë¡ ì ê·¼ì±ì ê²ì¦í©ëë¤.
ìì ë´ì©:
- axe DevToolsë¡ ìë ì¤ìº
- Lighthouse Accessibility ì ì íì¸
- í¤ë³´ëë§ì¼ë¡ ì ì²´ ê¸°ë¥ í ì¤í¸
- ì¤í¬ë¦° 리ë í ì¤í¸ (NVDA, VoiceOver)
ìì (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 í¤
button.focus();
fireEvent.keyDown(button, { key: 'Enter' });
expect(handleClick).toHaveBeenCalled();
// Space í¤
fireEvent.keyDown(button, { key: ' ' });
expect(handleClick).toHaveBeenCalledTimes(2);
});
});
Output format
기본 ì²´í¬ë¦¬ì¤í¸
## Accessibility Checklist
### Semantic HTML
- [x] ìë§¨í± HTML íê·¸ ì¬ì© (`<button>`, `<nav>`, `<main>` ë±)
- [x] ì 목 ê³ì¸µ 구조 ì¬ë°ë¦ (h1 â h2 â h3)
- [x] í¼ ë¼ë²¨ 모ë ì°ê²°ë¨
### Keyboard Navigation
- [x] Tabì¼ë¡ 모ë ì¸í°ëí°ë¸ ìì ì ê·¼ ê°ë¥
- [x] Enter/Spaceë¡ ë²í¼ íì±í
- [x] ESCë¡ ëª¨ë¬/ëë¡ë¤ì´ ë«ê¸°
- [x] í¬ì»¤ì¤ íì ëª
í (outline)
### ARIA
- [x] `role` ì ì í ì¬ì©
- [x] `aria-label` ëë `aria-labelledby` ì ê³µ
- [x] ëì ì½í
ì¸ ì `aria-live` ì¬ì©
- [x] ì¥ìì© ìì `aria-hidden="true"`
### Visual
- [x] ìì ëë¹ WCAG AA ì¤ì (4.5:1)
- [x] ììë§ì¼ë¡ ì ë³´ ì ë¬ ì í¨
- [x] í
ì¤í¸ í¬ê¸° ì¡°ì ê°ë¥
- [x] ë°ìí ëìì¸
### Testing
- [x] axe DevTools ìë° ì¬í 0
- [x] Lighthouse Accessibility 90+ ì ì
- [x] í¤ë³´ë í
ì¤í¸ íµê³¼
- [x] ì¤í¬ë¦° 리ë í
ì¤í¸ ìë£
Constraints
íì ê·ì¹ (MUST)
-
í¤ë³´ë ì ê·¼ì±: 모ë 기ë¥ì ë§ì°ì¤ ìì´ ì¬ì© ê°ë¥í´ì¼ í¨
- Tab, Enter, Space, íì´í, ESC ì§ì
- í¬ì»¤ì¤ í¸ë© 구í (모ë¬)
-
ëì²´ í ì¤í¸: 모ë ì´ë¯¸ì§ì
altìì±- ì미 ìë ì´ë¯¸ì§: ì¤ëª ì alt text
- ì¥ìì© ì´ë¯¸ì§:
alt=""(ì¤í¬ë¦° 리ë 무ì)
-
ëª íí ë¼ë²¨: 모ë í¼ ì ë ¥ì ì°ê²°ë ë¼ë²¨
<label for="...">ëëaria-label- íë ì´ì¤íëë§ì¼ë¡ ë¼ë²¨ ëì²´ ê¸ì§
ê¸ì§ ì¬í (MUST NOT)
-
outline ì ê±° ê¸ì§:
outline: noneì ë ì¬ì© ê¸ì§- í¤ë³´ë ì¬ì©ììê² ì¹ëª ì
- 커ì¤í í¬ì»¤ì¤ ì¤íì¼ ì ê³µ íì
-
tabindex > 0 ì¬ì© ê¸ì§: í¬ì»¤ì¤ ìì ë³ê²½ ì§ì
- DOM ìì를 ë ¼ë¦¬ì ì¼ë¡ ì ì§
- ìì¸: í¹ë³í ì´ì ê° ìë ê²½ì°ë§
-
ììë§ì¼ë¡ ì ë³´ ì ë¬ ê¸ì§: ìì´ì½, í ì¤í¸ ë³í
- ìë§¹ ì¬ì©ì ê³ ë ¤
- ì: “빨ê°ì í목 í´ë¦” â “â ï¸ Error í목 í´ë¦”
Examples
ìì 1: ì ê·¼ ê°ë¥í í¼
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>
{/* ì´ë¦ */}
<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>
{/* ì´ë©ì¼ */}
<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>
{/* ì ì¶ ë²í¼ */}
<button type="submit" disabled={submitStatus === 'loading'}>
{submitStatus === 'loading' ? 'Submitting...' : 'Submit'}
</button>
{/* ì±ê³µ/ì¤í¨ ë©ìì§ */}
{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>
);
}
ìì 2: ì ê·¼ ê°ë¥í í UI
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>
);
}
Best practices
-
ìë§¨í± HTML ì°ì : ARIAë ë§ì§ë§ ìë¨
- ì¬ë°ë¥¸ HTML ìì ì¬ì©íë©´ ARIA ë¶íì
- ì:
<button>vs<div role="button">
-
í¬ì»¤ì¤ ê´ë¦¬: SPAìì íì´ì§ ì í ì í¬ì»¤ì¤ ê´ë¦¬
- ì íì´ì§ ë¡ë ì ë©ì¸ ì½í ì¸ ë¡ í¬ì»¤ì¤ ì´ë
- Skip links ì ê³µ (“Skip to main content”)
-
ìë¬ ë©ìì§: ëª ííê³ ëìì´ ëë ìë¬ ë©ìì§
- “Invalid input” â â “Email must be in format: example@domain.com” â
References
Metadata
ë²ì
- íì¬ ë²ì : 1.0.0
- ìµì¢ ì ë°ì´í¸: 2025-01-01
- í¸í íë«í¼: Claude, ChatGPT, Gemini
ê´ë ¨ ì¤í¬
- ui-component-patterns: UI ì»´í¬ëí¸ êµ¬í
- responsive-design: ë°ìí ëìì¸
íê·¸
#accessibility #a11y #WCAG #ARIA #screen-reader #keyboard-navigation #frontend