# color — 색 체계 상세 3단계에서 [tokens.md](tokens.md) §2 의 역할 토큰(`--surface`·`--surface-raised`·`--ink`·`--ink-muted`·`--line`·`--accent`·`--accent-ink`)을 실제 값으로 채울 때 필요한 절만 연다. **역할 토큰이 정본이고 이 문서는 방법이다** — 여기서 새 역할을 만들거나 `tokens.md`의 규칙을 바꾸지 않는다. 이 문서는 그 역할에 어떤 값을 어떻게 채우는지, 다크모드·그라디언트·대비를 어떻게 다루는지를 다룬다. ## 이 문서를 읽는 법 | 상황 | 읽을 곳 | |---|---| | 역할 토큰에 채울 실제 색이 없다(새 시스템) | §1~§3 | | 여러 단계(램프)가 필요하다 | §2 | | 그라디언트를 쓴다 | §4 | | 다크모드를 만들거나 다듬는다 | §5 | | 대비가 실패했거나 대비 근거가 필요하다 | §6 | | 상태색·강조색의 의미와 로케일을 정한다 | §7 | | 표준 7개 역할 밖의 색(비활성·scrim 등)이 필요하다 | §8 | | 고채도 브랜드 색이 화면에서 탁하게 보인다 | §9 | | 리디자인이나 정리·일관화 작업에서 기존 팔레트를 정리한다 | §10 | | 값을 어떤 표기법(hex·oklch 등)으로 쓸지 모르겠다 | §11 | | 마무리 전 확인 | §12 | --- ## §0. 언제 색을 다루는 모든 순간이 아니라, **역할 토큰에 값을 채우는 3단계**와 **다크모드·그라디언트·리디자인처럼 별도 결정이 필요한 순간**에만 연다. 브리프·기존 코드에 이미 정해진 색이 있으면 그것을 재사용하고 이 문서는 건너뛴다. 판정 위계는 이 문서 전체에서 세 층으로 나뉜다. **하드 게이트**는 WCAG 2 대비처럼 협상 대상이 아닌 규범이다. **프로젝트 계약**은 브리프·다크모드 지원 여부·특정 프레임워크 채택처럼 이 프로젝트가 선택하면 지키는 약속이다. **관찰 후보**는 스타일 휴리스틱이며 시작값일 뿐 통과 기준이 아니다. --- ## §1. 역할 우선과 선택적 원시값 계층 > 언제: 다중 hue 브랜드(강조색이 여러 개 필요)이거나 정밀한 다크모드 튜닝이 필요할 때만 연다. [tokens.md](tokens.md) §2의 7개 역할 토큰은 대부분의 프로젝트에 충분한 **시맨틱 계층**이다. 시맨틱 토큰은 직무를 이름 짓고(`--accent`, `--ink-muted`) 컴포넌트가 참조하는 유일한 계층이다. 그 아래에 **원시값(primitive) 계층**을 둘지는 선택이다. 원시값은 값을 이름 짓고(`--blue-500`, `--neutral-200`) 컴포넌트에 절대 직접 쓰이지 않으며, 시맨틱 토큰이 그것을 가리킨다. 이 2계층 구조가 다크모드·화이트라벨 테마·증가-대비 변형을 가능하게 하는 이음매다 — 경계가 없으면 다크 모드를 만들 때마다 "이 파랑이 강조를 뜻했는지 그냥 파랑을 원했는지"를 모든 사용처에서 가려내야 한다 [SKILL-BETTER-COLORS]. ```css :root { /* 원시값(1계층) — 값을 이름 짓는다. 컴포넌트에 직접 쓰지 않는다 */ --blue-500: #3b82f6; --neutral-700: #374151; /* 시맨틱(2계층) — 직무를 이름 짓는다. tokens.md §2의 역할과 일치시킨다 */ --accent: var(--blue-500); --ink-muted: var(--neutral-700); } ``` **단순 프로젝트에 원시값 계층을 강제하지 않는다.** 다중 hue나 정밀 다크모드 튜닝이 필요 없으면 `tokens.md`의 7개 역할 토큰에 값을 직접 채우는 것으로 끝낸다 — 계층을 늘리는 것 자체가 목적이 아니다. --- ## §2. 램프 생성 > 언제: 하나의 hue에서 여러 단계(호버·활성·옅은 배경·솔리드 필 등)가 동시에 필요할 때만 연다. 역할 토큰 하나에 값 하나면 이 절은 필요 없다. 잘 만든 램프는 네 가지 속성을 갖는다 [SKILL-BETTER-COLORS]. 1. **지각 명도 간격이 고르다.** 포맷이 부르는 명도가 아니라 사람이 실제로 느끼는 명도로 계단진다 — HSL의 명도는 비지각적이라 고르게 벌린 HSL 값이 한쪽으로 뭉친다. 2. **hue가 끝까지 일정하다.** 방황하는 hue는 두 색이 섞인 것처럼 읽힌다. 3. **생생함(채도·명도의 지각적 강도)은 중간에서 정점을 찍고 양끝에서 떨어진다.** 양끝까지 완전 채도를 유지하면 빛나는 밝은 끝과 잉크를 쏟은 듯한 어두운 끝이 된다. 4. **밝은 끝일수록 스텝이 촘촘하다.** 밝은 배경은 더 세밀한 구분이 필요하다 — 전 범위를 고르게 배치하면 옅은 끝의 두 표면(예: 페이지 배경과 카드 배경)이 구별되지 않는다. **관찰 후보 — 브랜드 색을 고정하는 두 방식.** 계약상 정확히 지켜야 하는 브랜드 색은 **핀(pin)** — 그 값을 정확히 유지하고 램프가 그 주변에서 바깥으로 지어지며, 그 스텝만 약간 고르지 않게 배치된다. 그 외에는 **스냅(snap)** — 램프에 맞춰 모든 스텝을 고르게 배치한다. 스냅이 대개 더 고르게 보이고, 스와치를 나란히 대보지 않으면 차이가 잘 드러나지 않는다 [SKILL-BETTER-COLORS]. **절대 손이나 눈으로 계산하지 않는다.** 색 라이브러리(`culori`, `colorjs.io`, `chroma.js` 등)로 지각 공간에서 보간하고 프로젝트 표기법으로 출력한다 [SKILL-BETTER-COLORS]. ```js import { clampChroma, formatHex } from 'culori' // OKLCH에서 hue를 고정하고, 밝은 끝을 촘촘하게 둔 명도 배열을 직접 준다(원칙 4) const L = [0.98, 0.955, 0.92, 0.86, 0.78, 0.68, 0.58, 0.49, 0.41, 0.33, 0.25] const hue = 259, peakChroma = 0.19 const steps = L.map((l, i) => { const t = i / (L.length - 1) const c = peakChroma * Math.sin(Math.PI * t) + 0.01 // 채도는 중간에서 정점(원칙 3) return formatHex(clampChroma({ mode: 'oklch', l, c, h: hue }, 'oklch')) // sRGB 밖이면 채도만 깎는다 }) ``` 명도 배열과 채도 곡선은 시작값이다. 브랜드 색을 핀으로 고정하면 그 스텝의 `l`·`c`를 브랜드 값으로 바꾸고 이웃을 다시 고른다. **Tailwind `50`-`950` 11스텝, Radix `1`-`12` 12스텝 매핑은 그 체계를 이미 쓰는 프로젝트에서만 참조한다.** 이 매핑을 새 프로젝트의 기본값으로 강제하지 않는다. 두 컨벤션은 번호가 아니라 종류가 다르다 — Radix는 스텝을 역할로 정의해 다크 스케일이 같은 번호를 재사용하는 별도 램프라 `--accent-9`가 라이트·다크 양쪽에서 "솔리드 필"을 뜻하지만, Tailwind는 스텝을 명도로 정의해(`50`=밝음, `950`=어두움) 다크모드에서 매핑이 반전된다(페이지 배경이 `950`이 됨) [SKILL-BETTER-COLORS]. 양끝 모두 순수 검정·흰색에는 못 미치게 둔다 — 거기 닿는 순간 색이 페이지 배경이 사는 지점에서 정체성을 잃는다. --- ## §3. OKLCH 파생 `oklch(L C H)` — 명도(L) 0~1, 채도(C) 0~약 0.4, hue(H) 0~360, 알파는 `/`로 분리한다. 지각적으로 균일한 명도·안정적 hue·예측 가능한 램프가 장점이다. Baseline 2023-05 Newly(모든 주요 엔진 지원), 2025-11경 Widely다 [WEB-BASELINE]. **hue와 chroma를 고정하고 lightness만 움직이면 라이트·다크 쌍을 체계적으로 파생할 수 있다.** ```css :root { /* hue(259)·chroma(0.19)는 고정, lightness만 움직인다 — 어두운 배경 위에서는 강조색을 밝게 올린다 */ --accent-light: oklch(0.55 0.19 259); --accent-dark: oklch(0.75 0.19 259); } ``` **진짜 새 색 시스템이면 `oklch()`가 최선의 기본값이다.** 그 외에는 색 라이브러리로 프로젝트 고유 표기법에 맞춰 같은 연산을 한다 — 표기법 선택 자체는 §11을 따른다. `color-mix()`는 상태용 색 파생(호버·비활성 등)에 유용하다. Baseline 2023-05 Newly, 2025-11경 Widely다 [WEB-BASELINE]. 다만 생성값이 토큰 계층 밖에 놓여 디자인 툴에서 검사할 수 없다는 한계가 있으므로, 반복해서 쓰이는 값이면 토큰으로 승격할지 판단한다. 상대 색 문법(`oklch(from var(--x) calc(l - 0.1) c h)`)도 같은 용도로 강력하다. 다만 Baseline Newly(2024-09-16)로 `color-mix()`보다 16개월 늦어 아직 Widely가 아니다 — 지원 하한이 Safari 17 이하까지 내려가는 프로젝트는 고정 값 폴백을 먼저 선언한다[WEB-BASELINE]. 3중 이상 연쇄 파생은 읽을 수 없어진다 — **관찰 후보**로 두고 체인이 길어지면 고정 값으로 되돌린다 [SKILL-BETTER-COLORS]. `contrast-color(<배경>)`는 배경에 대해 검정·흰색 중 대비가 큰 쪽을 고른다(Baseline Newly 2026-04-10). 사용자 지정 색 위의 글자색처럼 배경을 미리 알 수 없는 자리의 폴백으로만 쓴다 — 결과가 둘뿐이라 브랜드 잉크 토큰을 대체하지 못하고, 중간 명도 배경에서는 고른 쪽도 4.5:1에 못 미칠 수 있으므로 §6의 대비 측정을 건너뛰는 근거가 되지 않는다. [WEB-BASELINE] --- ## §4. 그라디언트 보간 > 언제: 실제로 그라디언트를 쓸 때만 연다. 기본값(아무것도 지정하지 않았을 때)인 sRGB 보간은 직선(rectangular) 공간이다. hue 휠에서 반대편에 있는 두 색 사이를 직선으로 잇기 때문에 그 직선이 중립축 근처를 지나며 **중간 지점이 탁하고 어두운 회색으로 꺼진다(회색 데드존)**. `oklab`도 직선 보간이지만 지각적으로 균일한 밝기를 유지해 sRGB보다 낫다. `oklch`는 극좌표(polar) 공간이라 hue 각도를 돌아가며 보간해 생생함이 끝까지 유지되지만, 대신 두 스톱 사이의 모든 hue를 훑고 지나간다(예: 파랑→핑크가 보라를 거쳐 간다 — 의도한 룩일 수도, 서프라이즈일 수도 있다) [SKILL-BETTER-COLORS]. ```css /* 기본값(sRGB): 지정 안 하면 이것 — 중간이 탁해진다 */ .a { background: linear-gradient(to right, #eff6ff, #3b82f6); } /* 최선 기본값: 직선 보간이면서 균일한 밝기 */ .b { background: linear-gradient(in oklab, #eff6ff, #3b82f6); } /* hue 휠을 도는 룩이 필요할 때 */ .c { background: linear-gradient(in oklch longer hue, #eff6ff, #3b82f6); } ``` 그라디언트 보간 공간 지정 문법은 Baseline Newly available이다(2024-06-11, Firefox가 마지막으로 지원을 완료). Widely는 그 날짜에서 30개월 뒤다 — 읽는 시점이 그 전이면 지정하지 않았을 때의 기본 sRGB 결과가 허용 가능한지 확인하거나 `@supports` 폴백을 둔다 [WEB-BASELINE]. **밴딩**은 스톱 간 대비가 낮은 넓은 영역에서 8비트 디스플레이의 계단이 드러나는 현상이다. 대비를 넓히거나 영역을 줄이거나 미세한 노이즈 텍스처를 얹는다. `shorter hue`/`longer hue` 키워드로 `oklch` 보간이 도는 방향을 고를 수 있다. --- ## §5. 다크모드 재조정과 전환 메커니즘 > 언제: 프로젝트가 다크모드를 지원하기로 했을 때 연다. `tokens.md` §2는 "다크는 반전이 아니라 역할별 재정의"라고 이미 선언한다 — 이 절은 그 재정의를 실행하는 구체 체크리스트다. **반전은 출발점이지 결과물이 아니다.** 라이트 팔레트를 기계적으로 뒤집은 뒤, 거의 항상 아래 세 가지를 수동으로 다듬어야 한다 [SKILL-BETTER-COLORS]. 이는 **프로젝트 계약** — 다크모드를 지원하기로 했다면 지킨다. 1. **생생함을 낮춘다.** 흰 배경 위에서 자신감 있게 읽히던 색이 거의 검정 위에서는 네온으로 읽힌다. 다크 외형에서는 강조색을 1~2스텝 덜 생생하게 조정한다. 2. **어두운 끝을 분리한다.** 옅은 배경에서 구분되던 스텝이 어두운 표면에서는 서로 뭉개진다. 어두운 끝에 스텝을 더 둔다. 3. **대비 비대칭을 재검사한다.** 대비는 거울상이 아니다 — 라이트에서 통과한 쌍이 반전되면 실패할 수 있다. 두 외형 모두 실제 렌더 배경에 대해 전경을 다시 검사한다. **전환 메커니즘은 하나를 고르고 전체에 쓴다.** 메커니즘을 섞는 것이 흔한 실패다 — 일부 토큰은 미디어쿼리로, 다른 토큰은 클래스로 설정하면 사용자가 시스템 선호를 오버라이드하는 순간 반쯤만 테마된 인터페이스가 된다 [SKILL-BETTER-COLORS]. | 메커니즘 | 맞는 상황 | 주의 | |---|---|---| | `prefers-color-scheme` 단독 | 테마 토글 UI가 없을 때 | persist·hydrate할 상태가 없어야 성립한다 | | `.dark` 클래스 | 사용자가 시스템 설정을 오버라이드할 수 있을 때 | 미디어쿼리는 초깃값만 정하고, 클래스가 최종 결정이다 | | `light-dark()` | `color-scheme`도 함께 선언하는 프로젝트 | 클래스 기반 토글이라도 `color-scheme` 속성을 함께 갱신해야 값이 반영된다. Baseline Newly available(2024-05-13. Widely는 30개월 뒤)이므로 읽는 시점이 그 전이면 지원 범위를 확인한다 [WEB-BASELINE] | 어느 행인지는 **기본 테마(시스템 따름·라이트·다크)와 사용자 토글 유무**가 정한다. 둘 다 브리프의 값이다 — 브리프에 없으면 프로젝트의 기존 테마 파일이 쓰는 방식을 따르고, 임의로 토글을 더하거나 빼지 않는다. ```css :root { color-scheme: light dark; --surface: light-dark(<밝은 테마 표면>, <어두운 테마 표면>); --ink: light-dark(<밝은 테마 본문>, <어두운 테마 본문>); } ``` 값은 3단계에서 정한 것을 넣는다. 성격이 다른 표면 세 쌍의 예다 — 형식을 보이려는 것이니 그대로 쓰지 않고, 대비는 각자 다시 잰다. - 쿨 회색: `light-dark(oklch(0.97 0.005 250), oklch(0.2 0.02 250))` - 중간 명도 유채: `light-dark(oklch(0.86 0.06 120), oklch(0.3 0.05 120))` - 깊은 유채 다크: `light-dark(oklch(0.95 0.02 30), oklch(0.18 0.04 300))` **다크모드 완결성 — 값 밖의 다섯 가지.** 다크모드를 지원하기로 했다면 지킨다(프로젝트 계약). 마지막 두 줄은 관찰 후보다. 1. **`color-scheme`을 선언한다.** CSS `color-scheme`(위 예시)이나 ``로 알려야 스크롤바·폼 컨트롤·기본 캔버스색이 테마를 따른다. 한 테마만 제공하면 그 값만 적는다. 2. **첫 페인트 전에 테마를 정한다(FOUC 방지).** 사용자 선택을 저장하는 토글이 있으면 `
` 안 인라인 스크립트로 저장값을 읽어 ``에 클래스·`color-scheme`을 붙인 뒤 CSS가 그려지게 한다. 외부 JS나 하이드레이션 뒤에 정하면 흰 화면이 한 번 번쩍인다. 저장소 접근이 막힌 환경(try/catch)에서는 시스템 선호로 떨어진다. ```html ``` 3. **브라우저 UI 색을 테마마다 맞춘다.** ``와 다크용 한 줄을 둔다. 이 값을 브라우저 탭에서 쓰는 것은 Android Chrome이고, Safari는 26부터 **설치된 웹앱에서만** 쓴다(MDN 호환성 데이터, 확인 2026-10-06)[WEB-BASELINE]. Safari 탭의 툴바 색을 정하는 규칙은 문서화되지 않았으므로 특정 색을 약속하지 않는다 — `html`·`body`에 테마별 `background-color`를 실제 표면색으로 선언하고(투명·그라디언트만 두지 않는다), 상단 고정 헤더가 있으면 iPhone 실기기의 라이트·다크에서 툴바와 어울리는지 확인한다. 사용자 토글이 시스템 선호와 다를 수 있으면 토글 때 스크립트로 `content`와 루트 배경을 함께 갱신한다. 4. **이미지·일러스트를 테마별로 본다.** 흰 바탕을 품은 PNG·스크린샷은 다크 위에서 흰 상자가 된다. `