콘텐츠로 이동

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.tsxfocus-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>

색각 이상이 있으면 세 상태를 구분할 수 없다. 흑백 프린터로 뽑아도 마찬가지다.

남성의 약 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
  1. 린트를 켠다 — 빌드 타임에 명백한 실수를 잡는다

    Terminal window
    pnpm add -D eslint-plugin-jsx-a11y
  2. axe DevTools로 실행 중인 페이지를 검사한다

  3. 마우스 없이 전 기능을 써본다 — 가장 효과적인 단일 테스트

  4. 브라우저 확대 200%에서 레이아웃이 깨지지 않는지 본다

  5. macOS VoiceOver(Cmd+F5)로 주요 흐름을 들어본다

  • <div onClick> — 키보드로 접근 불가. <button>을 쓴다
  • outline: none — 포커스 링 제거
  • placeholder를 라벨 대신 — 입력하면 사라져서 무엇을 넣는지 알 수 없다
  • aria-hidden을 포커스 가능한 요소에 — 보이는데 스크린리더는 모르는 상태
  • render/asChild로 태그를 바꾸며 의미를 잃음 — 버튼을 <div>로 렌더 (15장)
  • 자동 재생·자동 캐러셀 — 읽는 중에 내용이 바뀐다
  • 프리미티브가 포커스 트랩·키보드 규약·ARIA 관계를 대신해 준다
  • 목표는 그것을 실수로 깨뜨리지 않는 것
  • :focus-visible을 쓰고 포커스 링을 지우지 않는다
  • 색만으로 정보를 전달하지 않는다 — 텍스트를 함께
  • 아이콘 버튼에는 aria-label 또는 sr-only 텍스트
  • 동적 알림은 role="status"(polite) / role="alert"(assertive)
  • 마우스 없이 써보는 것이 가장 효과적인 단일 테스트