- Restore the step-0 interview as a mechanical gate the skill explicitly depends on; add harness.md (per-harness question tools, limits, fallbacks) and brief-interview.md (slots, question cards, rounds). - Add 10 reference docs absorbed from external design skills (accessibility, interaction-feel, elevation, color, icons, product-copy, component-systems, critique, change-review, print-email) and extend existing references. - Add a review-only route and two hard-gate clauses (truncated content reachability, three-flashes limit). - design-gate: split tap targets into WCAG 2.5.8 and 44px contract layers, run axe-core when available, and fix false positives found on a real site (decorative alt="", stacked wordmark line count, url-only pages). - lint-skill: fail if the interview gate section or its links disappear. - Ship agents/openai.yaml and THIRD_PARTY_NOTICES.md.
42 KiB
accessibility.md — 접근성 구현 계약
4단계 구현부터 참조하고, 5단계 프리플라이트의 접근성 표(preflight.md §1)가 가리키는 정본이다. preflight.md는 체크리스트 행만 두고 여기를 가리킨다 — 이 문서는 체크리스트를 반복하지 않고 왜 실패하는지, 어떤 코드로 고치는지, 무엇을 참조하는지를 다룬다.
라벨은 셋 중 하나다. 하드 게이트는 위반하면 통과시키지 않는다 — WCAG AA·키보드·포커스·대비·감소 모션·진실성처럼 규범 또는 기능에 근거한다. 프로젝트 계약은 브리프·design.md·플랫폼이 그 조건을 선택했을 때만 적용된다 — AAA 기준, HIG·M3의 44/48px 같은 모바일 앱 수준 목표가 여기 속한다. 관찰 후보는 스타일 휴리스틱의 시작값이며 실제 렌더에서 확인해 조정한다. 외부 스킬에서 가져온 수치는 "시작값"이라 적고, 근거가 약하면 "실측 조정"을 덧붙인다.
이 문서를 읽는 법
| 상황 | 읽을 곳 |
|---|---|
| AA와 AAA, 자동 점수와 실제 준수의 관계가 헷갈린다 | §0 |
<div onClick> 대신 뭘 써야 하는지, 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 §6.
1. 시맨틱·네이티브 우선
하드 게이트. ARIA를 쓰기 전에 같은 의미·동작을 가진 네이티브 HTML 요소가 있는지 먼저 확인한다. 다섯 가지 ARIA 원칙이다[SKILL-BETTER-A11Y]:
- 필요한 시맨틱·동작을 가진 네이티브 요소가 있으면 다른 요소를 ARIA로 흉내 내지 않고 그것을 쓴다.
- 정말 필요하지 않으면 네이티브 시맨틱을 바꾸지 않는다.
- 인터랙티브 ARIA 컨트롤은 반드시 키보드로 조작할 수 있어야 한다 — role은 그 위젯의 전체 키보드 모델을 지키겠다는 약속이다.
- 포커스를 받는 요소에
role="presentation"이나aria-hidden="true"를 걸지 않는다. - 모든 인터랙티브 요소는 접근 가능한 이름을 가져야 한다(§2).
나쁜 ARIA보다 ARIA가 없는 편이 낫다. 스크린리더는 role을 그대로 믿으므로, 틀린 role은 아무것도 없는 것보다 더 나쁘다.
| 요소 | 쓰는 곳 | 이유 |
|---|---|---|
<a href> |
이동 — 어딘가로 가거나 URL이 바뀌는 모든 것 | Cmd/Ctrl/가운데 클릭, 우클릭으로 링크 복사, Enter로 활성화가 전부 공짜다 |
<button> |
액션 — 제출·토글·열기·삭제 | 포커스, Enter와 Space 둘 다로 활성화, 폼 시맨틱이 전부 공짜다 |
<div onClick> |
아무 데도 쓰지 않는다 | role도 포커스도 키보드도 없다. 스크린리더에는 그냥 텍스트다 |
<!-- 나쁨: 키보드와 스크린리더에서 보이지 않는다 -->
<div onclick="openSettings()">설정</div>
<!-- 좋음: 포커스·Enter/Space 활성화·시맨틱이 전부 공짜다 -->
<button onclick="openSettings()">설정</button>
보이는 게 클릭 가능하면 실제로 클릭 가능해야 하고, 클릭 가능하면 실제 인터랙티브 요소여야 한다. 링크를 버튼으로, 버튼을 링크로 다시 만들면 사용자의 기대(가운데 클릭으로 새 탭 열기 등)가 깨진다. 이동하는 "버튼"은 스타일만 버튼인 <a>다[SKILL-BETTER-A11Y].
네이티브 요소를 정말 쓸 수 없을 때만 role="button" + tabindex="0"의 완전한 폴리필을 쓴다(코드는 §4). 코드량 자체가 네이티브 요소보다 항상 많다는 사실이 네이티브를 우선해야 하는 실무적 이유이기도 하다.
흔한 ARIA 실수[SKILL-BETTER-A11Y]
| 실수 | 왜 실패하는가 |
|---|---|
<div>나 <span>에 aria-label |
대부분의 스크린리더가 인터랙티브하지 않고 role도 없는 요소의 이름은 무시한다 |
<button role="button"> |
중복 role. 잡음만 추가하고 이득이 없다 |
포커스 가능한 요소에 걸린 aria-hidden="true" |
Tab으로는 갈 수 있지만 스크린리더에는 존재하지 않는 요소가 생긴다 |
존재하지 않는 id를 가리키는 aria-labelledby/aria-describedby |
조용히 이름·설명이 없는 것으로 처리된다 |
내비게이션 목록에 role="menu" |
menu는 앱 스타일 화살표 키 동작을 약속한다. 사이트 내비게이션은 <nav> + 리스트다 |
2. 접근 가능한 이름
하드 게이트. 모든 인터랙티브 컨트롤은 접근 가능한 이름이 있어야 한다[WCAG-NRV]. 이름의 우선순위는 aria-labelledby > aria-label > 네이티브 라벨(<label>·텍스트 콘텐츠·alt) > title 속성이다. 가능하면 보이는 텍스트나 aria-labelledby를 쓴다 — aria-label은 화면에 보이지 않아 UI와 어긋나기 쉽고 번역 도구가 일관되게 처리하지 못한다[SKILL-BETTER-A11Y].
아이콘 전용 버튼은 텍스트 라벨이 없으므로 항상 이름이 필요하다. 아이콘 자체는 장식이므로 aria-hidden="true"로 접근성 트리에서 뺀다.
<!-- 좋음: 보이는 텍스트에서 이름이 나오고 아이콘은 숨긴다 -->
<button>
<svg aria-hidden="true">…</svg> 삭제
</button>
<!-- 좋음: 아이콘만 있을 때는 명시적 이름 -->
<button aria-label="삭제">
<svg aria-hidden="true">…</svg>
</button>
Label in Name — [WCAG-LABEL-NAME] (2.5.3). 보이는 라벨과 접근 가능한 이름이 다르면 음성 제어 사용자가 깨진다. 버튼에 "보내기"라고 쓰여 있는데 aria-label="메시지 전송"을 걸면, "보내기 클릭"이라고 말하는 사용자의 명령이 인식되지 않는다. 접근 가능한 이름은 보이는 텍스트를 반드시 포함해야 한다[SKILL-BETTER-A11Y].
인라인 SVG는 장식인지 의미가 있는지로 마크업이 갈린다[SKILL-BETTER-A11Y].
<!-- 장식 아이콘: 이름을 가진 버튼 안에 있다 -->
<button aria-label="닫기">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
<!-- 독립된 의미 있는 아이콘 -->
<svg role="img" aria-label="인증된 계정">…</svg>
focusable="false"는 레거시 IE·Edge가 SVG를 탭 순서에 넣는 것을 막기 위한 것이다. 단순한 경우에는 <img src="icon.svg" alt="…">가 가장 안정적으로 전달된다.
브랜드명·코드 토큰에는 translate="no"를 붙인다. 한국어 로케일 프로젝트에서 영문 브랜드명·식별자가 브라우저 자동번역 확장에 의해 깨지는 것을 막는다[SKILL-BETTER-A11Y].
<span translate="no">designpaca</span>
모달·시트·드로어의 제목. 열리는 표면은 항상 접근 가능한 제목을 가져야 한다. 시각적으로 숨기려면 .sr-only(정의는 motion.md 코드 G)로라도 존재해야 하며, 완전히 생략하는 것은 [WCAG-NRV] 위반이다[SKILL-SHADCN]. 컴포넌트 시스템을 쓰는 프로젝트의 합성 규칙은 component-systems.md를 따른다.
3. 포커스
하드 게이트. :focus-visible만 스타일링하고 :focus 단독을 쓰지 않는다. 브라우저는 키보드·보조기술 포커스에서만 :focus-visible을 보여 주고 마우스 클릭에서는 억제한다 — 클릭에서는 이미 포커스가 명백하기 때문이다. 검증된 대체 없이 outline: none을 쓰지 않는다. 이는 눈으로 보는 키보드 사용자의 내비게이션 자체를 지운다[SKILL-BETTER-A11Y].
/* 가장 안전: 브라우저 기본 링을 유지하고 여백만 준다 */
: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의 --header-h 토큰과 연결한다.
: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].
<div role="tablist">
<button role="tab" id="tab-1" tabindex="0" aria-selected="true">개요</button>
<button role="tab" id="tab-2" tabindex="-1" aria-selected="false">가격</button>
<button role="tab" id="tab-3" tabindex="-1" aria-selected="false">FAQ</button>
</div>
// 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가 페이지를 스크롤한다.
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activate();
}
});
함정 — 네이티브 <button>에는 이 핸들러를 절대 추가하지 않는다. 네이티브 버튼은 이미 Enter와 Space에서 click 이벤트를 발생시킨다. 같은 자리에 keydown 핸들러로 같은 액션을 또 호출하면, 키보드 사용자만 클릭이 두 번 실행되는(이중 트리거) 버그가 생긴다. 이 핸들러는 role="button"으로 흉내 낸 커스텀 요소 전용이다[SKILL-WEB-A11Y].
4.4 ARIA APG 키보드 패턴표
네이티브 요소는 아래 동작을 공짜로 갖는다. 커스텀 위젯에 role을 붙였다면 (선택)으로 표시한 동작을 뺀 나머지는 전부 구현해야 한다 — role은 그 위젯의 전체 키보드 모델을 지키겠다는 약속이다[SKILL-BETTER-A11Y]. 각 행은 WAI-ARIA APG의 해당 패턴 페이지로 1차 확인됐다[ARIA-APG].
| 위젯 | 키 계약 |
|---|---|
| Dialog | 여는 순간 다이얼로그 안 요소로 포커스 이동(파괴적 확인은 가장 덜 위험한 액션으로). Tab/Shift+Tab이 안에서만 순환(끝에서 wrap). Escape로 닫힘 |
| Tabs | 화살표 키(세로형은 Up/Down)로 탭 간 이동(wrap). Tab은 탭 목록을 벗어나 패널로 간다. Home/End로 처음/끝 탭. 활성화는 자동(포커스 즉시 전환) 또는 수동(Enter/Space로 전환) 중 선택 |
| Menu button | Enter/Space가 열고 첫 항목에 포커스. (선택) ArrowDown이 열고 첫 항목, ArrowUp이 열고 마지막 항목. Escape가 닫고 버튼에 포커스 복귀 |
| Disclosure/accordion | 헤더는 <button aria-expanded>. Enter·Space가 토글 |
| Combobox | ArrowDown이 팝업을 열거나 안으로 이동. Enter가 자동완성 제안을 받아들임. Escape가 팝업을 닫고 입력으로 돌아감. 타이핑이 필터링. 팝업이 listbox/grid/tree/dialog인지에 따라 내부 탐색 방식이 달라진다 |
| Listbox/radio group | 화살표 키가 선택을 옮긴다(단일 선택 시). Home/End(5개 이상일 때 권장)로 처음/끝. 그룹 전체가 Tab 정지점 하나 |
보편 규칙: Escape는 가장 최근에 연 것부터 닫는다(툴팁 → 메뉴 → 다이얼로그 순). 화살표 키는 위젯 내부를 움직이고, Tab은 위젯 사이를 움직인다. <textarea>에서 Enter는 줄바꿈이고 ⌘/Ctrl+Enter가 제출이다[SKILL-BETTER-A11Y].
4.5 모달 포커스 트랩·inert·복귀
네이티브 <dialog> + showModal()을 우선 고려한다. 트랩·배경 inert·Escape 처리를 공짜로 얻는다. <dialog>는 Widely available 상태다(확인 2026-09-24, baseline 2022-03-14)[WEB-BASELINE]. 기하 계약(위치·중심·scroll owner)은 audit-gate.md와 preflight.md §4-1이 정하므로 여기서 반복하지 않는다. 이 절은 포커스 관리만 다룬다.
네이티브를 쓸 수 없는 커스텀 오버레이는 role="dialog" + aria-modal="true" + aria-labelledby(제목 id)를 갖추고, 아래를 구현한다[SKILL-BETTER-A11Y]:
// 열 때: 배경을 inert로 만들고 첫 포커스 가능 요소로 이동한다
document.getElementById('app-content').inert = true; // 배경을 탭 순서·보조기술 양쪽에서 제거한다
const dialog = dialogRef;
(dialog.querySelector('[autofocus]') ??
dialog.querySelector('button, [href], input, select, textarea'))?.focus();
// 닫을 때: 배경을 복구하고 포커스를 연 요소로 되돌린다
document.getElementById('app-content').inert = false;
triggerEl?.focus();
inert 속성은 Widely available 상태다(확인 2026-09-24, baseline 2023-04-11)[WEB-BASELINE]. 파괴적 확인 다이얼로그는 첫 포커스를 가장 덜 위험한 액션(대개 취소)에 둔다. 다이얼로그에 overscroll-behavior: contain을 걸어, 안에서 스크롤해도 배경 페이지가 함께 스크롤되지 않게 한다.
4.6 SPA 라우트 변경
언제: 클라이언트 사이드 라우팅(SPA)을 쓸 때. 라우트 전환은 포커스와 알림을 저절로 리셋하지 않는다. 라우트가 바뀌면 document.title을 새 맥락에 맞게 갱신하고, 새 뷰의 <h1 tabindex="-1"> 또는 <main>으로 포커스를 옮긴다. 뒤로/앞으로 내비게이션은 스크롤 위치를 복원하고, 앞으로 이동은 맨 위로 스크롤한다[SKILL-BETTER-A11Y].
5. 구조
하드 게이트 — 스킵 링크. 반복되는 내비게이션을 건너뛸 수 있는 링크를 페이지 첫 포커스 가능 요소로 둔다. 포커스 전에는 화면 밖에 숨긴다.
.skip-link { position: absolute; inset-inline-start: -999px; }
.skip-link:focus { inset-inline-start: 16px; top: 16px; }
<body>
<a class="skip-link" href="#main-content">본문으로 건너뛰기</a>
<header>…</header>
<main id="main-content" tabindex="-1">…</main>
</body>
하드 게이트 — 랜드마크. 보이는 주 <main> 랜드마크 하나를 노출한다. <header>·<nav>·<aside>·<footer>는 스크린리더 사용자가 랜드마크 사이를 건너뛰며 이동할 수 있게 한다. 같은 타입 랜드마크가 여러 개면 구분 레이블을 단다.
<nav aria-label="주 메뉴">…</nav>
<nav aria-label="브레드크럼">…</nav>
div로 짠 레이아웃을 시맨틱 랜드마크로 바꾸는 것 자체가 흔히 빠뜨리는 항목이다[SKILL-WEB-A11Y].
헤딩 순서. h1 하나, 레벨을 건너뛰지 않는다(기준은 preflight.md §1). 헤딩은 구조이지 스타일이 아니다 — 크기 때문에 태그를 고르지 말고, 레벨은 문서 구조로 정한 뒤 시각적 크기는 CSS로 별도로 입힌다[SKILL-BETTER-A11Y].
링크 텍스트 — [WCAG-LINK] (2.4.4). 링크의 목적은 링크 텍스트만으로, 또는 텍스트와 프로그램적으로 확인 가능한 맥락을 합쳐서 알 수 있어야 한다. 스크린리더의 링크 목록 모드는 맥락 없이 텍스트만 읽으므로, "더보기"·"여기"·"클릭"을 단독으로 쓰지 않고 목적어를 포함한다("가격표 보기"). 실제 카피 문구·톤은 product-copy.md를 따른다. 새 탭으로 여는 링크는 rel="noopener"와 함께 "새 탭에서 열림"을 알리는 숨김 텍스트(.sr-only)를 둔다.
6. 폼
6.1 레이블
하드 게이트. 모든 컨트롤은 프로그램적 레이블이 있어야 한다 — id를 가리키는 <label for> 또는 감싸는 <label>. placeholder는 레이블이 아니다. 입력하는 순간 사라지고 대개 대비 기준도 통과하지 못한다[SKILL-BETTER-A11Y].
<label for="email">이메일</label>
<input id="email" type="email" autocomplete="email" />
<!-- 감싸는 레이블: 레이블과 컨트롤이 같은 히트 영역을 공유한다 -->
<label>
<input type="checkbox" /> 소식 받기
</label>
필수 필드는 네이티브 required와 폼당 한 번 설명하는 보이는 표시("* 필수")를 함께 쓴다. 레이블에 더해 입력 형식 예시를 보여주는 것은 placeholder의 정당한 용도다(placeholder="name@company.com").
6.2 자동완성 — [WCAG-INPUT-PURPOSE] (1.3.5, AA)
하드 게이트. 사용자 정보를 묻는 입력은 의미 있는 autocomplete 값을 가져야 한다는 WCAG 요구다. 값을 지어내지 않고 아래 표를 따른다[SKILL-BETTER-A11Y].
| 필드 | autocomplete |
|---|---|
| 이름 | name(또는 given-name/family-name) |
| 이메일 | email |
| 전화번호 | tel |
| 주소 | street-address, address-line1, postal-code, country |
| 카드 | cc-number, cc-exp, cc-csc, cc-name |
| 로그인 | username, current-password |
| 가입/재설정 | new-password |
| 2단계 인증 코드 | one-time-code |
섹션이 여러 개일 때는 접두어를 붙인다: autocomplete="shipping street-address".
6.3 입력 타입·inputmode
올바른 type·inputmode가 맞는 모바일 키보드를 소환한다[SKILL-BETTER-A11Y].
| 입력 | 쓸 것 |
|---|---|
| 이메일·URL·전화 | type="email", type="url", type="tel" |
| OTP/PIN/카드번호 | type="text" inputmode="numeric"(텍스트 시맨틱 유지, 스피너 없음) |
| 금액·소수 | type="text" inputmode="decimal" |
| 진짜 수량(정수 스피너가 맞는 값) | type="number" |
이메일·코드·사용자명에는 spellcheck="false"를 건다. 값 자체는 사용자가 붙여넣게 두고, 타이핑을 막거나 문자를 필터링하지 않는다 — 비밀번호·OTP는 붙여넣기가 표준 사용 방식이다. 진짜 <form>·정확한 autocomplete·가짜 입력 금지로 비밀번호 관리자·2FA 자동입력과 계속 호환되게 유지한다[SKILL-BETTER-A11Y].
6.4 접근 가능한 인증 — [WCAG-AUTH] (3.3.8, AA, WCAG 2.2 신규)
언제: 로그인·가입 플로우를 구현할 때. 로그인은 인지 기능 테스트(비밀번호 암기·퍼즐)만으로 막지 않는다. 다음 중 최소 하나가 함께 있어야 한다: (a) 인지 기능 테스트에 의존하지 않는 대안(패스키·SSO·이메일 링크), (b) 테스트를 돕는 메커니즘(붙여넣기 허용, 자동완성), (c) 객체 인식이나 사용자가 제공한 콘텐츠 인식 기반 테스트[WCAG-AUTH]. 최소한 붙여넣기와 정확한 autocomplete는 항상 지킨다.
6.5 중복 입력 방지 — [WCAG-REDUNDANT] (3.3.7, A, WCAG 2.2 신규)
언제: 다단계 폼(체크아웃·가입 등)을 구현할 때. 같은 세션에서 이미 받은 정보를 다시 입력하라고 강요하지 않는다 — 배송지=청구지 자동 채움 체크박스처럼 자동으로 채우거나 선택할 수 있게 한다. 예외는 보안 재확인과 만료된 정보뿐이다.
6.6 제출 버튼
프로젝트 계약 — WCAG가 직접 요구하지는 않는 강한 기본값이라는 이유로 하드 게이트가 아니라 프로젝트 계약으로 둔다. 제출 버튼은 요청이 시작되기 전까지 활성 상태를 유지한다. 폼이 유효해질 때까지 미리 비활성화하지 않는다 — 사용자가 어떤 필드가 문제인지 알 방법이 없어진다. 요청이 시작된 뒤에만 비활성화하고, 원래 레이블 옆에 스피너를 둔다("저장" + 스피너이지, 스피너만이 아니다. 레이블이 있어야 보조기술이 어떤 버튼이 바쁜지 안다)[SKILL-BETTER-A11Y].
하드 게이트 — 비활성화 때문에 오류를 확인할 방법 자체가 사라지면 오류 식별 실패로 하드 게이트다.
6.7 disabled vs aria-disabled — 결정표
프로젝트 계약 — WCAG가 직접 요구하지는 않는 강한 기본값이라는 이유로 하드 게이트가 아니라 프로젝트 계약으로 둔다.
| 상황 | 쓸 것 | 결과 |
|---|---|---|
| 컨트롤이 진짜로 쓸 수 없는 상태(네이티브 지원 있음) | disabled |
탭 순서에서 빠짐, 활성화 억제, :disabled 스타일 적용, 폼 제출에서 제외됨 — 전부 자동 |
| 이유를 옆에 텍스트로 보여줘야 하거나, 비활성 상태에서도 탭 순서·hover에 남겨야 함(툴팁이 필요할 때) | aria-disabled="true" |
상태만 announce된다. 포커스 가능성·동작·스타일은 바뀌지 않으므로 코드·CSS로 직접 막아야 한다 |
커스텀 컨트롤이라 네이티브 disabled가 없음 |
aria-disabled="true" |
위와 동일 |
함정 — 네이티브 disabled 위의 툴팁은 절대 열리지 않는다. disabled는 포인터 이벤트를 억제하고 탭 순서에서 빠지므로, 키보드·터치에서는 열릴 방법이 없고 마우스에서도 신뢰할 수 없다. 이유를 설명해야 한다면 aria-disabled="true"로 바꾸거나 이유를 옆에 보이는 텍스트로 둔다. disabled와 aria-disabled를 같은 요소에 동시에 설정하지 않는다. aria-disabled를 쓸 때는 핸들러에서 포인터·키보드 활성화를 직접 막고, 폼 제출도 필요하면 직접 막고, forced-colors 대응 스타일을 포함한다. 비활성 컨트롤은 대비 최소 기준의 예외지만 그래도 읽을 수 있게 유지한다(역할 정의는 color.md §8 "비활성 텍스트" 역할)[SKILL-BETTER-A11Y].
6.8 오류 처리
하드 게이트. 완전한 오류 패턴이다[SKILL-BETTER-A11Y]:
<label for="email">이메일</label>
<input
id="email" type="email" autocomplete="email"
aria-invalid="true" aria-describedby="email-error"
/>
<p id="email-error">올바른 이메일 주소를 입력하세요.</p>
aria-invalid="true"는 실패한 필드에만, 고치면 제거한다.aria-describedby가 필드와 인라인 오류를 연결해 스크린리더가 필드와 함께 오류를 읽게 한다.- 오류는 필드 옆에 인라인으로, 아이콘이나 텍스트와 함께 렌더한다. 빨간 테두리만으로는 안 된다 — 색상 단독 신호다.
- 제출 시 첫 번째로 실패한 필드에 포커스를 이동한다. 포커스 이동 자체가 announcement이므로 별도 알림이 필요 없다.
- 제출 전 값은 trim한다 — 자동완성과 텍스트 확장이 뒤 공백을 남긴다.
- 폼 레벨 오류(특정 필드에 묶이지 않는 실패)에만
role="alert"를 예약한다(§7).
관찰 후보 — 검증 시점 [SKILL-APPLE-DESIGN]. 필드를 벗어날 때(blur) 또는 타이핑 중 짧은 디바운스 후 검증하고, 제출 버튼을 누른 뒤에야 오류를 보여주지 않는다. 불완전한 상태로도 제출을 허용해 검증이 드러나게 한다 — 타이핑 자체를 막거나 문자를 필터링하지 않는다.
6.9 세션 타이밍
언제: 세션 타임아웃이 있는 웹앱일 때. 시간 제한은 연장할 수 있어야 한다 — 만료 전 경고 모달에 "연장" / "로그아웃" 선택지를 준다.
7. 상태 메시지 — [WCAG-STATUS] (4.1.3, AA)
하드 게이트. 페이지가 새로고침 없이 바뀌는 콘텐츠(토스트·검증 결과·검색 결과 수·로딩 상태)를 어떻게 알릴지는 아래 순서로 판정하고, 첫 번째로 맞는 항목에서 멈춘다[SKILL-BETTER-A11Y]:
- 포커스가 이미 그곳으로 이동한다 — 열린 모달, 첫 실패 필드처럼. 포커스 이동 자체가 announcement다. 추가 조치가 필요 없다.
- 특정 컨트롤에 묶여 있다 — 필드 오류, 글자 수 카운터처럼. 해당 컨트롤의
aria-describedby로 연결한다. - 긴급하지 않고 특정 컨트롤에 묶이지 않는다 — 토스트, "저장됨", 결과 개수, 로딩 상태처럼.
role="status"(polite, 말이 멈추길 기다림). - 긴급하고 특정 컨트롤에 묶이지 않는다 — 폼 레벨 실패, 세션 만료처럼.
role="alert"(assertive, 즉시 인터럽트).
| 메커니즘 | 결합 | 언제 |
|---|---|---|
role="status" |
aria-live="polite" + aria-atomic="true" |
토스트, "저장됨", 결과 수, 로딩 갱신 |
role="alert" |
aria-live="assertive" + aria-atomic="true" |
긴급한 오류뿐. 과용이 가장 흔한 실수다 — 사용자가 읽던 것을 인터럽트한다 |
재알림 트릭. 반복되는 polite 업데이트는 DOM에 안정된 빈 영역을 미리 두고 그 텍스트만 바꾼다. 매번 콘텐츠를 담은 새 polite 영역을 삽입하면 announce가 일관되지 않는다.
<!-- 처음부터 렌더된 영역, 메시지는 나중에 주입 -->
<div role="status" class="sr-only" id="status-region"></div>
로딩 상태는 갱신되는 영역에 aria-busy="true"를 걸고, "불러오는 중…"을 polite로 알린 뒤 결과("12개 결과를 불러왔습니다")를 announce한다.
토스트 특화 규칙[SKILL-BETTER-A11Y]:
- 토스트로 포커스를 옮기지 않는다. announce만 하고 포커스는 사용자가 작업하던 곳에 둔다.
- hover 또는 focus가 타이머를 일시정지한다.
- 자동 소멸은 저위험 확인에만 어울린다. 타임아웃을 쓴다면 5초가 바닥값이다.
- 액션·오류·놓치면 안 되는 정보를 담은 토스트는 자동으로 사라지지 않는다. 유일한 undo 경로가 담긴 토스트가 시간이 지나 사라지는 것은 예정된 데이터 손실이다.
8. 히트 영역 — [WCAG-TARGET] (2.5.8, AA)
하드 게이트. 포인터 입력 대상은 최소 24×24 CSS px이거나 아래 예외 중 하나에 해당해야 한다.
간격 예외의 정확한 정의: 24px 미만인 대상도, 그 바운딩박스 중심에 24px 지름 원을 그렸을 때 그 원이 다른 대상이나 다른 미달 대상의 원과 겹치지 않으면 통과한다[WCAG-TARGET]. 단순한 경우로는 20px 대상 사이에 4px 간격이 있으면 충분하다. 그 밖의 예외는 동등 기능을 제공하는 다른 컨트롤(Equivalent), 문장 안 인라인 텍스트(Inline), 사용자 에이전트가 정한 크기(User Agent Control), 특정 표현이 필수이거나 법적으로 요구되는 경우(Essential)다.
프로젝트 계약. HIG의 44×44pt[PLATFORM-HIG-TARGET], Material Design의 48×48dp[PLATFORM-M3-TARGET]는 "모바일 앱 수준"을 브리프가 요구할 때의 계약이다 — WCAG AA의 보편 요건으로 확대하지 않는다. 상세는 mobile-app-ux.md.
히트 영역 확장. 보이는 요소가 작을 때(20×20 체크박스 등), 감싸는 <label>이나 <button>에 의사요소로 히트 영역을 넓힌다. <input> 자체에는 걸지 않는다 — 대체 요소는 ::before/::after를 안정적으로 렌더하지 않는다[SKILL-BETTER-A11Y].
아래 44px는 모바일 앱 수준 계약일 때의 값이다. 그 밖에는 24px와 간격 예외로 충분하다.
.checkbox-label {
position: relative;
width: 20px; height: 20px;
}
.checkbox-label::after {
content: "";
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%); /* 물리적 중앙 정렬 — 방향과 무관 */
width: 44px; height: 44px;
}
실제 박스 크기를 키울 여유가 있으면 의사요소 대신 박스 자체를 키운다 — 브라우저가 스크롤·제스처에 쓸 진짜 geometry를 갖게 된다.
.icon-button {
min-width: 44px; min-height: 44px;
display: inline-grid; place-items: center;
}
충돌 규칙. 확장된 히트 영역이 다른 인터랙티브 요소와 겹치면, 충돌하지 않는 최대 크기로 의사요소를 줄인다. 두 인터랙티브 요소의 히트 영역은 절대 겹치지 않는다.
장식 레이어. 인터랙티브 콘텐츠 위에 그려진 그라디언트 스크림·글로우·블러 시트 같은 장식 레이어는 자신이 덮은 영역의 포인터 이벤트를 전부 삼킨다 — 아래 컨트롤은 살아 있는 것처럼 보이지만 아무 반응이 없다. pointer-events: none으로 이벤트가 아래로 통과하게 하고, aria-hidden="true"로 접근성 트리에서도 뺀다[SKILL-BETTER-A11Y]. antipatterns.md의 "컬러 글로우" 항목이 시각 판단만 다루는 곳에 이 규칙을 더한다.
.card-glow {
position: absolute; inset: 0;
pointer-events: none;
}
클릭으로 닫는 모달 스크림처럼 사용자가 실제로 눌러야 하는 레이어는 예외다 — 그건 장식이 아니라 컨트롤이다.
터치 동작. 인터랙티브 요소에 touch-action: manipulation을 걸어 모바일의 더블탭 확대 지연을 없앤다. 자체 팬·줌·드래그를 구현하는 표면에는 touch-action: none을 그 표면에만 스코프해서 건다(페이지 레벨에 걸면 스크롤 자체가 사라진다). hover 전용 스타일은 @media (hover: hover)로 게이트한다 — 터치에서는 :hover가 탭 뒤에 눌러붙어 고정된 선택 상태처럼 보인다[SKILL-BETTER-A11Y].
9. 움직임·깜빡임·호버 콘텐츠
하드 게이트 — 깜빡임 한도 [WCAG-FLASH] (2.3.1, A). 초당 3회를 넘게 깜빡이거나 일반/적색 섬광 임계값을 넘는 콘텐츠는 만들지 않는다. 펄스형 상태 점, 글로우 애니메이션(svg-filters.md), 스트로브형 모션이 이 한도 안에 있는지 확인한다. 5초 넘게 자동으로 움직이는 콘텐츠의 정지 수단(2.2.2)은 motion.md가 정본이므로 여기서 반복하지 않는다.
하드 게이트 — 호버·포커스로 나타나는 콘텐츠 [WCAG-HOVER] (1.4.13, AA). 툴팁·팝오버처럼 호버나 포커스로 나타나는 부가 콘텐츠는 세 조건을 모두 만족해야 한다:
- Dismissible(해제 가능) — 추가 호버·포커스 없이 사용자가 닫을 수 있다(Esc 등). preflight.md §4-1이 이미 다룬다.
- Hoverable(호버 가능) — 포인터가 트리거를 벗어나 콘텐츠 쪽으로 이동해도 사라지지 않는다.
- Persistent(지속) — 호버·포커스가 유지되는 한, 시간 제한 없이 유지된다(사용자가 닫거나 정보가 무효해질 때까지).
/* 호버 가능: 트리거와 콘텐츠 사이에 죽은 공간이 없어야 포인터 이동 중 사라지지 않는다 */
.tooltip-trigger:hover .tooltip,
.tooltip-trigger:focus-within .tooltip,
.tooltip:hover { visibility: visible; }
10. 드래그 대안 — [WCAG-DRAG] (2.5.7, AA, WCAG 2.2 신규)
언제: 정렬 리스트·슬라이더·지도 팬·컬러피커처럼 드래그 제스처로 조작하는 위젯을 만들 때. 드래그 동작을 요구하는 모든 기능은 드래그 없이 단일 포인터(클릭 등)로도 실행할 수 있어야 한다. 예외는 드래그 자체가 본질적으로 필요한 경우(자유 곡선 그리기 등)와, 사용자 에이전트가 정한 동작(브라우저 스크롤·pull-to-refresh 등 저작자가 수정하지 않은 것)이다[WCAG-DRAG].
<!-- 드래그로 순서를 바꾸는 리스트에는 버튼 대안을 함께 둔다 -->
<li>
<span>항목 A</span>
<button aria-label="위로 이동">↑</button>
<button aria-label="아래로 이동">↓</button>
</li>
11. 미디어 자막·트랜스크립트
언제: 비디오·오디오 콘텐츠를 프로젝트에 포함할 때. 사전 녹화된 비디오는 자막이 필요하고, 오디오는 트랜스크립트를 제공한다. 소리가 있는 콘텐츠는 절대 자동재생하지 않고, 컨트롤은 항상 렌더한다[SKILL-BETTER-A11Y].
<video controls>
<source src="demo.mp4" type="video/mp4" />
<track kind="captions" src="demo.ko.vtt" srclang="ko" label="한국어" default />
</video>
<audio controls src="podcast.mp3"></audio>
<details>
<summary>트랜스크립트 보기</summary>
<p>…전문…</p>
</details>
이미지의 alt 5분류(장식·정보성·기능성·텍스트 이미지·복합)는 images.md §5가 정본이다.
12. 사용자 선호 신호
브라우저 지원과 WCAG 근거에 따라 하드 게이트·프로젝트 계약·점진적 향상으로 갈린다(확인 2026-09-24)[WEB-BASELINE].
| 신호 | 지원 상태 | 판정 |
|---|---|---|
prefers-reduced-motion |
Widely available | 하드 게이트. 무엇을 끄고 무엇을 남기는지는 motion.md가 정본이다 |
prefers-contrast |
Widely available(2022-05부터) | 프로젝트 계약. more 값에 반응해 대비를 높이는 것은 요구되면 지킨다 |
forced-colors |
Widely available(2022-09부터) | 하드 게이트 — §3의 규칙을 따른다 |
prefers-reduced-transparency |
Limited — Chrome/Edge만 구현하고 Firefox·Safari는 미지원, Apple이 표준화에 반대 의견을 낸 상태 | 점진적 향상 — 유리 재질을 쓰는 프로젝트에서는 프로젝트 계약(elevation.md §8). 반투명 표면을 불투명하게 낮추는 구체 레시피는 elevation.md·svg-filters.md가 조건부로 다룬다 |
색만으로 의미를 전달하지 않는다. 이 규칙 자체는 부록 A에서 다룬다.
13. 감사 루프
증거 우선 1~4단계 루프를 닫힌 상태로 돈다 — 수정 후 같은 감사를 다시 돌리지 않으면 통과라고 말할 수 없다. 5는 그 루프를 통과한 뒤 추가로 확인하는 항목이다.
- 자동 감사. Chrome DevTools MCP가 있으면
lighthouse_audit(accessibility 카테고리)를 쓴다. 없으면npx lighthouse <url> --only-categories=accessibility또는npx @axe-core/cli <url>로 대체한다. 프로젝트에axe-core가 있으면 audit-gate.md의 게이트가 뷰마다 axe 를 함께 돌린다. 도구 목록과 선택 기준은 harness.md §6을 따른다. - 실패 노드 국소화. 감사가 지목한 노드로 대상 컴포넌트를 좁힌다.
- 수동 확인. Chrome DevTools MCP가 있으면
take_snapshot으로 접근성 트리(이름·역할·상태·랜드마크·헤딩)를 검사한다. 영향받는 플로우를 실제로 키보드로 조작한다. 스크린리더를 켤 수 있으면 최소 단축키로 확인한다 — macOS VoiceOver는Cmd+F5(또는 Touch ID 3회 클릭)로 켜고 끄며Ctrl+Option+화살표로 항목을 이동하고Ctrl+Option+U가 로터(헤딩·링크 목록)를 연다. Windows NVDA는Ctrl+Alt+N으로 시작,Insert+Q로 종료,H가 다음 헤딩,K가 다음 링크,Insert+F7이 요소 목록을 연다. 스크린리더를 켤 수 없는 환경에서는 preflight.md의 대체 절차(읽기 순서 확인)를 따른다. - 수정 후 동일 감사 재실행. 1~3단계를 그대로 다시 돌려 재발이 없는지 확인한다. 재실행하지 않은 수정은 미검증이다.
- 고대비 모드 확인.
forced-colors: active(Windows 고대비)를 에뮬레이션하거나 실제로 켜서, 색만으로 전달되던 정보가 시스템 색 치환 후에도 남아 있는지 본다(§3).
증거 출처를 기록한다. 어떤 도구·엔진·에뮬레이션으로 확인했는지 보고에 적는다 — "확인함"이라고만 쓰면 재현할 수 없다는 원칙은 harness.md §6과 동일하다.
14. 보고
발견한 접근성 문제는 이 문서의 규칙을 근거로 삼되, 보고 형식·심각도 랭킹·톤은 critique.md를 따른다. 이 문서에서 다시 정의하지 않는다 — 접근성 하드 게이트 위반은 critique.md의 심각도 매핑에서 항상 Blocking이다.
부록 A. 색·모션 단독 전달 금지
하드 게이트. color.md와 motion.md가 이 규칙의 정본을 이 문서로 지목했으므로 여기서 정의한다[WCAG-22].
- 색만으로 상태·의미를 전달하지 않는다. 성공/실패, 필수/선택, 클릭 가능/불가능 같은 구분은 색만이 아니라 아이콘·텍스트·밑줄 같은 중복 단서를 함께 준다. 비색각 사용자와 회색조로 인쇄되는 화면 모두에 적용된다.
- 모션만으로 상태 변화를 전달하지 않는다. 토글·완료·선택 같은 상태 변화는 애니메이션이 꺼지거나 생략돼도(감소 모션 환경, 저사양 기기) 색·아이콘·라벨 중 하나로 남아 있어야 한다. 모션은 강조일 뿐 유일한 전달 수단이면 안 된다[SKILL-BETTER-INTERFACE].
두 규칙 모두 구현 위치는 각 도메인 문서다 — 색 역할·토큰은 color.md, 모션이 꺼졌을 때 무엇을 남길지는 motion.md 코드 H를 따른다. 여기서는 판정만 내린다.