18. 접근성
공짜로 받는 것을 실수로 깨뜨리지 않기
- 접근성은 “추가 기능”이 아니라 기본 품질이다
- 그런데 직접 구현하면 정말 어렵다 — 포커스 트랩, 롤 관계, 키보드 규약
- shadcn/ui를 쓰면 상당 부분이 이미 되어 있다. 무엇이 되어 있는지 알아야 망치지 않는다
- 공공기관·금융·대기업 프로젝트에서는 법적 요구사항이기도 하다
프리미티브가 해주는 것
섹션 제목: “프리미티브가 해주는 것”Dialog 하나를 직접 만들면 이만큼을 구현해야 한다.
role="dialog"+aria-modal="true"- 열릴 때 포커스를 안으로 이동, 닫힐 때 원래 위치로 복원
- 포커스 트랩 — Tab이 다이얼로그 밖으로 나가지 않게
- Esc로 닫기
- 바깥 클릭으로 닫기 (단, 드래그 시작이 안쪽이면 제외)
- 배경 스크롤 잠금 + 스크롤바 폭만큼 레이아웃 보정
aria-labelledby/aria-describedby를 제목·설명과 연결- 열린 동안 배경 콘텐츠를
aria-hidden으로
Base UI / Radix / React Aria가 이 전부를 처리한다. 13장에서 “프리미티브가 npm 패키지로 남는 게 오히려 다행”이라고 한 이유가 이것이다.
키보드 규약도 공짜다
섹션 제목: “키보드 규약도 공짜다”| 키 | 기대 동작 |
|---|---|
Tab / Shift+Tab |
다음/이전 포커스 가능 요소 |
Enter / Space |
버튼 실행 |
Esc |
다이얼로그·팝오버 닫기 |
↑ ↓ |
메뉴·셀렉트 항목 이동 |
Home / End |
목록 처음/끝 |
| 문자 입력 | 해당 글자로 시작하는 항목으로 점프 |
이 표의 동작이 Select, DropdownMenu, Tabs, Combobox에서 전부 공짜로 나온다. 직접 만들면 하나하나 구현해야 한다.
시각적으로 깨뜨리기 쉬운 것들
섹션 제목: “시각적으로 깨뜨리기 쉬운 것들”포커스 링을 지우지 말 것
섹션 제목: “포커스 링을 지우지 말 것”가장 흔하고 가장 해로운 실수다.
/* ❌ 절대 하지 말 것 */*:focus { outline: none; }키보드 사용자가 자기가 어디 있는지 알 수 없게 된다. 디자인이 안 예쁘다는 이유로 지우는 경우가 대부분인데, 대안이 있다.
/* ✅ 마우스 클릭에는 안 보이고, 키보드 이동에만 보인다 */:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }15장에서 본 button.tsx의 focus-visible:ring-[3px]가 정확히 이 원칙의 구현이다.
shadcn 컴포넌트를 쓰면 이건 이미 되어 있다 — 전역 CSS로 덮어쓰지만 않으면 된다.
색만으로 정보를 전달하지 않기
섹션 제목: “색만으로 정보를 전달하지 않기”<td><span className="text-green-600">●</span></td><td><span className="text-red-600">●</span></td><td><span className="text-amber-500">●</span></td>색각 이상이 있으면 세 상태를 구분할 수 없다. 흑백 프린터로 뽑아도 마찬가지다.
<td><Badge variant="secondary">완료</Badge></td><td><Badge variant="destructive">실패</Badge></td><td><Badge variant="outline">대기</Badge></td>색은 보조 수단이고, 텍스트가 정보를 전달한다.
남성의 약 8%가 색각 이상을 가진다. 흑백으로 뽑았을 때 구분되면 대체로 안전하다는 것이 실용적인 기준이다.
폼과 이름 붙이기
섹션 제목: “폼과 이름 붙이기”라벨과 오류 연결
섹션 제목: “라벨과 오류 연결”// shadcn/ui의 Form 컴포넌트가 자동으로 연결해 준다<FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>이메일</FormLabel> {/* htmlFor 자동 */} <FormControl> <Input {...field} /> {/* id, aria-invalid, aria-describedby 자동 */} </FormControl> <FormDescription>회사 이메일을 입력하세요</FormDescription> <FormMessage /> {/* role="alert" */} </FormItem> )}/><Label htmlFor>↔<input id>연결이 자동이다- 오류가 생기면
aria-invalid="true"와aria-describedby가 붙는다 - 스크린리더가 **“이메일, 편집, 유효하지 않음, 올바른 형식이 아닙니다”**로 읽는다
15장에서 본 aria-invalid:border-destructive가 여기서 만난다 —
같은 속성 하나가 스크린리더와 시각 표시를 동시에 담당한다.
아이콘 버튼에는 이름을 준다
섹션 제목: “아이콘 버튼에는 이름을 준다”// ❌ 스크린리더가 "버튼"이라고만 읽는다<Button size="icon"><TrashIcon /></Button>
// ✅ 방법 1 — aria-label<Button size="icon" aria-label="삭제"> <TrashIcon /></Button>
// ✅ 방법 2 — 시각적으로만 숨긴 텍스트<Button size="icon"> <TrashIcon /> <span className="sr-only">삭제</span></Button>동적 콘텐츠와 모션
섹션 제목: “동적 콘텐츠와 모션”화면 변화 알리기
섹션 제목: “화면 변화 알리기”화면이 바뀌었는데 포커스가 그대로면 스크린리더 사용자는 모른다.
// 토스트·알림 — role="status" 또는 aria-live<div role="status" aria-live="polite"> 3개 항목이 저장되었습니다</div>
// 긴급한 오류 — assertive (읽던 것을 끊고 알린다)<div role="alert"> 저장에 실패했습니다</div>polite— 읽던 것이 끝나면 알린다. 대부분 이걸 쓴다assertive— 즉시 끊고 알린다. 오류에만 쓴다- shadcn/ui의
Toast,Sonner가 이미 처리해 준다
모션 줄이기
섹션 제목: “모션 줄이기”@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }}<div class="transition-transform motion-reduce:transition-none">- 전정기관 장애가 있는 사용자에게 큰 모션은 어지럼증을 유발한다
- OS 설정을 켜둔 사용자에게만 적용된다
- Tailwind에는
motion-reduce:변형이 있다
점검하기
섹션 제목: “점검하기”flowchart LR
A["자동 도구<br/>eslint-plugin-jsx-a11y<br/>axe DevTools · Lighthouse"] --> C["문제의 30~40% ⚠️"]
B["수동 점검<br/>마우스 없이 조작<br/>200% 확대 · VoiceOver"] --> D["나머지 60~70% ✅"]
classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class C warn
class D ok
class A,B mute
-
린트를 켠다 — 빌드 타임에 명백한 실수를 잡는다
Terminal window pnpm add -D eslint-plugin-jsx-a11y -
axe DevTools로 실행 중인 페이지를 검사한다
-
마우스 없이 전 기능을 써본다 — 가장 효과적인 단일 테스트
-
브라우저 확대 200%에서 레이아웃이 깨지지 않는지 본다
-
macOS VoiceOver(
Cmd+F5)로 주요 흐름을 들어본다
실수로 깨뜨리는 것들
섹션 제목: “실수로 깨뜨리는 것들”<div onClick>— 키보드로 접근 불가.<button>을 쓴다outline: none— 포커스 링 제거- placeholder를 라벨 대신 — 입력하면 사라져서 무엇을 넣는지 알 수 없다
aria-hidden을 포커스 가능한 요소에 — 보이는데 스크린리더는 모르는 상태render/asChild로 태그를 바꾸며 의미를 잃음 — 버튼을<div>로 렌더 (15장)- 자동 재생·자동 캐러셀 — 읽는 중에 내용이 바뀐다
18장 요약
섹션 제목: “18장 요약”- 프리미티브가 포커스 트랩·키보드 규약·ARIA 관계를 대신해 준다
- 목표는 그것을 실수로 깨뜨리지 않는 것
:focus-visible을 쓰고 포커스 링을 지우지 않는다- 색만으로 정보를 전달하지 않는다 — 텍스트를 함께
- 아이콘 버튼에는
aria-label또는sr-only텍스트 - 동적 알림은
role="status"(polite) /role="alert"(assertive) - 마우스 없이 써보는 것이 가장 효과적인 단일 테스트