배경색이 바뀌면 글자색도 따라가야 한다 — contrast-color() 없이 살아남는 법

10 min read
CSScontrast-color접근성색상
배경색이 바뀌면 글자색도 따라가야 한다 — contrast-color() 없이 살아남는 법

사용자가 프로필 색상을 직접 고르는 서비스를 만든다고 가정하자. 밝은 노란색 배경에 흰색 텍스트를 올리면? 아무것도 보이지 않는다. 짙은 남색에 검정 텍스트를 올려도 마찬가지다. 배경색에 따라 텍스트 색상을 흑 또는 백으로 자동 전환해야 읽을 수 있다. 이것이 대비색 문제이고, CSS contrast-color() 함수가 정확히 이 문제를 풀기 위해 제안됐다. 하지만 2026년 2월 현재, 이 함수를 지원하는 브라우저는 Safari Technology Preview 정도에 그친다. 기다릴 수 없으니, 다른 CSS 기능으로 우회해야 한다.

[💡 잠깐! 이 용어는?] contrast-color(): CSS Color Module Level 6에 제안된 함수로, 주어진 배경색에 대해 WCAG 대비율 기준을 만족하는 텍스트 색상(보통 흑 또는 백)을 자동 반환한다.


왜 이것이 중요한가

Notion, Slack, GitHub — 사용자가 레이블 색상이나 프로필 배경을 직접 지정하는 서비스는 어디에나 있다. 임의의 배경색에 대해 읽기 쉬운 텍스트 색상을 보장해야 하는데, JavaScript로 명도를 계산해서 분기하는 것은 성능과 유지보수 면에서 비효율적이다.

WCAG 2.1은 텍스트와 배경 간 최소 대비율 4.5:1(일반 텍스트 기준)을 요구한다. 비유하면, "글자가 배경에 묻히지 않도록 최소한의 명암 격차를 보장하라"는 교통 표지판의 최소 크기 규정 같은 것이다. 대비율이 이 기준 아래로 떨어지면, 시력이 좋은 사람도 읽기 불편하다.


대안 1 — color-mix() + light-dark()

CSS light-dark() 함수는 현재 색상 스킴(light/dark)에 따라 두 색상 중 하나를 선택한다.

light-dark-approach.css
:root {
  --bg: #3498db;
  color-scheme: light dark;
}
 
