designpaca/packages/skill/references/accessibility.md
Yun Chan 6805fb2be7 feat(skill): absorb external design skills, restore interview gate, add review route
- 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.
2026-09-24 13:26:03 +09:00

42 KiB
Raw Blame History

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]:

  1. 필요한 시맨틱·동작을 가진 네이티브 요소가 있으면 다른 요소를 ARIA로 흉내 내지 않고 그것을 쓴다.
  2. 정말 필요하지 않으면 네이티브 시맨틱을 바꾸지 않는다.
  3. 인터랙티브 ARIA 컨트롤은 반드시 키보드로 조작할 수 있어야 한다 — role은 그 위젯의 전체 키보드 모델을 지키겠다는 약속이다.
  4. 포커스를 받는 요소에 role="presentation"이나 aria-hidden="true"를 걸지 않는다.
  5. 모든 인터랙티브 요소는 접근 가능한 이름을 가져야 한다(§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]:

  1. 포커스가 이미 그곳으로 이동한다 — 열린 모달, 첫 실패 필드처럼. 포커스 이동 자체가 announcement다. 추가 조치가 필요 없다.
  2. 특정 컨트롤에 묶여 있다 — 필드 오류, 글자 수 카운터처럼. 해당 컨트롤의 aria-describedby로 연결한다.
  3. 긴급하지 않고 특정 컨트롤에 묶이지 않는다 — 토스트, "저장됨", 결과 개수, 로딩 상태처럼. role="status"(polite, 말이 멈추길 기다림).
  4. 긴급하고 특정 컨트롤에 묶이지 않는다 — 폼 레벨 실패, 세션 만료처럼. 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는 그 루프를 통과한 뒤 추가로 확인하는 항목이다.

  1. 자동 감사. 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을 따른다.
  2. 실패 노드 국소화. 감사가 지목한 노드로 대상 컴포넌트를 좁힌다.
  3. 수동 확인. 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의 대체 절차(읽기 순서 확인)를 따른다.
  4. 수정 후 동일 감사 재실행. 1~3단계를 그대로 다시 돌려 재발이 없는지 확인한다. 재실행하지 않은 수정은 미검증이다.
  5. 고대비 모드 확인. 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를 따른다. 여기서는 판정만 내린다.