# accessibility.md — 접근성 구현 계약 4단계 구현부터 참조하고, 5단계 프리플라이트의 접근성 표([preflight.md](preflight.md) §1)가 가리키는 정본이다. `preflight.md`는 체크리스트 행만 두고 여기를 가리킨다 — 이 문서는 체크리스트를 반복하지 않고 **왜 실패하는지, 어떤 코드로 고치는지, 무엇을 참조하는지**를 다룬다. 라벨은 셋 중 하나다. **하드 게이트**는 위반하면 통과시키지 않는다 — WCAG AA·키보드·포커스·대비·감소 모션·진실성처럼 규범 또는 기능에 근거한다. **프로젝트 계약**은 브리프·`design.md`·플랫폼이 그 조건을 선택했을 때만 적용된다 — AAA 기준, HIG·M3의 44/48px 같은 모바일 앱 수준 목표가 여기 속한다. **관찰 후보**는 스타일 휴리스틱의 시작값이며 실제 렌더에서 확인해 조정한다. 외부 스킬에서 가져온 수치는 "시작값"이라 적고, 근거가 약하면 "실측 조정"을 덧붙인다. ## 이 문서를 읽는 법 | 상황 | 읽을 곳 | |---|---| | AA와 AAA, 자동 점수와 실제 준수의 관계가 헷갈린다 | §0 | | `
` 대신 뭘 써야 하는지, ARIA를 언제 쓰는지 | §1 | | 아이콘 전용 버튼, 인라인 SVG의 이름을 어떻게 붙이는지 | §2 | | 포커스 링 스타일·대비·가림 방지 | §3 | | 탭·메뉴·콤보박스·리스트박스·다이얼로그의 키보드 계약, 모달, SPA 라우팅 | §4 | | 스킵 링크·랜드마크·링크 텍스트 | §5 | | 라벨·자동완성·입력 타입·비활성화 규칙·오류 처리 | §6 | | 토스트·로딩 상태를 어떻게 알리는지 | §7 | | 터치 타깃 크기와 히트 영역 확장 | §8 | | 깜빡임·자동재생·호버 콘텐츠 | §9 | | 드래그로만 되는 조작의 대안 | §10 | | 비디오·오디오 자막 | §11 | | `prefers-*` 미디어 쿼리 중 뭐가 하드 게이트고 뭐가 점진적 향상인지 | §12 | | 감사를 어떤 순서로 돌리는지, MCP가 없을 때 뭘 쓰는지 | §13 | | 발견한 문제를 어떤 형식으로 보고하는지 | §14 | | 색·모션 단독 전달 금지(다른 문서가 이 문서를 정본으로 지목한 규칙) | 부록 A | --- ## 0. 판정 원칙 **하드 게이트.** WCAG 2.2는 A·AA·AAA 세 적합성 수준을 정의하며, 셋은 서로 다른 요구다 — AAA를 만족한다고 모두에게 접근 가능해지지는 않는다[WCAG-22]. designpaca는 **AA를 하드 게이트**로 삼는다. 이 문서의 모든 절은 별도 표기가 없는 한 AA 이하 기준을 하드 게이트로 취급한다. **프로젝트 계약.** AAA 기준([WCAG-FOCUS-APPEAR] 2.4.13 등)과 HIG·M3의 44/48px 같은 플랫폼 권장은 브리프가 그 수준을 선택했을 때만 적용된다. "모바일 앱 수준"이라는 말이 브리프에 있으면 project contract로 올라간다(§8). **자동 점수는 준수가 아니다.** Lighthouse 접근성 100점이나 axe 통과는 도구가 검출할 수 있는 하위 집합만 확인한 것이다. 100점이 WCAG 준수와 같지 않고, 낮은 점수도 그 자체로 이슈 증거를 대체하지 않는다[SKILL-WEB-A11Y]. 통과 판정은 점수가 아니라 §13의 감사 루프(키보드 조작·스크린리더 확인 포함)로 내린다. **표준 기반 평가는 사용자 평가로 보완한다.** W3C 자신이 표준 기반 적합성 평가와 실제 사용자 평가를 함께 써야 한다고 설명한다 — 적은 표본이나 한 번의 평가를 모든 사용자에게 일반화하지 않는다[W3C-USER-EVAL]. 자동 검사·수동 APG 대조·스크린리더 확인을 전부 통과했더라도, 실사용자 피드백이 오면 그것으로 규칙을 다시 검토한다. **APCA는 규범이 아니다.** 대비의 하드 게이트는 WCAG 2 비율이다. APCA는 2026년 기준으로도 WCAG 3 초안에 이름조차 실려 있지 않고 자체 문서가 스스로를 beta로 표시하는 평가 후보일 뿐이다[APCA-STATUS] — 상세는 [color.md](color.md) §6. --- ## 1. 시맨틱·네이티브 우선 **하드 게이트.** ARIA를 쓰기 전에 같은 의미·동작을 가진 네이티브 HTML 요소가 있는지 먼저 확인한다. 다섯 가지 ARIA 원칙이다[SKILL-BETTER-A11Y]: 1. 필요한 시맨틱·동작을 가진 네이티브 요소가 있으면 다른 요소를 ARIA로 흉내 내지 않고 그것을 쓴다. 2. 정말 필요하지 않으면 네이티브 시맨틱을 바꾸지 않는다. 3. 인터랙티브 ARIA 컨트롤은 반드시 키보드로 조작할 수 있어야 한다 — role은 그 위젯의 전체 키보드 모델을 지키겠다는 약속이다. 4. 포커스를 받는 요소에 `role="presentation"`이나 `aria-hidden="true"`를 걸지 않는다. 5. 모든 인터랙티브 요소는 접근 가능한 이름을 가져야 한다(§2). 나쁜 ARIA보다 ARIA가 없는 편이 낫다. 스크린리더는 role을 그대로 믿으므로, 틀린 role은 아무것도 없는 것보다 더 나쁘다. | 요소 | 쓰는 곳 | 이유 | |---|---|---| | `` | 이동 — 어딘가로 가거나 URL이 바뀌는 모든 것 | Cmd/Ctrl/가운데 클릭, 우클릭으로 링크 복사, Enter로 활성화가 전부 공짜다 | | ` ``` 보이는 게 클릭 가능하면 실제로 클릭 가능해야 하고, 클릭 가능하면 실제 인터랙티브 요소여야 한다. 링크를 버튼으로, 버튼을 링크로 다시 만들면 사용자의 기대(가운데 클릭으로 새 탭 열기 등)가 깨진다. 이동하는 "버튼"은 스타일만 버튼인 ``다[SKILL-BETTER-A11Y]. **네이티브 요소를 정말 쓸 수 없을 때만** `role="button"` + `tabindex="0"`의 완전한 폴리필을 쓴다(코드는 §4). 코드량 자체가 네이티브 요소보다 항상 많다는 사실이 네이티브를 우선해야 하는 실무적 이유이기도 하다. **흔한 ARIA 실수**[SKILL-BETTER-A11Y] | 실수 | 왜 실패하는가 | |---|---| | `
`나 ``에 `aria-label` | 대부분의 스크린리더가 인터랙티브하지 않고 role도 없는 요소의 이름은 무시한다 | | ` ``` **Label in Name — [WCAG-LABEL-NAME] (2.5.3).** 보이는 라벨과 접근 가능한 이름이 다르면 음성 제어 사용자가 깨진다. 버튼에 "보내기"라고 쓰여 있는데 `aria-label="메시지 전송"`을 걸면, "보내기 클릭"이라고 말하는 사용자의 명령이 인식되지 않는다. 접근 가능한 이름은 보이는 텍스트를 반드시 포함해야 한다[SKILL-BETTER-A11Y]. **인라인 SVG**는 장식인지 의미가 있는지로 마크업이 갈린다[SKILL-BETTER-A11Y]. ```html … ``` `focusable="false"`는 레거시 IE·Edge가 SVG를 탭 순서에 넣는 것을 막기 위한 것이다. 단순한 경우에는 `…`가 가장 안정적으로 전달된다. **브랜드명·코드 토큰에는 `translate="no"`를 붙인다.** 한국어 로케일 프로젝트에서 영문 브랜드명·식별자가 브라우저 자동번역 확장에 의해 깨지는 것을 막는다[SKILL-BETTER-A11Y]. ```html designpaca ``` **모달·시트·드로어의 제목.** 열리는 표면은 항상 접근 가능한 제목을 가져야 한다. 시각적으로 숨기려면 `.sr-only`(정의는 [motion.md](motion.md) 코드 G)로라도 존재해야 하며, 완전히 생략하는 것은 [WCAG-NRV] 위반이다[SKILL-SHADCN]. 컴포넌트 시스템을 쓰는 프로젝트의 합성 규칙은 [component-systems.md](component-systems.md)를 따른다. --- ## 3. 포커스 **하드 게이트.** `:focus-visible`만 스타일링하고 `:focus` 단독을 쓰지 않는다. 브라우저는 키보드·보조기술 포커스에서만 `:focus-visible`을 보여 주고 마우스 클릭에서는 억제한다 — 클릭에서는 이미 포커스가 명백하기 때문이다. 검증된 대체 없이 `outline: none`을 쓰지 않는다. 이는 눈으로 보는 키보드 사용자의 내비게이션 자체를 지운다[SKILL-BETTER-A11Y]. ```css /* 가장 안전: 브라우저 기본 링을 유지하고 여백만 준다 */ :focus-visible { outline-offset: 2px; } /* 브랜드 링이 필요할 때: 검증된 토큰을 쓴다 */ :focus-visible { outline: 2px solid var(--ring, currentColor); outline-offset: 2px; } ``` 브라우저의 기본 포커스 링은 플랫폼·고대비 설정에 맞춰 스스로 적응한다. `outline-offset`만 얹으면 그 적응력을 유지한 채 여백만 얻는다. 커스텀 링을 쓰기로 했다면 `currentColor`나 토큰이 **닿는 모든 인접 색**(카드·히어로·다크모드 표면)에서 3:1 이상인지 직접 검증한다 — [WCAG-NONTEXT] (1.4.11)이 요구하는 것은 텍스트 대비가 아니라 인접 배경과의 대비다. `:focus-within`은 래퍼가 켜져야 하는 경우에 쓴다 — 테두리 안에 아이콘이 있는 검색창처럼, 내부 입력이 포커스를 받으면 바깥 래퍼도 함께 밝아져야 할 때다[SKILL-BETTER-A11Y]. **포커스 가림 방지 — [WCAG-FOCUS-OBSCURED] (2.4.11, WCAG 2.2 신규, AA).** 키보드 포커스를 받은 요소가 스티키 헤더·고정 CTA 바에 완전히 가려지면 안 된다. 앵커 타깃과 포커스 가능 요소에 `scroll-margin-top`을 주되, 값을 손으로 맞추지 않고 [tokens.md](tokens.md)의 `--header-h` 토큰과 연결한다. ```css :focus-visible, [data-anchor-target] { scroll-margin-top: var(--header-h); } ``` **`forced-colors: active` (Windows 고대비 모드).** 이 미디어 쿼리는 Widely available 상태다(확인 2026-09-24)[WEB-BASELINE]. 기본 색 조정을 유지하거나 `Highlight` 같은 시스템 색을 명명한다. `forced-color-adjust: none`은 저작한 색을 그대로 고정하므로, 강제 색 치환 후에도 실제로 지각 가능함을 검증한 곳에서만 쓴다[SKILL-BETTER-A11Y]. **프로젝트 계약 — [WCAG-FOCUS-APPEAR] (2.4.13, AAA).** 포커스 표시 영역이 비포커스 상태 컴포넌트 둘레 2px 두께 이상이고, 포커스·비포커스 상태 사이 대비가 3:1 이상이어야 한다는 더 엄격한 기준이다. 브리프가 AAA를 명시했을 때만 적용한다. --- ## 4. 키보드 ### 4.1 tabindex 규칙 **하드 게이트.** `tabindex="0"`은 자연 탭 순서에 합류시킬 때만 쓴다 — 네이티브로 포커스되지 않는 커스텀 인터랙티브 요소 전용이다. `tabindex="-1"`은 JS로만 포커스할 대상(헤딩·모달 컨테이너·roving tabindex 멤버)에 쓴다. **양수 tabindex는 절대 쓰지 않는다** — 페이지 전체의 탭 순서를 납치한다. 순서가 틀렸으면 tabindex가 아니라 DOM 순서를 고친다[SKILL-BETTER-A11Y]. ### 4.2 roving tabindex — 복합 위젯 탭·메뉴·툴바·라디오 그룹처럼 하나로 묶여 Tab 정지점 하나만 차지해야 하는 위젯은 활성 항목만 `tabindex="0"`, 나머지는 `tabindex="-1"`로 두고 화살표 키가 포커스와 `0`을 함께 옮긴다[SKILL-BETTER-A11Y]. ```html
``` ```js // ArrowLeft/ArrowRight로 activeIndex를 옮기고, 옮긴 탭에만 tabindex=0을 준다 function onTabsKeydown(e, tabs, activeIndex) { const delta = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; if (!delta) return; const next = (activeIndex + delta + tabs.length) % tabs.length; // 끝에서 wrap tabs[activeIndex].tabIndex = -1; tabs[next].tabIndex = 0; tabs[next].focus(); return next; } ``` ### 4.3 커스텀 인터랙티브 요소의 키보드 핸들러 네이티브 요소를 정말 쓸 수 없을 때, `role="button"` + `tabindex="0"`의 완전한 폴리필은 클릭 리스너에 더해 `keydown`에서 Enter·Space를 처리한다. **기본 동작을 막지 않으면 Space가 페이지를 스크롤한다.** ```js el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } }); ``` **함정 — 네이티브 `