.badge {
  background-color: var(--bg);
  color: light-dark(#000000, #ffffff);
}

한계가 명확하다. light-dark()시스템 또는 페이지 레벨의 color-scheme에 반응할 뿐, 개별 요소의 배경색 명도에 따라 전환되지 않는다. "한 페이지에 밝은 뱃지와 어두운 뱃지가 공존하는" 상황에서는 쓸 수 없다.


대안 2 — Relative Color Syntax + oklch 명도 분기

CSS Relative Color Syntax를 사용하면 기존 색상의 채널 값을 추출하고 조작할 수 있다. oklch 색공간의 명도(L) 채널은 인간의 밝기 인지와 잘 맞아서, 명도 기반 분기에 가장 적합하다.

oklch-contrast.css
.badge {
  --bg: #e74c3c;
  background-color: var(--bg);
 
  /*
    oklch에서 L(명도)을 추출해 분기:
    L > 0.6이면 어두운 텍스트, L <= 0.6이면 밝은 텍스트
  */
  color: oklch(from var(--bg) calc((0.6 - l) * 999) 0 0);
}

[💡 잠깐! 이 용어는?] oklch: 색상을 Lightness(명도), Chroma(채도), Hue(색상) 세 축으로 표현하는 색공간. sRGB보다 인간의 색 인지에 가까워서, "이 색이 밝은지 어두운지"를 판단하기에 더 정확하다.

핵심은 calc((0.6 - l) * 999) 부분이다. 시소 원리와 같다. 명도 0.6을 축(기준점)으로 놓고, 배경색 명도가 기준보다 높으면 한쪽(검정)으로, 낮으면 반대쪽(흰색)으로 확 기울어지도록 큰 배율(999)을 곱한다.

  • l이 0.7(밝은 배경): (0.6 - 0.7) * 999 = -99.9 → clamp되어 0 (검정)
  • l이 0.3(어두운 배경): (0.6 - 0.3) * 999 = 299.7 → clamp되어 1 (흰색)

Relative Color Syntax는 Chrome 119+, Safari 16.4+, Firefox 128+에서 지원된다.


대안 3 — Custom Properties + calc() (레거시 호환)

JavaScript 없이 순수 CSS만으로 RGB 값 기반 명도를 계산하는 방법이다. 가장 넓은 브라우저 호환성을 확보한다.

custom-properties-contrast.css
.badge {
  --r: 52;
  --g: 152;
  --b: 219;
 
  background-color: rgb(var(--r) var(--g) var(--b));
 
  /*
    상대 밝기(relative luminance) 근사 계산
    공식: 0.2126*R + 0.7152*G + 0.0722*B
    결과가 128보다 크면 밝은 배경 → 검정 텍스트
  */
  --luminance: calc(
    0.2126 * var(--r) + 0.7152 * var(--g) + 0.0722 * var(--b)
  );
 
  --text-lightness: calc((var(--luminance) - 128) * -999);
  color: rgb(
    clamp(0, var(--text-lightness), 255)
    clamp(0, var(--text-lightness), 255)
    clamp(0, var(--text-lightness), 255)
  );
}

[💡 잠깐! 이 용어는?] 상대 밝기(Relative Luminance): 색상이 인간의 눈에 얼마나 밝게 보이는지를 수치화한 값. 녹색(G)이 가장 큰 가중치(0.7152)를 갖는 이유는, 인간의 눈이 녹색광에 가장 민감하기 때문이다.

장점은 CSS Custom Properties와 calc()만 쓰기 때문에 모든 모던 브라우저에서 동작한다는 것이다. 단점은 #3498db 같은 hex 값을 직접 쓸 수 없고, RGB 채널을 수동으로 분리하거나 Sass 같은 전처리기의 도움이 필요하다는 점이다.


세 가지 대안 비교

기준color-mix() + light-dark()oklch Relative ColorCustom Properties + calc()
정확도낮음 (페이지 레벨 분기)높음 (요소별 분기)중간 (근사 계산)
코드 간결성간결간결장황
브라우저 지원Chrome 123+, Safari 17.4+Chrome 119+, Safari 16.4+모든 모던 브라우저
hex 색상 직접 사용가능가능불가 (RGB 분리 필요)
요소별 독립 동작불가가능가능
권장 상황라이트/다크 모드 전환동적 배경색 대응레거시 호환 필요 시

실전 — 동적 뱃지 컴포넌트

실무에서 가장 자주 마주치는 시나리오는 태그(Tag)나 뱃지(Badge)다. 사용자가 색상을 자유롭게 지정하고, 그 위에 텍스트가 올라가는 구조다.

dynamic-badge.css
.badge {
  --badge-color: var(--user-selected-color, #3498db);
 
  background-color: var(--badge-color);
  color: oklch(from var(--badge-color) calc((0.6 - l) * 999) 0 0);
 
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 14px;
  font-weight: 600;
}
 
.badge--danger  { --badge-color: #e74c3c; }
.badge--warning { --badge-color: #f1c40f; }
.badge--info    { --badge-color: #2c3e50; }

JavaScript를 한 줄도 쓰지 않고, CSS 변수 하나만 바꾸면 배경색과 텍스트 색이 모두 자동으로 적절하게 조정된다.


접근성 주의사항

CSS만으로 대비색을 자동 계산하는 방법들은 편리하지만, WCAG 대비율 4.5:1을 완벽히 보장하지는 않는다. oklch 명도 기준 0.6은 경험적으로 좋은 결과를 내지만, 모든 색상에서 AA 기준을 통과하는 것은 아니다. 특히 중간 명도의 채도 높은 색상(선명한 초록, 선명한 주황)에서는 흑과 백 모두 대비율 4.5:1 미만이 될 수 있다.

현실적인 전략은 두 가지를 병행하는 것이다.

  1. CSS 자동 분기를 기본 구현한다
  2. 디자인 시스템 레벨에서 사용 가능한 색상 팔레트를 제한해, 모든 팔레트 색상에 대해 대비율을 사전 검증한다

색상 팔레트를 제한하면 자유도가 줄어드는 것 같지만, 비유하면 "아무 물감이나 써라"보다 **"검증된 물감 30가지 중에서 골라라"**가 사고를 줄이는 것과 같다.


마무리

contrast-color()가 모든 브라우저에서 지원되는 날이 오면, 이 모든 우회 방법은 퇴역한다. 그날이 올 때까지 oklch Relative Color Syntax가 가장 실용적인 대안이다. 코드가 간결하고, 요소별로 독립 동작하며, 모던 브라우저 지원 범위도 충분하다. 레거시 호환이 필요하면 Custom Properties + calc()를 선택하면 된다. 어떤 방법을 쓰든 원칙은 하나다 — 배경색이 바뀌면 텍스트 색도 자동으로 따라가야 한다.

관심 있을 만한 포스트

OHP 필름을 겹치듯 — conic-gradient와 attr()로 순수 CSS 파이 차트 만들기

conic-gradient, CSS 커스텀 프로퍼티, 새로운 attr() 타입 구문을 활용해 JavaScript 없이 시맨틱한 파이 차트를 구현하는 방법을 다룬다.

CSSconic-gradient

CSS @property — 커스텀 속성에 타입을 부여하는 방법

CSS @property at-rule로 커스텀 속성에 타입 정의, 상속 제어, 폴백을 추가해 렌더링 안정성과 애니메이션 가능성을 확보하는 방법을 다룬다.

CSS@property

CSS :near() — 마우스가 '가까이' 오면 반응하는 새로운 의사 클래스

CSS Working Group에 제안된 :near() 의사 클래스는 포인터 근접성을 감지해 호버 전에 UI를 활성화하는 새로운 상호작용 패턴을 연다.

CSS:near()

CSS만으로 커스텀 셀렉트 박스 — JavaScript 150줄이 사라지는 순간

Chrome 135에 도입된 appearance: base-select와 sibling-index()로 JavaScript 없이 완전한 커스텀 드롭다운을 구현하는 방법을 분석한다.

CSSbase-select

sibling-index()로 만드는 CSS 스크롤 소용돌이 — JavaScript 없이 수백 개 요소 애니메이션

CSS sibling-index()와 scroll-driven animations를 결합해 순수 CSS만으로 텍스트 보텍스 효과를 구현하는 기법을 다룬다.

CSSsibling-index

Interop 2026 — 브라우저 전쟁이 끝나고 표준 전쟁이 시작됐다

Chrome, Safari, Firefox가 합의한 20개 웹 표준 집중 영역과 프론트엔드 개발자가 주목해야 할 핵심 기능을 정리한다.

InteropCSS

CSS Stacking Context — z-index: 99999를 줬는데 왜 안 올라올까

CSS 쌓임 맥락의 생성 조건, z-index의 실제 작동 원리, 그리고 레이아웃 버그를 디버깅하는 실전 전략을 정리한다.

CSSz-index

드롭다운의 정체를 밝혀라 — Combobox, Multiselect, Listbox 완전 해부

Combobox, Multiselect, Listbox, Dual Listbox의 차이점과 접근성 요구사항, 선택 기준을 ARIA 패턴 기반으로 정리한다.

UI컴포넌트접근성