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)
<30decorative 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가 검증된다.

PairWCAGAPCA
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 bold60
18px regular60
24px regular45
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,  // 보수적
  };
}

실제 색의 점수 (참고용)

PairWCAG ratioAPCA Lc판정
#000 on #fff21:1106AAA / 본문
#222 on #fff16:199AAA / 본문
#666 on #fff5.7:170AA / 대형
#999 on #fff2.8:147AA fail / 대형 가능
#fff on #3370b85.4:174AA / 본문
#000 on #3370b83.8:165AA fail / 대형
노랑 #ffeb3b on #fff1.4:111둘 다 fail
노랑 #ffeb3b on #00014:191WCAG 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별로 자동 재바인딩하는 법.