프론트엔드 접근성 패턴
React 및 Next.js를 위한 실용적인 접근성 패턴. 코드 리뷰에서 가장 자주 지적되는 문제(누락된 폼 라벨, 잘못된 ARIA 사용, 비시맨틱 인터랙티브 요소, 깨진 키보드 네비게이션)를 다룹니다.
활성화 시점
- 폼 컴포넌트(
<input>,<select>,<textarea>)를 구축하거나 리뷰할 때 - 인터랙티브 요소(모달, 드롭다운, 툴팁, 탭)를 만들 때
onClick과 함께<div>또는<span>을 사용할 때- 요소에
aria-*속성을 추가할 때 - 키보드 네비게이션 또는 포커스 관리를 구현할 때
- 코드 리뷰 도구(CodeRabbit, ESLint a11y)로부터 접근성 피드백을 받을 때
- 스크린 리더를 지원해야 하는 컴포넌트를 구축할 때
폼 접근성
코드 리뷰에서 가장 흔히 지적되는 문제는 누락된 htmlFor / id 짝 연결과 분리된 오류 메시지입니다.
라벨 연결
// BAD: 라벨이 input과 연결되지 않음 — 스크린 리더가 연관 지을 수 없음
<label>Email</label>
<input type="email" />
// GOOD: htmlFor가 input의 id와 일치함
<label htmlFor="email">Email</label>
<input id="email" type="email" />
필수 필드
// BAD: 시각적 별표만으로는 스크린 리더에 아무 정보도 전달하지 못함
<label htmlFor="email">Email *</label>
<input id="email" type="email" />
// GOOD: required는 네이티브 브라우저 유효성 검사를 활성화하고, aria-required는 스크린 리더에 신호를 보냄
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />
오류 메시지
// BAD: 오류 텍스트가 시각적으로 존재하지만 input에 연결되지 않음
<input id="email" type="email" />
<span className="error">Invalid email address</span>
// GOOD: aria-describedby가 input을 오류 메시지에 연결함
// aria-invalid가 스크린 리더에 잘못된 상태를 알림
<input
id="email"
type="email"
aria-describedby="email-error"
aria-invalid={!!error}
/>
{error && (
<span id="email-error" role="alert">
{error}
</span>
)}
완전한 접근성 있는 폼
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: typeof errors = {};
if (!email) newErrors.email = 'Email is required';
if (!password) newErrors.password = 'Password is required';
if (Object.keys(newErrors).length) {
setErrors(newErrors);
return;
}
onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
aria-required="true"
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
autoComplete="email"
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">
Password <span aria-hidden="true">*</span>
</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
aria-required="true"
aria-describedby={errors.password ? 'password-error' : undefined}
aria-invalid={!!errors.password}
autoComplete="current-password"
/>
{errors.password && (
<span id="password-error" role="alert">
{errors.password}
</span>
)}
</div>
<button type="submit">Log in</button>
</form>
);
}
시맨틱 HTML
의도와 일치하는 요소를 사용하세요. 스크린 리더와 키보드 사용자는 네이티브 시맨틱에 의존합니다.
// BAD: div에 role, 키보드 지원, 접근 가능한 이름이 없음
<div onClick={handleClick}>Submit</div>
// GOOD: button은 포커스 가능하며, Enter/Space로 활성화되고, "button"으로 알림
<button type="button" onClick={handleClick}>Submit</button>
// BAD: 비시맨틱 네비게이션
<div onClick={() => navigate('/home')}>Home</div>
// GOOD: 앵커는 우클릭, 중간 클릭, 키보드 네비게이션을 지원함
<a href="/home">Home</a>
// BAD: 제목 계층 건너뜀 (h1에서 h4)
<h1>Dashboard</h1>
<h4>Recent Activity</h4>
// GOOD: 순차적인 제목 수준
<h1>Dashboard</h1>
<h2>Recent Activity</h2>
ARIA 속성
네이티브 HTML 시맨틱이 충분하지 않을 때만 ARIA를 사용하세요. 잘못된 ARIA는 ARIA가 없는 것보다 더 나쁩니다.
aria-label vs aria-labelledby
// aria-label: 인라인 문자열 라벨 — 보이는 라벨 텍스트가 없을 때 사용
<button aria-label="Close modal">
<XIcon />
</button>
// aria-labelledby: 다른 요소의 텍스트를 참조 — 보이는 라벨이 있을 때 사용
<section aria-labelledby="section-title">
<h2 id="section-title">Recent Orders</h2>
{/* content */}
</section>
aria-describedby
// 라벨 외에 추가 설명을 제공
<button
aria-describedby="delete-warning"
onClick={handleDelete}
> Delete account
</button>
<p id="delete-warning">This action cannot be undone.</p>
동적 콘텐츠를 위한 aria-live
// 페이지 새로고침 없이 업데이트되는 콘텐츠를 알리려면 aria-live를 사용하세요.
// polite: 현재 작업이 끝날 때까지 기다린 후 알림
// assertive: 즉시 중단 — 긴급한 오류에만 사용
export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
return (
<div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
{message}
</div>
);
}
aria-expanded와 aria-controls
export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const contentId = useId();
return (
<div>
<button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
{title}
</button>
<div id={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
}
키보드 네비게이션
모든 인터랙티브 요소는 키보드만으로 도달 가능하고 작동 가능해야 합니다.
커스텀 드롭다운
export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const listId = useId();
if (!options.length) return null;
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen) onSelect(options[activeIndex]);
setIsOpen(prev => !prev);
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listId}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => setIsOpen(prev => !prev)}
>
<span>{options[activeIndex]}</span>
{isOpen && (
<ul id={listId} role="listbox">
{options.map((option, index) => (
<li
key={option}
role="option"
aria-selected={index === activeIndex}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
포커스 관리
UI 상태가 변경될 때 포커스는 논리적으로 이동해야 합니다. 특히 모달 및 경로 전환에서 중요합니다.
모달 포커스 복원
이 예제는 초기 포커스와 복원을 다룹니다. Tab/Shift+Tab 순환이 포함된 전체 포커스 트랩을 구현하려면 동적 콘텐츠 및 중첩 포털과 같은 엣지 케이스를 처리하는
focus-trap-react와 같은 라이브러리를 사용하세요.
export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// 현재 포커스된 요소를 저장하고 포커스를 모달로 이동
previousFocusRef.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
} else {
// 모달을 연 요소로 포커스 복원
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
이미지 및 아이콘
// BAD: 장식용 아이콘이 레이블 없는 이미지로 알려짐
<img src="/icon.svg" />
// GOOD: 장식용 이미지가 스크린 리더에서 숨겨짐
<img src="/decoration.png" alt="" aria-hidden="true" />
// GOOD: 의미 있는 이미지에 설명적인 대체 텍스트 제공
<img src="/chart.png" alt="Monthly revenue increased 23% from January to March" />
// GOOD: 접근 가능한 레이블이 있는 아이콘 버튼
<button aria-label="Delete item">
<TrashIcon aria-hidden="true" />
</button>
동작 줄이기
OS 설정에서 동작 줄이기를 요청한 사용자를 존중하세요.
export function useReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return prefersReduced;
}
// 사용법
export function AnimatedCard({ children }: { children: React.ReactNode }) {
const reduceMotion = useReducedMotion();
return (
<div
style={{
transition: reduceMotion ? 'none' : 'transform 300ms ease'
}}
>
{children}
</div>
);
}
안티패턴
// BAD: 키보드 지원이 없는 비인터랙티브 요소의 onClick
<div onClick={handleClick}>Click me</div>
// BAD: role이 없는 div의 aria-label
<div aria-label="Navigation">...</div>
// BAD: 라벨 대체로 placeholder 사용
<input placeholder="Enter your email" />
// BAD: 양수 tabIndex는 예측 불가능한 탭 순서를 만듦
<button tabIndex={3}>Submit</button>
// BAD: 포커스 가능한 요소의 aria-hidden — 키보드 사용자가 갇힘
<button aria-hidden="true">Open</button>
// BAD: 키보드 핸들러 없는 div의 role="button"
<div role="button" onClick={handleClick}>Submit</div>
// 누락: tabIndex={0}, Enter/Space를 위한 onKeyDown
체크리스트
리뷰를 위해 인터랙티브 컴포넌트를 제출하기 전에:
- 모든
<input>,<select>,<textarea>가htmlFor/id를 통해 연결된<label>을 가지고 있는가 - 오류 메시지가
aria-describedby로 연결되고role="alert"가 표시되었는가 -
role,tabIndex,onKeyDown없이<div>또는<span>에onClick이 사용되지 않았는가 - 아이콘 전용 버튼에
aria-label이 있는가 - 장식용 이미지에
alt=""및aria-hidden="true"가 사용되었는가 - 모달이 닫힐 때 포커스를 복원하는가 (Tab/Shift+Tab 순환을 포함한 전체 포커스 트래핑은
focus-trap-react와 같은 라이브러리 사용) - 동적 콘텐츠 업데이트에
aria-live가 사용되었는가 - 애니메이션에 대해
prefers-reduced-motion이 존중되는가
관련 스킬
frontend-patterns— 일반 React 컴포넌트 및 상태 패턴design-system— 디자인 토큰 및 컴포넌트 일관성motion-foundations및motion-patterns: 접근성을 고려한 애니메이션 패턴

