이 글은 누구를 위한 것인가
- 스크린리더 사용자를 포함한 모든 사용자를 위한 UI를 만들려는 팀
- WCAG 2.2 AA 기준을 충족해야 하는 프로젝트 개발자
- 모달, 탭, 드롭다운 같은 복잡한 컴포넌트의 접근성을 개선하려는 팀
들어가며
접근성은 장애인만을 위한 것이 아니다. 키보드 전용 사용자, 임시 장애(손 부상), 열악한 환경(밝은 햇빛, 소음)의 사용자 모두에게 영향을 미친다. ARIA는 HTML 시맨틱만으로 표현할 수 없는 UI 패턴을 스크린리더에 전달하는 표준이다.
이 글은 bluefoxdev.kr의 웹 접근성 ARIA 가이드 를 참고하여 작성했습니다.
1. ARIA 핵심 개념
[ARIA 3가지 요소]
1. Role (역할): 요소의 의미
role="button" → 클릭 가능한 버튼
role="dialog" → 모달 다이얼로그
role="tablist" → 탭 그룹
role="alert" → 긴급 알림 (즉시 읽음)
role="status" → 비긴급 알림 (여유롭게 읽음)
2. Properties (속성): 불변 특성
aria-label → 시각적 레이블 없을 때 대체 텍스트
aria-labelledby → 레이블 역할 요소 ID 참조
aria-describedby → 추가 설명 요소 ID 참조
aria-required → 필수 입력 필드
3. States (상태): 동적으로 변하는 값
aria-expanded → 열림/닫힘
aria-selected → 선택됨
aria-disabled → 비활성화
aria-invalid → 유효성 오류
aria-live → 동적 콘텐츠 알림
[ARIA 사용 원칙]
1. 시맨틱 HTML 우선 (<button> > <div role="button">)
2. 네이티브 시맨틱 변경 금지 (<h2 role="button"> X)
3. 인터랙티브 ARIA는 키보드 접근 가능해야 함
4. aria-hidden="true" 요소는 포커스 받으면 안 됨
[WCAG 2.2 AA 핵심]
색상 대비: 일반 텍스트 4.5:1 이상
터치 타겟: 최소 24×24px (권장 44×44px)
포커스 표시: 2px 이상 명확한 시각적 표시
레이블: 모든 폼 컨트롤에 연결된 레이블
2. 접근성 UI 패턴 구현
// 1. 접근성 모달 - 포커스 트랩 + Esc 닫기
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, title, children }: {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
} else {
previousFocusRef.current?.focus();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
function trapFocus(e: KeyboardEvent) {
if (e.key === 'Escape') { onClose(); return; }
if (e.key !== 'Tab') return;
const focusable = Array.from(
dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])'
) ?? []
);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
}
document.addEventListener('keydown', trapFocus);
return () => document.removeEventListener('keydown', trapFocus);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose} aria-hidden="true">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal"
onClick={e => e.stopPropagation()}
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="닫기">×</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
);
}
// 2. 접근성 탭 - Roving Tabindex + Arrow Key 내비게이션
function Tabs({ tabs }: { tabs: { id: string; label: string; content: React.ReactNode }[] }) {
const [activeTab, setActiveTab] = useState(tabs[0].id);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
function handleKeyDown(e: React.KeyboardEvent, currentId: string) {
const ids = tabs.map(t => t.id);
const idx = ids.indexOf(currentId);
let next: number | null = null;
if (e.key === 'ArrowRight') next = (idx + 1) % ids.length;
else if (e.key === 'ArrowLeft') next = (idx - 1 + ids.length) % ids.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = ids.length - 1;
if (next !== null) {
e.preventDefault();
const nextId = ids[next];
setActiveTab(nextId);
tabRefs.current.get(nextId)?.focus();
}
}
return (
<div>
<div role="tablist" aria-label="콘텐츠 탭">
{tabs.map(tab => (
<button
key={tab.id}
ref={el => el && tabRefs.current.set(tab.id, el)}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onKeyDown={e => handleKeyDown(e, tab.id)}
>
{tab.label}
</button>
))}
</div>
{tabs.map(tab => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== tab.id}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
// 3. aria-live 동적 알림 + 접근성 폼
function AccessibleForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
return (
<form noValidate onSubmit={e => { e.preventDefault(); /* 검증 로직 */ }}>
{/* 오류 요약: 제출 실패 시 상단에 표시 */}
{Object.keys(errors).length > 0 && (
<div role="alert" aria-label="입력 오류 목록">
<h2>다음 항목을 수정해주세요</h2>
<ul>
{Object.entries(errors).map(([field, msg]) => (
<li key={field}><a href={`#${field}`}>{msg}</a></li>
))}
</ul>
</div>
)}
<div>
<label htmlFor="email">
이메일
<span aria-hidden="true" style={{ color: 'red' }}> *</span>
<span className="sr-only">(필수)</span>
</label>
<input
id="email"
type="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
/>
<span id="email-hint" className="hint">예: user@example.com</span>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
</form>
);
}
// 스킵 내비게이션 (키보드 사용자 핵심)
function SkipLink() {
return (
<a href="#main-content" className="skip-link">
본문으로 건너뛰기
</a>
);
}
/* visually-hidden: 시각적으로 숨기되 스크린리더는 읽음 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* 스킵 링크: 평소 숨김, 포커스 시 표시 */
.skip-link {
position: absolute;
top: -100%;
left: 0;
z-index: 9999;
padding: 8px 16px;
background: #000;
color: #fff;
text-decoration: none;
font-weight: bold;
}
.skip-link:focus { top: 0; }
/* 포커스 스타일 (절대 제거 금지) */
:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
border-radius: 4px;
}
/* 마우스 클릭 시에는 포커스 링 숨김 */
:focus:not(:focus-visible) {
outline: none;
}
/* 고대비 모드 대응 */
@media (forced-colors: active) {
:focus-visible {
outline: 3px solid ButtonText;
}
}
/* 모션 감소 선호 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
마무리
접근성의 첫 번째 원칙은 시맨틱 HTML 우선이다. <button>을 쓰면 role, keyboard 접근, focus가 자동 처리된다. ARIA는 HTML 시맨틱으로 부족한 경우에만 보완한다. 모달의 포커스 트랩, 탭의 Roving Tabindex, aria-live의 동적 알림, aria-invalid + aria-describedby의 오류 연결은 스크린리더 경험의 핵심 네 가지다. :focus-visible로 마우스와 키보드 사용자의 포커스 스타일을 분리하면 시각적 노이즈 없이 접근성을 확보할 수 있다.