접근성
접근성은 따로 챙기는 체크리스트가 아니다. Senco의 모든 컴포넌트는 처음부터 접근성을 고려해 만든다. 기준은 최소 WCAG 2.2 Level AA다.
Senco 사용자 중에는 액정이 깨진 폰을 쓰는 사람, 흔들리는 차 안에서 한 손으로 앱을 여는 사람, 앱 언어가 제2·제3 언어인 사람, 송금이 한 번 실패하면 월세가 밀리는 사람이 있다. 그래서 여기서 접근성은 디자인 위에 덧붙이는 규정 준수 작업이 아니라, 제품을 쓸 수 있게 만드는 일 그 자체다.
목표 기준
모든 컴포넌트, 패턴, 페이지 템플릿은 WCAG 2.2 Level AA를 지킨다. 현실적으로 가능하면 Level AAA까지 간다. 금융 데이터에 강화 대비를 적용하고, 터치 타깃은 AAA 요구치보다도 큰 48px로 둔다.
WCAG 2.2는 2.1과 하위 호환되므로, 2.2 AA를 충족하면 2.1 AA도 충족한다. 2.2에서 새로 생긴 기준 중 Senco에 특히 중요한 것은 다음과 같다.
| Criterion | Level | Senco position |
|---|---|---|
| 2.4.11 Focus Not Obscured (Minimum) | AA | 고정 액션 바가 포커스된 필드를 가리면 안 된다 |
| 2.5.7 Dragging Movements | AA | 드래그가 필요한 컨트롤은 없다. 모든 슬라이더에는 숫자 입력이 있다 |
| 2.5.8 Target Size (Minimum) — 24px | AA | 기준보다 크다. Senco는 48px을 요구한다 |
| 3.2.6 Consistent Help | A | 도움말 진입점은 모든 화면에서 같은 자리에 있다 |
| 3.3.7 Redundant Entry | A | 수취인과 주소 정보는 이전 단계에서 미리 채워진다 |
| 3.3.8 Accessible Authentication (Minimum) | AA | 인지 기능 테스트 없음. OTP 필드는 붙여 넣기를 허용한다 |
송금 제품에서는 Redundant Entry와 Accessible Authentication이 실제로 중요하다. 두 단계 전에 입력한 계좌 번호를 다시 입력하게 해서는 안 된다. 붙여 넣기를 막은 OTP 필드는 시간에 쫓기는 사용자에게 여섯 자리 코드를 외우게 만든다.
색과 대비
| Requirement | Ratio | Applies to |
|---|---|---|
| 일반 텍스트 | 4.5:1 | 본문, 라벨, 설명 |
| 큰 텍스트 (굵게 18.66px 이상 또는 24px 이상) | 3:1 | 제목, 큰 라벨 |
| UI 컴포넌트 | 3:1 | 테두리, 아이콘, 포커스 링, 폼 컨트롤 |
| 텍스트 외 대비 | 3:1 | 차트, 그래프, 상태 표시 |
- 색만으로 의미를 전달하지 않는다. 항상 텍스트, 아이콘, 패턴을 함께 쓴다
- 상태 배지는 색, 점, 텍스트 라벨을 함께 쓴다
- 에러 상태는 빨간 테두리, 에러 아이콘, 설명 텍스트를 함께 쓴다
큰 텍스트의 함정
"큰 텍스트"는 굵게 18.66px 이상이거나, 굵기와 상관없이 24px 이상인 텍스트다. 18px semibold 제목은 큰 텍스트에 들지 않는다. Senco의 H4가 딱 이 경우다. 그래서 H2와 H3은 굵기 700으로 지정했고, H4는 4.5:1 기준을 그대로 지킨다. Typography 참고.
대비 검증
모든 토큰 조합은 라이트와 다크 모드에서 각각 검증한다. 전체 표는 Color와 Themes에 있다.
기준을 깔끔하게 통과하지 못한 항목이 둘 있다. 둘 다 반올림해 넘기지 않고 측정값 그대로 기록한다.
| Item | Measured | Status |
|---|---|---|
기본 상태 입력 테두리 (흰색 위 {gray-200}) | 1.24:1 | 허용한 예외. 필드에 항상 보이는 라벨과 48px 컨트롤 영역이 있다 |
채움 위의 비활성 버튼 라벨. 라이트는 {gray-300} 위 {gray-500}, 다크는 #334155 위 #7E8EA3 | 라이트 3.76:1, 다크 3.10:1 | 면제. SC 1.4.3은 비활성 컴포넌트를 제외한다. 그래도 기록하는 이유는 면제를 받을지가 선택의 문제이기 때문이다. Senco의 비활성 컨트롤은 포커스를 받고 비활성인 이유를 알려 주므로, 라벨도 그대로 읽힌다 |
다크 모드 Primary 버튼 라벨은 3.23:1로 해결되지 않은 결함이었지만, Platform 계층에서 --ds-on-accent를 재매핑해 해결했다. Themes 참고.
Visible label above the input stays present when the user types.
Placeholder-only label disappears on focus, leaving no context.
All text and UI element pairings must meet minimum contrast ratios.
| Element | Minimum ratio | Standard |
|---|---|---|
| Normal text (<18px) | 4.5:1 | WCAG 1.4.3 AA |
| Large text (18px+ bold, 24px+) | 3:1 | WCAG 1.4.3 AA |
| UI components & icons | 3:1 | WCAG 1.4.11 |
| Focus indicators | 3:1 | WCAG 2.4.7 |
키보드 내비게이션
모든 상호작용 컴포넌트는 키보드만으로 완전히 조작할 수 있어야 한다.
| Key | Action |
|---|---|
Tab | 다음 상호작용 요소로 포커스 이동 |
Shift+Tab | 이전 요소로 포커스 이동 |
Enter / Space | 버튼, 링크, 토글 실행 |
Arrow keys | 메뉴, 탭, 라디오 그룹 안에서 이동 |
Escape | 다이얼로그, 툴팁, 팝오버, 드롭다운 닫기 |
Home / End | 목록의 처음/마지막 항목으로 이동 |
복합 컴포넌트당 Tab 정지점은 하나
라디오 그룹, 탭 목록, 스테퍼, 메뉴 바는 각각 Tab 정지점이 하나다. Tab으로는 컴포넌트 사이를, 화살표 키로는 컴포넌트 안을 이동한다. 옵션이 열 개인 통화 라디오 그룹을 지나는 데 Tab을 열 번 눌러야 한다면, 조작은 가능해도 실제로는 쓸 수 없다.
포커스 관리
- 포커스 표시는 두 가지다. 테두리가 있는 컨트롤(입력, 셀렉트)은 테두리 색을
--ds-focus로 바꾸고, 오프셋 없이 불투명도 20%의 3px 후광을 더한다. 테두리가 없는 컨트롤(버튼, 링크, 탭, 행)은 2px 오프셋을 두고 3px 실선--ds-focus외곽선을 그린다. 어느 쪽을 쓸지는 Tokens에 정리했다 - 3:1을 넘어야 하는 것은 포커스된 테두리나 외곽선이며, 둘 다 흰 배경에서 5.00:1이다. 후광은 장식이라 계산에 넣지 않는다
:focus대신:focus-visible을 쓴다. 마우스로 클릭했을 때는 링이 남지 않아야 하고, Tab을 눌렀을 때는 남아야 한다- 포커스 순서는 시각적인 읽기 순서를 따른다 (왼쪽에서 오른쪽, 위에서 아래)
- 대체 표시 없이
:focus-visible외곽선을 지우지 않는다 - 다이얼로그는 포커스를 안에 가두고, 닫힐 때 다이얼로그를 연 요소로 포커스를 돌려준다
- 라우트가 바뀌면 포커스를
<body>가 아니라 새 페이지의<h1>으로 옮긴다.<body>로 보내면 스크린 리더 사용자가 화면 맨 위 크롬에 덩그러니 남는다 - 포커스된 요소가 고정 액션 바에 가려져서는 안 된다 (SC 2.4.11)
링은 --ds-focus를 기준으로 color-mix()로 만든다. 그래서 테마가 포커스 색을 바꾸면 링 색도 알맞게 따라 바뀐다. rgba 값을 하드코딩했다면 다크 모드용 토큰이 하나 더 필요했을 것이고, 두 값은 결국 어긋났을 것이다.
파괴적이고 되돌릴 수 없는 액션
송금 확인은 페이지 아무 곳에서나 Enter를 누른다고 실행되지 않는다. 주요 액션 버튼에 포커스가 있어야만 실행된다. 제품에서 유일하게 되돌릴 수 없는 액션이라 일부러 마찰을 둔 것이다.
- Visible focus indicator on all interactive elements
- Focus ring meets 3:1 contrast against adjacent colors
- Custom focus styles consistent across the system
- Never remove outline without providing an alternative
3:1 contrast, 3px ring, visible on the focused element
Keyboard users cannot see where they are
- All interactive elements reachable via Tab
- Logical focus order matching visual order
- Escape key closes modals and overlays
- Arrow keys navigate within composite widgets
- Enter/Space activates buttons and controls
Tab index
Use tabIndex to control keyboard focus order.
| Value | Behavior | When to use |
|---|---|---|
tabIndex="0" | Follows natural DOM order | Custom interactive elements (div with onClick) |
tabIndex="-1" | Focusable via JS, skipped by Tab | Off-screen elements, programmatic focus (modals, error summaries) |
tabIndex="1+" | Forces priority order | Avoid — breaks natural flow, confuses users |
시맨틱 HTML
- 가능하면 네이티브 HTML 요소를 쓴다 (
<button>,<input>,<nav>,<main>) - 상호작용 요소를
<div>나<span>으로 만들지 않는다 - 제목은 위계 순서대로 쓴다 (h1 → h2 → h3, 단계를 건너뛰지 않는다)
- 랜드마크 요소를 쓴다:
<header>,<nav>,<main>,<footer>,<aside> - 목록은 스타일을 입힌 div 대신
<ul>/<ol>로 만든다 <h1>은 페이지마다 하나만 두고, 제품 이름이 아니라 페이지 이름을 쓴다
네이티브 <button>을 쓰면 키보드 실행, 포커스 관리, 비활성 상태, 폼 연동, 올바른 스크린 리더 역할이 기본으로 따라온다. <div role="button">으로 만들면 이걸 전부 직접 구현해야 하고, 실제로는 꼭 하나씩 빠뜨린다.
ARIA 패턴
ARIA는 네이티브 HTML 시맨틱으로 부족할 때만 쓴다.
| Pattern | When to use |
|---|---|
aria-label | 아이콘만 있는 버튼, 라벨이 없는 컨트롤 |
aria-describedby | 에러 메시지, 힌트 텍스트, 보충 정보 |
aria-live | 동적 콘텐츠 갱신 (토스트, 로딩 상태) |
aria-current="page" | 활성 내비게이션 항목, 브레드크럼의 현재 페이지 |
aria-expanded | 아코디언 트리거, 드롭다운 토글 |
aria-invalid="true" | 검증 에러가 있는 폼 입력 |
role="alert" | 에러 요약, 중요한 알림 |
알림 우선순위
| Level | Used for | Example |
|---|---|---|
aria-live="polite" | 읽던 내용이 끝난 뒤에 알려도 되는 상태 | "Rate updated", "3 results" |
aria-live="assertive" / role="alert" | 사용자가 지금 바로 대응해야 하는 일 | 송금 실패, 세션 곧 만료 |
Assertive는 스크린 리더가 읽고 있던 내용을 끊고 들어온다. 환율 갱신에 쓰면 30초마다 사용자가 듣던 문장을 중간에 자르게 된다. Senco에서 assertive는 실패와 보안 이벤트에만 쓴다.
disabled가 아니라 aria-disabled
disabled 버튼은 탭 순서에서 빠진다. 그래서 키보드로 폼 끝까지 Tab을 눌러 내려간 사용자는 버튼을 찾지 못하고, 이유도 알 수 없다. Senco는 aria-disabled="true"를 쓰고 클릭 핸들러만 막는다. 컨트롤은 계속 포커스를 받고, aria-describedby로 무엇이 빠졌는지 알려 줄 수 있다.
첫 번째 규칙
잘못된 ARIA보다 ARIA가 없는 편이 낫다. role을 잘못 붙이면 올바른 네이티브 시맨틱을 덮어써서, 요소가 실제와 다른 것으로 읽힌다. 컴포넌트에 넣은 ARIA 속성은 하나하나 한 문장으로 이유를 댈 수 있어야 한다.
- Use semantic HTML first, ARIA second
- Apply ARIA roles and landmarks to define page regions
- Use aria-label, aria-describedby, and aria-live where needed
- Never use ARIA to override semantic meaning
Landmarks let assistive technology users jump between page regions.
스크린 리더 테스트
모든 컴포넌트는 스크린 리더 두 가지 이상으로 테스트한다.
| Platform | Screen reader | Browser |
|---|---|---|
| macOS | VoiceOver | Safari |
| Windows | NVDA | Firefox or Chrome |
| Mobile | TalkBack (Android) | Chrome |
| Mobile | VoiceOver (iOS) | Safari |
Chrome의 TalkBack은 표 맨 아래에 있지만 우선순위는 가장 높다. Senco 사용자 대부분이 쓰는 기기 환경이기 때문이다.
확인할 것
- 모든 상호작용 요소에 접근 가능한 이름이 있다
- 폼 에러가 나타나면 바로 읽어 준다
- 페이지를 이동하면 페이지 제목이 바뀐다
- 동적 콘텐츠가 바뀌면
aria-live로 알린다 - 이미지에 의미 있는 대체 텍스트가 있다 (장식용이면
aria-hidden) - 금액을 자연스럽게 읽는다. "2 5 0 U S D"가 아니라 "250 dollars"
- 대기 중인 송금의 상태가 바뀌면, 사용자가 다시 찾아 들어가지 않아도 알려 준다
자동 검사는 최소한일 뿐이다
자동화 도구가 잡는 WCAG 이슈는 대략 3분의 1이고, 여기서 가장 중요한 이슈는 하나도 잡지 못한다. 포커스 순서가 자연스러운지, 에러 메시지가 해결 방법을 알려 주는지, 상태 안내가 필요한 순간에 나오는지 같은 것들이다. 자동 검사는 풀 리퀘스트를, 수동 테스트는 릴리스를 통과시키는 조건이다.
- Meaningful alt text for images
- Hidden descriptive text for icon-only actions
- Live regions for dynamic content updates
- Form labels and error messages announced correctly
Alt text for images
alt="USDT wallet ending 9c1d, selected"alt="icon"alt="wallet"alt="South Korean flag — Min-ji Kim, sent $250 CAD"alt="flag"alt=""본문으로 건너뛰기
건너뛰기 링크는 숨어 있다가 키보드 포커스를 받으면 나타나는 앵커다. 반복되는 내비게이션을 건너뛰고 본문으로 바로 이동하게 해 준다.
- 건너뛰기 링크는 페이지에서 가장 먼저 포커스를 받는 요소여야 한다
- 평소에는 화면에서 숨기고,
:focus일 때 보여 준다 - 대상은
id가 있는 랜드마크로 한다 (예:#ds-main-content) - 고정 내비게이션이 있는 페이지에는 모두 건너뛰기 링크를 둔다
- 대상은 포커스를 받을 수 있어야 한다(
tabindex="-1"). 그렇지 않으면 포커스가 앵커에 남고, 다음 Tab에서 다시 내비게이션으로 돌아간다
<body>
<a href="#main" class="ds-skip-link">
Skip to main content
</a>
<nav>...</nav>
<main id="main">...</main><body>
<nav>...</nav>
<a href="#main">
Skip to main content
</a>
<main id="main">...</main>모션과 애니메이션
prefers-reduced-motion설정을 따른다- 동작 줄이기가 켜져 있으면 꼭 필요하지 않은 애니메이션은 끈다
- 스피너는 회전만 멈추고 화면에는 남는다. 로딩 중이라는 사실은 텍스트 라벨이 알려 준다
- 1초에 세 번 넘게 깜빡이는 요소는 없다 (SC 2.3.1)
- 상태 변화를 모션으로만 전달하지 않는다
Platform 계층에서 전역으로 처리한다. Motion 참고.
터치 타깃
- 최소 터치 타깃: 48×48 CSS 픽셀 (WCAG 2.5.5 AAA와 2.5.8 AA보다 크다)
- 타깃 사이 최소 간격: 8px
- 눈에 보이는 요소는 더 작아도 되지만, 탭 영역은 48×48px이어야 한다
체크리스트
새 컴포넌트나 패턴을 만들 때마다 이 체크리스트를 확인한다. 체크하지 않은 항목이나 기록하지 않은 예외가 있으면 출시하지 않는다.
- 색 대비가 두 테마 모두에서 텍스트 4.5:1, UI 3:1을 통과한다
- 키보드로 조작할 수 있다 (Tab, Enter, Escape, 화살표 키). 복합 컴포넌트는 Tab 정지점이 하나다
- 포커스 표시(3px, 20%)가 보이고, 대비 기준을 충족하며,
:focus-visible을 쓴다 - 포커스된 요소가 고정 크롬에 가려지지 않는다
- 스크린 리더가 이름, 역할, 상태를 읽어 준다
- 에러 상태를 색만이 아니라 텍스트와 아이콘으로도 보여 준다
- 에러 메시지가 문제가 있다는 사실만이 아니라 해결 방법을 알려 준다
- 터치 타깃이 최소 48×48px이고, 타깃 사이 간격이 8px이다
-
prefers-reduced-motion을 따르고, 모션으로만 전달하는 상태가 없다 - 시맨틱 HTML을 썼다 (div로 만든 버튼 없음)
- ARIA 속성이 정확하고 꼭 필요한 만큼만 있다.
disabled대신aria-disabled를 쓴다 - VoiceOver와 NVDA로 테스트했다. 송금 경로에 있는 화면은 TalkBack으로도 테스트했다
- 320px 너비와 200% 확대에서 가로 스크롤 없이 텍스트가 줄바꿈된다
- 브라우저 글자 크기 200%에서도 잘리지 않고 동작한다
책임
| Role | Owns |
|---|---|
| 디자인 | 대비, 타깃 크기, 레이아웃상의 포커스 순서, 해결 방법을 알려 주는 에러 카피 |
| 엔지니어링 | 시맨틱 마크업, 키보드 동작, ARIA, 라이브 영역, 포커스 관리 |
| QA | 스크린 리더 테스트, 확대와 리플로우, 두 테마, 위 체크리스트 |
QA에서 돌아온 접근성 실패는 한데 묶어 등록하지 않고 원인별로 나눈다. 대비 실패는 어떤 기준인지 적어 디자인으로, 빠진 라이브 영역은 엔지니어링으로 보낸다.
알려진 공백
| Item | Status |
|---|---|
forced-colors / Windows 고대비 | 구현도 테스트도 안 됨 |
| 기본 상태 입력 테두리 | 근거를 남기고 허용한 예외 |
| CI의 자동 접근성 검사 | 설정 안 됨 |
| 컴포넌트별 접근성 상태 표 | 공개 안 됨. Carbon처럼 variant별로 보고하는 작업이 남아 있다 |
| 인지 접근성 리뷰 | 다국어를 쓰고 시간에 쫓기는 사용자층인데도 정식으로 진행한 적 없음 |