HEX, RGB, HSL과 새로 등장한 색 공간들
CSS의 색은 세 개 또는 네 개의 숫자에 그것을 읽는 규칙, 즉 색 공간을 더한 것입니다. 화면에서 고르지 않아 보이는 팔레트는 대개 시각이 동작하는 방식과 맞지 않는 공간에서 단계를 나눈 결과입니다.
HEX와 RGB는 같은 숫자입니다
#3B82F6은 16진수로 표기한 3바이트입니다. 3B는 59, 82는 130, F6은 246이므로 rgb(59, 130, 246)은 같은 색을 10진수로 쓴 것이며, 색 변환기는 손실 없이 둘 사이를 오갑니다.
세 자리 축약형은 각 자리를 0으로 채우는 것이 아니라 그대로 한 번 더 복사하므로, #F80은 #F08000이 아니라 #FF8800으로 펼쳐집니다. 축약형을 가질 수 있는 색은 4,096가지뿐입니다. #RGBA는 같은 방식으로 알파 자리를 하나 더하고 #RRGGBBAA는 네 바이트를 모두 적습니다. 이 알파 바이트는 0부터 255까지의 범위이며(1A는 10%, 80은 50%, CC는 80%), CSS의 다른 알파 값은 모두 0부터 1까지입니다. 최신 문법은 공백으로 구분한 성분과 슬래시 뒤의 알파도 받아들입니다. rgb(59 130 246 / 50%)처럼 씁니다.
이 숫자들은 빛의 양이 아닙니다. sRGB는 각 채널을 약 2.2의 전달 곡선을 통해 인코딩하므로, 128은 255가 내는 빛의 절반이 아니라 약 21.6%를 냅니다. 그리고 원시 값으로 계산한 모든 혼합은 그 곡선을 그대로 물려받습니다.
HSL, HSV, 그리고 명도가 아닌 명도
HSL은 같은 RGB 큐브를 원기둥으로 재배열한 것입니다. 색상은 각도(0은 빨강, 120은 초록, 240은 파랑)이고, 채도는 중앙의 회색 축에서 떨어진 거리이며, 명도는 0에서 순수한 색상인 50%를 거쳐 흰색인 100%까지 이어집니다. HSV는 명도 대신 명채도를 쓰는데, 그 꼭대기는 흰색이 아니라 순수한 색상이므로 밝은 색조는 채도를 낮춰서 만듭니다. 둘 다 지각에 대해서는 아무것도 모릅니다.
| 색 | HSL | 상대 휘도 | OKLCH 명도 |
|---|---|---|---|
#FFFF00 | hsl(60 100% 50%) | 0.928 | 0.97 |
#0000FF | hsl(240 100% 50%) | 0.072 | 0.45 |
둘 다 명도 50%라고 주장하지만, 한쪽이 내는 빛은 다른 쪽의 약 13배입니다. 모든 색상을 50%에 고정한 토큰 목록은 무게가 뒤죽박죽인 것처럼 읽힙니다.
색상과 채도를 고정한 채 HSL 명도만 단계별로 나누는 방식도 같은 이유로 실패하며, 이유가 하나 더 있습니다. 채도는 해당 명도에서 쓸 수 있는 여유를 기준으로 측정되므로 50% 단계가 가장 화려하고 그 양쪽은 전부 흐려집니다. 게다가 색상 각도는 그대로인데 지각되는 채도는 무너지며, 중간 단계가 탁해 보이는 원인이 바로 이것입니다. 그 단계들은 기본 색을 어둡게 한 것이 아니라, 아무도 정하지 않은 만큼 회색이 섞인 기본 색입니다.
노랑이 이 문제를 가장 심하게 보여줍니다. HSL 명도를 낮추면 노랑은 그대로 올리브색으로 걸어 들어갑니다. 그 색상 각도에서 어두운 노랑은 곧 올리브색이기 때문이며, 노랑으로 유지하려면 내려가는 동안 색상을 주황 쪽으로 돌려야 합니다. 파랑은 정반대의 문제를 안고 있습니다. 휘도가 워낙 낮아서 쓸 만한 변주가 전부 50% 위쪽에 몰려 있습니다.
LCH와 OKLCH
LCH는 색 대응 실험을 토대로 만든 CIE Lab의 극좌표 형태이며, 0에서 100까지의 명도, 채도(고정된 최댓값이 없음), 색상 각도로 이루어집니다. OKLCH는 Oklab 위에 얹은 같은 구조로, Lab이 가진 것으로 알려진 색상 이동, 특히 파랑이 밝아지면서 보라 쪽으로 흘러가는 현상을 바로잡습니다.
.a { color: oklch(0.62 0.21 260); } /* L 0 to 1, C about 0 to 0.37, H in degrees */
.b { color: oklch(62% 0.21 260 / 50%); } /* percentages and alpha both allowed */
.c { color: lch(52% 82 285); } /* L runs 0 to 100 here */
지각적 균일성이란 수치상 같은 거리가 지각상으로도 거의 같은 거리라는 뜻입니다. 명도를 0.08씩 올린 램프는 균등한 단계로 보이고, 하나의 명도에 고정된 색상들의 목록은 하나의 줄로 읽히며, 명도를 바꿔도 색상은 지정한 자리에 그대로 남습니다. 보간에도 이 공간을 쓸 수 있으며 linear-gradient(in oklch, #0000FF, #FFFF00)처럼 씁니다. 한 가지 주의할 점이 있습니다. OKLCH의 명도는 HSL보다 체감 밝기에 훨씬 가깝지만 WCAG의 휘도 공식은 아니므로, 텍스트 조합은 여전히 대비 검사기를 거쳐야 합니다.
색역과 클리핑
색역은 한 공간이 표현할 수 있는 색의 집합입니다. sRGB가 오랫동안 기본이었고, Display P3는 눈에 보이는 색의 약 4분의 1을 더 담아내며 그 이득의 거의 전부가 진한 빨강과 초록에 몰려 있습니다. OKLCH의 채도에는 상한이 없으므로 어떤 화면도 표시할 수 없는 색을 쓰기가 쉽습니다.
@supports (color: color(display-p3 1 1 1)) {
.vivid { background: color(display-p3 0.9 0.1 0.14); }
}
채널별 클리핑은 색역을 벗어난 색을 각 채널을 독립적으로 한계값에 고정해서 되돌리는데, 이 과정에서 색상이 틀어지고 이웃한 색조들이 하나의 값으로 뭉개져 그라데이션에 띠가 생기고 디테일이 사라집니다. CSS Color 4는 대신 매핑을 씁니다. 명도와 색상은 유지한 채, 색이 색역 안에 들어올 때까지 OKLCH에서 채도를 낮춥니다.
선명한 사진의 스크린샷이 밋밋해 보일 수 있는 이유가 이것입니다. 사진에는 P3 프로파일이 붙어 있고 디스플레이는 그것을 온전히 보여주지만, sRGB로 변환해 내보낸 결과물은 그 채도 높은 픽셀들을 더 작은 부피 안으로 밀어 넣습니다. 그리고 빨강과 초록이 이동해야 할 거리가 가장 멉니다.
알파 합성
반투명한 색이 배경 위에 놓이면 채널별로 계산되어 세 번째 불투명한 색이 만들어집니다.
result = alpha * source + (1 - alpha) * backdrop
흰 바탕 위의 50% 검정은 (127.5, 127.5, 127.5), 즉 #808080이 됩니다. "검정을 불투명도 50%로 하면 회색"이라는 말이 나온 것은 이 우연 때문이며, 그 말은 흰 바탕에서만 성립하고 그 밖의 어디에서도 성립하지 않습니다. 같은 검정을 #3B82F6 위에 얹으면 대략 #1E417B라는 짙은 파랑이 나오고, 사진 위에 얹으면 픽셀마다 다른 색이 나옵니다. 반투명한 검정은 회색이 아니라 어둡게 만드는 연산자입니다.
합성은 인코딩된 값 위에서 이루어지므로, 그 #808080의 상대 휘도는 0.5가 아니라 약 0.216입니다. 선언된 색이 아니라 합성된 HEX 값을 측정하십시오. 알파도 기대만큼 누적되지 않습니다. 50%짜리 두 겹은 100%가 아니라 75%를 덮습니다. 두 번째 겹은 첫 번째가 통과시킨 부분에만 작용하기 때문입니다.
밝은 색조, 어두운 색조, 탁한 색조, 그리고 배색
| 용어 | 전통적인 방법 | OKLCH에서는 |
|---|---|---|
| Tint | 기본 색에 흰색을 더함 | L을 올리고 C를 약간 낮춤 |
| Shade | 기본 색에 검정을 더함 | L을 낮춤 |
| Tone | 기본 색에 회색을 더함 | L은 고정한 채 C를 낮춤 |
배색은 색상 각도의 오프셋입니다. 배색은 강조색을 골라줄 뿐, 시스템을 만들어 주지는 않습니다.
| 배색 | 오프셋 | 어울리는 용도 |
|---|---|---|
| 보색 | +180° | 지배적인 색상에 맞선 강조색 하나. 다만 양쪽 모두 채도가 높으면 눈이 어른거립니다 |
| 유사색 | ±30° | 차분한 묶음, 배경, 같은 계열 안의 상태 표현 |
| 삼색 | +120°, +240° | 차트 계열처럼 범주를 나누는 집합 |
RGB 색상환 위의 오프셋은 지각적으로 균등하지 않습니다. 그 색상환은 초록에 지나치게 넓은 호를 할애하기 때문입니다. 그러니 계산으로 얻은 배색은 초안으로 여기고 눈으로 조정하십시오. 기본 색 하나에서 밝은 색조, 어두운 색조, 배색을 만들어 주는 팔레트 생성기는 그 초안을 빠르게 화면에 올려줍니다.
중립색 램프는 따로 결정해야 합니다. 빨강과 초록과 파랑이 같은 순수한 회색은 색이 들어간 인터페이스 옆에서 생기 없고 어딘가 지저분해 보입니다. 중립색에는 브랜드 색상 근처로 아주 약간의 채도, OKLCH 기준으로 대략 0.005에서 0.02 정도를 주십시오. 60°에서 90° 부근의 따뜻한 회색은 종이처럼 읽히고, 250° 부근의 차가운 회색은 기술적인 인상을 줍니다. 가장 밝은 단계로 갈수록 그 채도를 줄여야 배경이 물든 것처럼 보이지 않습니다.
색각 이상
남성 약 12명 중 1명, 여성 약 200명 중 1명이 색각 이상을 가지고 있습니다. 그 대부분은 적록 계열이며 그중에서도 녹색약이 압도적으로 많습니다. 청황 계열인 제3색각 이상은 1만 명 중 1명이 안 됩니다.
인터페이스에서 실제로 문제가 되는 혼동은 명도가 비슷한 빨강과 초록, 초록과 갈색 및 주황, 보라와 파랑, 분홍과 회색입니다. 제1색각 이상에서는 빨강이 더 어두워지기까지 하므로, 긴급함을 알리려고 고른 빨강이 화면에서 가장 눈에 띄지 않는 요소가 될 수 있습니다.
따라서 색이 의미를 전달하는 유일한 수단이 되어서는 안 됩니다. 모든 상태에 레이블이나 아이콘, 모양, 위치를 함께 붙이고, 짝을 이루는 상태는 색상뿐 아니라 명도도 다르게 하십시오. OKLCH 명도가 같은 빨강과 초록은 가능한 조합 중 최악이기 때문입니다. 스크린샷을 흑백으로 변환해 보면 그런 충돌을 몇 초 만에 찾아낼 수 있습니다.