03 — Contrast & Accessibility
이 문서가 답하는 질문: 색 토큰이 접근성을 통과했다는 건 누가, 어떻게, 어느 기준으로 보장하는가? WCAG 2.1만 믿으면 안 되는 이유, 그리고 APCA가 어떻게 그 자리를 대체하고 있는가. 한 줄 답 (Pyramid Top): 색 접근성은 “foreground × background = pair” 의 문제다. 단일 색 토큰은 접근성이 없다 — pair만 접근성이 있다. WCAG 2.1의 4.5:1 ratio는 1985년의 sRGB 측정에 기반해 밝은 텍스트의 가독성을 과대평가한다. APCA는 같은 문제를 sRGB OETF + 모델 기반으로 다시 풀고, 극단 명도 영역에서 더 정확하다.
Why — 왜 WCAG 2.1로는 부족한가
WCAG 2.1 SC 1.4.3 — contrast ratio
ratio = (L1 + 0.05) / (L2 + 0.05)
where L1 > L2 (둘 다 sRGB relative luminance)- AA 통과: 4.5:1 (본문), 3:1 (대형 텍스트 18pt+ 또는 14pt bold+)
- AAA 통과: 7:1 / 4.5:1
// sRGB relative luminance
function lum(c) {
const [r, g, b] = c.map(x => {
x /= 255;
return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function ratio(rgb1, rgb2) {
const [a, b] = [lum(rgb1), lum(rgb2)].sort((x, y) => y - x);
return (a + 0.05) / (b + 0.05);
}WCAG 2.1의 4가지 약점
| 약점 | 증상 |
|---|---|
| 광원 노랑이 너무 잘 통과 | #ffeb3b on white → 1.6:1 (fail), 사람 눈엔 정말 안 보임. 잘못 fail은 OK인데 반대도 종종 일어남. |
| 다크모드 본문에 너무 빡빡 | 흰 텍스트 on 짙은 회색 → ratio는 통과하지만 눈부심 |
| 글자 크기·두께를 안 봄 | 8pt 본문 4.5:1과 14pt 본문 4.5:1을 동일하게 평가 |
| sRGB만 가정 | OKLCH·P3·다양한 광원 색을 변환 후 처리하지만, 변환 손실 |
APCA의 등장 (W3C WCAG 3 draft)
APCA (Accessible Perceptual Contrast Algorithm), Andrew Somers가 2019~ 개발. WCAG 3의 contrast 기준 후보. WordPress·Adobe·Figma가 채택.
Lc = round( (Y_bg_OETF^0.65 - Y_fg_OETF^0.62) × 1.14 × 100 )- 결과:
Lc 0 ~ ±108. 방향성 있는 점수. +이면 어두운 글자 on 밝은 배경,-이면 반대.- 글자 크기·두께에 따라 필요한 Lc가 다름.
APCA 기준 (실용):
| Lc | 사용처 |
|---|---|
| 90+ | 모든 본문 절대 안전 |
| 75+ | 4.5pt 이상 본문 (AA에 해당) |
| 60+ | 18pt 이상 대형 텍스트 |
| 45+ | 24pt 이상 매우 큰 텍스트 |
| 30+ | non-text (icon, divider) |
<30 | decorative only |
How — Pair 단위로 토큰 만들기
핵심: foreground × background를 함께 토큰화
/* ✗ 안 됨 — 단일 색은 접근성이 없음 */
:root {
--text-primary: oklch(0.21 0.04 254);
--bg-page: oklch(0.99 0 0);
}
/* 누가 누구 위에 올라간다는 보장이 없음 */
/* ✓ Pair로 묶기 */
:root {
--pair-text-on-page: {
fg: oklch(0.21 0.04 254);
bg: oklch(0.99 0 0);
};
}CSS는 객체 토큰이 없으니 명명 규칙으로 푼다:
:root {
/* page bg + 그 위 텍스트 — 묶어서 검증된 페어 */
--color-bg-page: oklch(0.99 0 0);
--color-text-on-page: oklch(0.21 0.04 254); /* APCA Lc 95 vs page */
--color-text-on-page-2: oklch(0.43 0.10 254); /* APCA Lc 75 vs page */
/* card bg + 그 위 텍스트 */
--color-bg-card: oklch(0.98 0.005 254);
--color-text-on-card: oklch(0.21 0.04 254); /* APCA Lc 94 vs card */
/* solid primary bg + 그 위 텍스트 */
--color-bg-primary: oklch(0.55 0.23 247); /* blue-9 */
--color-text-on-primary: white; /* APCA Lc -85 (어두운 배경) */
}이름 패턴: text-on-{surface}. 어떤 surface 위에 올라가는 텍스트인지 명시.
빌드 타임 검증 스크립트
// scripts/check-contrast.mjs
import { APCAcontrast, sRGBtoY } from 'apca-w3';
import { parse, formatRgb } from 'culori';
const PAIRS = [
{ name: 'text-on-page', fg: 'oklch(0.21 0.04 254)', bg: 'oklch(0.99 0 0)', minLc: 90 },
{ name: 'text-on-page-2', fg: 'oklch(0.43 0.10 254)', bg: 'oklch(0.99 0 0)', minLc: 75 },
{ name: 'text-on-card', fg: 'oklch(0.21 0.04 254)', bg: 'oklch(0.98 0.005 254)', minLc: 90 },
{ name: 'text-on-primary', fg: '#ffffff', bg: 'oklch(0.55 0.23 247)', minLc: 75 },
];
let failed = 0;
for (const p of PAIRS) {
const fgRGB = parse(p.fg);
const bgRGB = parse(p.bg);
const Lc = Math.abs(APCAcontrast(
sRGBtoY([fgRGB.r * 255, fgRGB.g * 255, fgRGB.b * 255]),
sRGBtoY([bgRGB.r * 255, bgRGB.g * 255, bgRGB.b * 255]),
));
const ok = Lc >= p.minLc;
console.log(`${ok ? '✓' : '✗'} ${p.name}: Lc=${Lc.toFixed(1)} (min ${p.minLc})`);
if (!ok) failed++;
}
process.exit(failed > 0 ? 1 : 0);CI에 걸어두면 디자이너가 토큰을 바꿔도 자동으로 검증된다. 접근성을 문서가 아닌 빌드 시스템에 박는다.
Radix Colors의 보장
Radix 12-step의 step 11/12는 step 1/2 위에서 항상 WCAG AA 통과를 보장한다. step 9는 white 위 또는 검정 위에서 contrast pair가 검증된다.
| Pair | WCAG | APCA |
|---|---|---|
| step 11 (text-low) on step 1 (bg) | 4.5:1+ | Lc 75+ |
| step 12 (text-high) on step 1 (bg) | 7:1+ | Lc 90+ |
| white on step 9 (solid brand) | 4.5:1+ (대부분) | Lc 75+ |
| step 12 on step 3 (component bg) | 4.5:1+ | Lc 75+ |
이게 Radix의 가장 큰 가치 — 명도 슬롯 + contrast 보장이 묶여 있다.
What — 실제 토큰 + 검증
Semantic pair 토큰 정의
// tokens/contrast-pairs.ts
export const pairs = {
// body text
'text/primary/on-page': { fg: 'oklch(0.21 0.04 254)', bg: 'oklch(0.99 0 0)', minLc: 90 },
'text/secondary/on-page': { fg: 'oklch(0.43 0.10 254)', bg: 'oklch(0.99 0 0)', minLc: 75 },
'text/disabled/on-page': { fg: 'oklch(0.62 0.05 254)', bg: 'oklch(0.99 0 0)', minLc: 45 },
// on dark surface
'text/primary/on-dark': { fg: 'oklch(0.96 0 0)', bg: 'oklch(0.14 0.02 254)', minLc: 90 },
// on brand
'text/on-brand-solid': { fg: '#ffffff', bg: 'oklch(0.55 0.23 247)', minLc: 75 },
'text/on-warning-solid': { fg: 'oklch(0.21 0.04 70)', bg: 'oklch(0.85 0.15 70)', minLc: 75 },
// borders / icons (non-text)
'border/default/on-page': { fg: 'oklch(0.83 0.02 254)', bg: 'oklch(0.99 0 0)', minLc: 30 },
'icon/decorative': { fg: 'oklch(0.68 0.15 254)', bg: 'oklch(0.99 0 0)', minLc: 45 },
};APCA 점수 계산 (실시간)
import { APCAcontrast, sRGBtoY, displayP3toY } from 'apca-w3';
import { converter } from 'culori';
const toRgb = converter('rgb');
export function apca(fg: string, bg: string): number {
const fgRgb = toRgb(fg);
const bgRgb = toRgb(bg);
return APCAcontrast(
sRGBtoY([fgRgb.r * 255, fgRgb.g * 255, fgRgb.b * 255]),
sRGBtoY([bgRgb.r * 255, bgRgb.g * 255, bgRgb.b * 255]),
);
}
// 사용
console.log(apca('oklch(0.21 0.04 254)', 'oklch(0.99 0 0)'));
// → -97.6 (음수 = 어두운 글자 on 밝은 배경)APCA의 권장 사용표 (실용)
| 글자 크기 / 두께 | 최소 Lc |
|---|---|
| 16px regular (본문) | 75 |
| 16px bold | 60 |
| 18px regular | 60 |
| 24px regular | 45 |
| 32px+ | 30 |
| 비-텍스트 (border, icon) | 30 |
| decorative만 | 15 |
WCAG 2.1 vs APCA 동시 검증
WCAG 3가 아직 draft라서 둘 다 만족하는 게 안전하다.
function validatePair({ fg, bg, role }: Pair) {
const wcag = wcagRatio(fg, bg);
const apca = Math.abs(apca(fg, bg));
return {
wcagAA: wcag >= 4.5,
wcagAAA: wcag >= 7,
apcaBody: apca >= 75,
apcaLarge: apca >= 60,
pass: wcag >= 4.5 && apca >= 75, // 보수적
};
}실제 색의 점수 (참고용)
| Pair | WCAG ratio | APCA Lc | 판정 |
|---|---|---|---|
#000 on #fff | 21:1 | 106 | AAA / 본문 |
#222 on #fff | 16:1 | 99 | AAA / 본문 |
#666 on #fff | 5.7:1 | 70 | AA / 대형 |
#999 on #fff | 2.8:1 | 47 | AA fail / 대형 가능 |
#fff on #3370b8 | 5.4:1 | 74 | AA / 본문 |
#000 on #3370b8 | 3.8:1 | 65 | AA fail / 대형 |
노랑 #ffeb3b on #fff | 1.4:1 | 11 | 둘 다 fail |
노랑 #ffeb3b on #000 | 14:1 | 91 | WCAG AAA / APCA AAA (방향 차이!) |
마지막 행이 흥미롭다 — 노랑 on 검정은 WCAG AAA지만 APCA 91도 통과. 하지만 노랑 on 흰색은 둘 다 fail. WCAG는 대칭(흰/검 결과 같음)이지만 APCA는 비대칭 — 사람 눈의 실제 동작과 일치.
What-if — 잘못 다루면
1) “단일 색 토큰의 접근성”을 주장하기
✗ "--color-text-primary는 WCAG AAA 통과한 색이야"색 단독으로는 통과 못 한다. 어떤 배경 위에서인지 명시 안 하면 의미 없다. Pair로 만들어야 한다.
2) WCAG 2.1만 검증
특히 brand 색과 warning/info 노랑/주황이 문제. WCAG가 통과해도 APCA에서 떨어지는 경우 많음. Adobe·Figma가 둘 다 도입한 이유.
3) Hover/active state의 contrast 무시
.btn { background: var(--blue-9); color: white; } /* 검증 됨 */
.btn:hover { background: var(--blue-10); } /* 검증 안 됨 */step 10은 더 어두워서 안전하지만, 다른 색(특히 warning)에선 hover가 contrast를 깰 수 있다. 모든 state의 pair를 검증해야 한다.
4) Focus ring을 약하게
.input:focus { outline: 1px solid var(--blue-5); } /* L=0.88 — 안 보임 */focus ring은 border와 다른 명도여야 가시성이 있다. WCAG SC 2.4.7 — focus visible. APCA Lc 30+ 권장.
5) Dark mode contrast = light mode contrast 가정
light에서 4.5:1이면 dark에서도 4.5:1일 거라고 맹신하면, 명도 반전 시 chroma는 다르게 동작하므로 깨질 수 있다. Pair는 theme별로 재검증한다. → 04장
Insight — APCA가 WCAG를 대체하지 못하는 진짜 이유
“맞는 답이지만 책임 회피 안 되는 답”
APCA가 수학적으로 더 정확하다는 건 색채과학자들이 동의한다. Andrew Somers의 페이퍼와 데이터는 탄탄하다. 그런데 2026년 현재도 WCAG 2.1이 법적 기준이다.
이유는 단순하다. 법적 책임. 미국 ADA, 유럽 EAA(2025년 시행), 한국 KWCAG — 모두 WCAG 2.1 AA를 기준으로 명시한다. 회사 변호사는 APCA를 모른다. 그가 문서에 “WCAG 2.1 AA 준수” 라고 쓸 수 있는 것이 우선이다.
그래서 실무 패턴은 “WCAG 2.1을 baseline, APCA를 high bar” 로 같이 쓴다 — WCAG로 법적 통과, APCA로 실제 가독성을 보장. 빌드 검증 스크립트도 둘 다 돌린다.
또 하나의 반전 — APCA는 OKLCH 친화적이다. APCA의 OETF는 sRGB의 비선형 응답을 모델링하는데, 이게 OKLCH의 L과 가깝게 정렬된다. 그래서 OKLCH L 차이만으로 APCA Lc를 근사할 수 있다 — |ΔL| × 약 100 ≈ |Lc| (대략적). OKLCH 시스템에선 APCA 검증이 덤에 가깝다.
WCAG 3의 정식 발표는 2027년 이후로 미뤄질 가능성이 크다. 그때까지는 둘 다가 답.
요약
- 색의 접근성은 pair의 속성이지 단일 토큰의 속성이 아니다.
- WCAG 2.1: 4.5:1 / 3:1 / 7:1. 법적 baseline.
- APCA Lc: 90 / 75 / 60 / 45. 실제 가독성 + WCAG 3 draft.
- 디자인 시스템은
text-on-{surface}패턴으로 pair를 이름으로 표현. - 빌드 검증 스크립트로 모든 pair에 대해 둘 다 통과를 강제한다.
다음: 04-dark-mode-token-strategy — pair를 theme별로 자동 재바인딩하는 법.