안녕하세요! 프론트엔드 개발 공부에 열중하고 계시는군요. 웹 디자인에서 '색상(Color)'을 다루는 것은 단순히 미적인 요소를 넘어 사용자 경험을 결정짓는 아주 중요한 부분입니다. 특히 요즘처럼 다크 모드가 기본이 되고, 고해상도 디스플레이가 보급되는 환경에서는 CSS로 색상을 어떻게 정의하고 다루는지 깊이 이해할 필요가 있습니다.
과거에는 red나 #FF0000 같은 원시적인(?) 방식만 썼다면, 이제는 사람의 눈에 가장 자연스럽게 보이는 lch()나 동적으로 색상을 섞어주는 color-mix() 같은 마법 같은 함수들이 등장했습니다.
오늘 함께 살펴볼 문서는 바로 이 방대한 'CSS 색상 값(CSS color values)'에 대한 완벽 가이드입니다. 실무에서 바로 써먹을 수 있는 팁들과 함께 아주 쉽고 재밌게 구어체로 설명해 드릴게요!
CSS에서 색상을 표현하려면 "색"이라는 아날로그적인 개념을 컴퓨터가 사용할 수 있는 디지털 형태로 변환할 방법을 찾아야 합니다. 이 작업은 보통 색상을 여러 가지 구성 요소(component)로 쪼개는 방식으로 이루어집니다. 예를 들어, 섞어 쓸 다양한 원색들의 양을 수치화하거나 밝기(brightness)와 색조(hue)를 숫자로 나누는 식이죠. 이렇게 정의된 '색상 모델(color models)'은 어떤 기기에서 렌더링하더라도 색상이 동일하게 보이도록 보장해 줍니다.
색상 모델(color model)이란 숫자를 사용해 색상을 표현하는 수학적 모델입니다. 색상 모델은 특정 '색상 공간(color space)' 내에서 사용 가능한 색상들을 어떻게 만들어낼지 설명합니다.
RGB는 웹을 위해 만들어진 최초의 색상 모델이었습니다. 이 RGB 색상 모델의 sRGB 색상 공간(표준 빨강, 초록, 파랑 색상 공간)은 1996년에 컴퓨터 모니터와 웹을 위해 만들어졌습니다. 색상 공간(color space)이란 어떤 색상을 일관되게 설명할 수 있도록 색상들을 묶어놓은 시스템입니다. 만약 여러분이 어떤 색상을 두 개의 서로 다른 색상 공간 사이에서 변환(transform)하더라도, 우리 눈에는 두 색상이 완벽하게 똑같아 보여야만 합니다.
초창기 모니터들은 표현할 수 있는 색상의 수가 매우 제한적이었고, CSS 색상 역시 그 한계에 묶여 있었습니다. 하지만 기술이 발전하면서 그 한계도 점점 확장되었죠. 현대의 기기들은 더 이상 RGB에만 얽매여 있지 않습니다. 이제 우리는 하드웨어의 한계가 아닌 '인간의 지각(human perception)'을 기반으로 한 색상 모델도 갖게 되었고, 이를 통해 훨씬 더 넓은 범위의 색 영역(gamut)을 표현할 수 있게 되었습니다. 현재 우리는 CSS에서 아주 다양한 방법으로 색상을 묘사할 수 있으며, 그 선택지는 계속해서 늘어나고 있습니다.
이 가이드는 다양한 <color> 값의 타입들을 소개합니다. 더 깊이 있는 내용은 아래에 제공된 참조 링크들을 확인해 보세요.
웹은 숫자 대신 키워드(단어)를 사용해 색상을 묘사할 수 있도록 표준 색상 이름 세트를 정의해 두었습니다. 이 방법은 아주 간단하긴 하지만 꽤 제한적인 접근 방식입니다. 여러분이 정확히 원하는 그 오묘한 색상을 100% 대변해 주는 키워드가 없을 수도 있으니까요.
색상 키워드에는 red, blue, orange 같은 표준 원색과 보조색, black부터 white까지 이어지는 무채색 계열(darkgray, lightgrey 포함), 그리고 lightseagreen, cornflowerblue, rebeccapurple 같은 다양하게 혼합된 색상들이 포함됩니다. 이렇게 이름이 붙은 색상(Named colors)들은 RGB 모델을 사용하며 sRGB(srgb) 색상 공간에 연결되어 있습니다.
현재 160개가 넘는 이름 붙은 색상들이 존재합니다. 그중에는 특별히 눈여겨볼 만한 키워드들도 있는데요. transparent는 완전히 투명한 색상을 설정하고, currentColor는 현재 요소의 CSS color 속성(글자색) 값을 그대로 가져와서 설정합니다. 또한, 사용자나 브라우저, 운영 체제가 설정해 둔 기본 테마 색상을 그대로 반영하는 accentcolortext나 buttonface 같은 <system-color> 키워드들도 있습니다.
모든 색상 키워드는 대소문자를 구분하지 않습니다(case-insensitive). 색상 키워드에 대한 더 자세한 정보는 <named-color> 데이터 타입을 확인하세요.
💡 강사 팁:
실무에서는currentColor가 정말 유용합니다! SVG 아이콘의fill속성이나border-color에currentColor를 주면, 부모 요소의 글자색이 바뀔 때 아이콘 색상이나 테두리 색상이 알아서 글자색을 따라갑니다. 다크 모드를 구현할 때 코드를 엄청나게 줄여주는 일등 공신이죠!
CSS에서 RGB 색상을 빨강(Red), 초록(Green), 파랑(Blue) 성분으로 정의하는 가장 대표적인 방법은 두 가지입니다. 바로 16진수(hexadecimal) 표기법과 rgb() 함수죠. 키워드 색상과 마찬가지로 이 방법들 역시 RGB 모델을 사용하며 sRGB 색상 공간에 속합니다. 하지만 키워드와는 비교도 안 될 정도로 훨씬 더 넓은 범위의 색상을 세밀하게 지정할 수 있습니다.
16진수(hex) 문자열 표기법은 RGB 색상의 각 구성 요소(빨강, 초록, 파랑)를 16진수 값으로 표현합니다. 여기에 투명도(불투명도)를 나타내는 네 번째 구성 요소인 알파(alpha) 채널을 포함할 수도 있습니다.
16진수 문자열 표기법으로 색상을 적을 때는 항상 우물 정자 "#" 기호로 시작해야 합니다. 그 뒤에 색상 코드를 나타내는 16진수 숫자들이 따라옵니다. 이 문자열 역시 대소문자를 구분하지 않습니다.
"#rrggbb"
빨강 성분이 16진수 0xrr, 초록 성분이 0xgg, 파랑 성분이 0xbb인 완전히 불투명한 색상을 지정합니다.
"#rrggbbaa"
위와 동일하게 빨강, 초록, 파랑을 지정하고, 알파(투명도) 채널을 0xaa로 지정합니다. 이 값이 낮을수록 색상은 더 투명(translucent)해집니다.
"#rgb"
각 색상 성분을 한 자리 16진수로 지정합니다. (예: #f00)
"#rgba"
각 색상 성분과 알파 채널을 한 자리 16진수로 지정합니다.
위에서 보시듯 빨강, 초록, 파랑 성분은 각각 0(00)부터 255(FF) 사이의 숫자를 나타내는 두 자리 16진수 값으로 표현할 수도 있고, 0(0)부터 15(F) 사이의 숫자를 나타내는 한 자리 16진수 값으로 표현할 수도 있습니다.
참고:
위 설명에 등장하는0x는 프로그래밍에서 이 숫자가 16진수 정수임을 나타내는 표시입니다. 16진수는 숫자(0-9)와 문자a–f(또는A–F)를 포함할 수 있습니다. 대소문자는 값에 영향을 주지 않으므로0xa=0xA=10이며,0xf=0xF=15입니다.
아래 두 개의 hex 색상은 완벽히 똑같은 값입니다. 둘 다 새빨간 색이죠!
color: #ff0000;
color: #f00;
주의할 점은, 모든 구성 요소는 반드시 동일한 자릿수로 지정되어야 한다는 것입니다. 만약 한 자리 표기법을 사용한다면, 브라우저가 화면에 그릴 때 각 자리의 숫자를 두 번씩 반복해서 계산합니다. 즉, "#D"라고 쓰면 실제로는 "#DD"로 처리되는 식이죠.
이 빨간색을 25% 정도의 투명도를 가진(불투명도 25% 인) 색상으로 만들고 싶다면, 아래처럼 맨 뒤에 알파 채널 값을 추가해 주면 됩니다.
color: #ff000044; /* 44(hex)는 십진수로 약 68. (68 / 255 ≈ 0.26) */
color: #f004; /* 4(hex)를 두 번 반복하면 44. 동일한 결과! */
16진수 색상에 대한 더 자세한 정보는 <hex-color> 데이터 타입 문서를 참고하세요.
웹사이트를 만들다 보면 사용자가 직접 색상을 선택하게 해야 할 때가 꽤 많습니다. 사용자가 테마를 마음대로 커스터마이징하는 UI, 그림 그리기 앱, 텍스트 색상을 바꿀 수 있는 에디터, 폴더나 아이템에 색상 라벨을 달아주는 기능 등이 대표적이죠. 이럴 때 <input> 태그의 type 속성을 "color"로 지정해주면, 아주 멋진 '색상 선택기(color picker)' 컨트롤을 화면에 렌더링할 수 있습니다.
아래 예제를 한번 직접 만져보세요. 색상을 선택하면 박스의 테두리 색상(border-color)이 선택한 색상으로 즉시 바뀌고, 그 16진수 값도 텍스트로 출력됩니다.
<div id="box">
<label for="colorPicker">Border color:</label>
<input type="color" value="#8888ff" id="colorPicker" />
<output></output>
</div>
이 HTML 코드는 색상 선택기(Color picker)를 담고 있는 박스를 만듭니다. <label> 태그로 이름을 달아주었고, 나중에 자바스크립트로 선택된 색상값을 출력할 빈 <output> 요소도 준비해 두었죠.
여기서 아주 중요한 사실 하나! 이 <input type="color">에서 뱉어내는 값은 무조건 16진수(hexadecimal) 문자열입니다.
/* CSS 스타일링 */
#box {
width: 500px;
height: 100px;
border: 5px solid rgb(245 220 225);
padding: 4px 6px;
font: 16px "Lucida Grande", "Helvetica", "Arial", sans-serif;
}
이제 JavaScript를 붙여서 작동하게 만들어봅시다. <input type="color"> 요소에 두 가지 이벤트 리스너를 달아줄 거예요.
const colorPicker = document.querySelector("#colorPicker");
const box = document.querySelector("#box");
const output = document.querySelector("output");
// 초기 상태일 때 선택기의 값으로 테두리 색상 세팅
box.style.borderColor = colorPicker.value;
// 사용자가 색상 스펙트럼에서 마우스를 드래그하며 색을 이리저리 바꿀 때마다(input) 발생
colorPicker.addEventListener("input", (event) => {
box.style.borderColor = event.target.value;
});
// 사용자가 색상 선택을 완전히 끝내고 창을 닫았을 때(change) 발생
colorPicker.addEventListener("change", (event) => {
output.innerText = `${colorPicker.value}`;
});
MDN Playground에서 실행해보기 (Live Sample)
input 이벤트는 요소의 값이 변할 때마다 쉴 새 없이 발생합니다. 즉, 사용자가 컬러 피커 안에서 색을 이리저리 조정하는 족족 이벤트가 들어오죠. 우리는 이 이벤트가 올 때마다 박스의 테두리 색상을 바로바로 업데이트해 줍니다.
반면 change 이벤트는 사용자가 색상 선택을 최종적으로 확정 지었을 때 단 한 번 발생합니다. 우리는 이 타이밍에 맞춰 <output> 요소에 선택된 16진수 문자열 값을 찍어줍니다.
💡 강사 팁:
input과change이벤트의 차이를 아는 것은 프론트엔드 면접의 단골 질문 중 하나입니다! 실시간 반응이 필요하면input을, 최종 결과만 서버에 보내거나 저장하고 싶다면change를 쓰시면 됩니다.
RGB(Red/Green/Blue) 함수 표기법은 16진수 표기법과 마찬가지로 빨강, 초록, 파랑 성분(선택적으로 불투명도를 위한 알파 채널)을 사용해 색상을 표현합니다. 하지만 의미를 알기 힘든 문자열 대신 CSS 함수인 rgb()를 사용하죠. 이 함수는 빨강, 초록, 파랑 값과 선택적인 알파 값까지 총 3개 또는 4개의 파라미터를 받습니다.
각 파라미터에 들어갈 수 있는 유효한 값은 다음과 같습니다:
red, green, 그리고 blue
각각 0부터 255까지의 <number>(숫자)이거나, 0%부터 100%까지의 <percentage>(퍼센트)여야 합니다. none이라는 키워드를 쓸 수도 있는데, 이 경우엔 0과 동일하게 취급됩니다.
alpha
알파 채널은 0%(완전 투명)에서 100%(완전 불투명) 사이의 퍼센트 값으로 적거나, 0.0(0%와 동일)에서 1.0(100%와 동일) 사이의 소수로 적을 수 있습니다.
예를 들어, 불투명도가 50%인 밝은 빨간색은 rgb(255 0 0 / 50%) 또는 rgb(100% 0 0 / 0.5)로 표현할 수 있습니다.
과거에는 rgb(255, 0, 0)처럼 쉼표(,)를 썼지만, 최신 CSS 스펙에서는 쉼표 없이 띄어쓰기로만 구분하고 투명도 앞에 슬래시(/)를 긋는 방식을 권장합니다.
RGB 함수 표기법에 대한 더 자세한 정보는 rgb() 색상 함수 문서를 참고하세요.
지금까지 살펴본 RGB 방식은 직관적이지 않습니다. "빨강 120, 초록 40, 파랑 200을 섞으면 무슨 색이 나오지?" 하고 머릿속에 바로 그려지시나요? 쉽지 않죠.
그래서 등장한 것이 바로 <hue> 구성 요소를 가진 색상 함수들입니다. 이 함수들은 색상환(color wheel)의 특정한 각도(<angle>)를 이용해 색을 찾습니다.
여기에 속하는 함수로는 sRGB 색상 모델을 쓰는 hsl()과 hwb(), 그리고 CIELab 기반의 lch(), OKLab 기반의 oklch() 함수가 있습니다. 이 함수들은 빨강, 주황, 노랑, 초록, 파랑 등 우리가 일상에서 느끼는 색의 차이를 '색조(hue)'라는 각도로 조절할 수 있게 해주기 때문에, 인간의 머리로 이해하기에 훨씬 직관적입니다.
hsl()은 브라우저에서 최초로 지원되기 시작한 색조(hue) 기반의 CSS 색상 함수입니다. rgb()보다 훨씬 다루기 쉬운데요, 빨강/초록/파랑 수치를 이리저리 바꾸는 것보다 단순히 색조(Hue, h), 채도(Saturation, s), 명도(Lightness, l) 값을 조절해서 색이 어떻게 변하는지 파악하는 게 훨씬 직관적이기 때문입니다. 게다가 포토샵(Photoshop)에 있는 HSB(색조, 채도, 밝기) 컬러 피커와 매우 흡사해서, 디자이너나 개발자들에게 아주 친숙한 방식이기도 합니다.
hsl()과 hwb() 함수는 모두 원기둥(cylindrical) 모형을 기반으로 합니다. 먼저 색조(Hue)는 원형의 색상환(color wheel) 위에서 특정 각도(<angle>)로 색을 정의합니다. 아래 그림이 바로 HSL 색상 원기둥입니다.
채도(Saturation)는 퍼센트 값으로, 완전한 흑백(회색조) 상태부터 해당 색조가 가질 수 있는 가장 선명한 상태 사이에서 색이 얼마나 진한지를 나타냅니다. 명도(Lightness) 값이 커질수록 색은 가장 어두운색(검은색)에서 가장 밝은색(흰색)으로 서서히 변해갑니다.
이 원기둥에서 색조(H)의 각도는 0°에서 빨간색으로 시작해 노랑, 초록, 청록, 파랑, 자홍을 거쳐 다시 360°에서 빨간색으로 되돌아옵니다. 이 값은 deg(도), rad(라디안), grad(그라디안), turn(턴) 등 CSS가 지원하는 모든 <angle> 단위로 작성할 수 있습니다. 색조는 색상의 뼈대(base shade)를 결정할 뿐, 그 색이 얼마나 선명하고 탁한지, 얼마나 밝고 어두운지는 제어하지 못합니다.
채도(S)는 그 색조가 얼마나 강하게 뿜어져 나오는지를 퍼센트로 나타냅니다. 100%면 물감을 생으로 짜낸 것처럼 가장 선명한 색이고, 0%면 색기가 쫙 빠진 무채색(회색조)이 됩니다. 명도(L)는 완전한 검정(0%)부터 완전한 흰색(100%) 사이에서 색의 밝기를 결정합니다. 슬래시(/) 뒤에 선택적으로 투명도를 위한 알파 채널도 추가할 수 있죠.
자, 그럼 HSL 표기법의 실제 코드와 결과를 볼까요?
table {
border: 1px solid black;
border-collapse: collapse;
}
th, td {
border: 1px solid black;
padding: 4px 6px;
}
th {
background-color: hsl(0 0% 75%); /* 색조 0, 채도 0%, 명도 75% -> 밝은 회색 */
}
// 자바스크립트로 여러 HSL 색상을 생성해서 표에 그려봅니다
const colors = [
"hsl(90deg 0% 50%)", /* 채도 0%이므로 중간 회색 */
"hsl(90 100% 50%)", /* 90도는 초록색 계열. 아주 쨍한 초록색 */
"hsl(0.15turn 50% 75%)", /* turn 단위 사용. 부드러운 연두색 */
"hsl(0.15turn 90% 75%)", /* 채도를 90%로 올려서 더 선명하게 */
"hsl(0.15turn 90% 50%)", /* 명도를 50%로 낮춰서 더 진하고 쨍하게 */
"hsl(270deg 90% 50% / 50%)", /* 270도는 보라색. 50% 반투명 처리 */
];
// ... DOM에 요소를 추가하는 로직 생략
MDN Playground에서 실행해보기 (Live Sample)
참고:
만약 색조(Hue) 값에 단위를 적지 않고 숫자만 덜렁 적는다면, 브라우저는 알아서 각도(deg)로 간주합니다.
hwb() 색상 함수도 hsl()과 완전히 동일한 색상환 각도 시스템을 사용합니다(0deg가 빨간색인 것도 똑같죠). 차이점이 있다면, hsl()이 채도(S)와 명도(L)를 썼던 반면, hwb()는 하양(Whiteness, W)과 검정(Blackness, B)을 혼합하여 색을 만듭니다.
물감놀이를 한다고 생각해 보세요. 원하는 색상(Hue) 물감을 쭉 짠 다음, 거기에 흰색 물감과 검은색 물감을 원하는 양만큼 섞어서 색을 조색하는 방식입니다. 굉장히 직관적이죠!
W와 B의 값은 0%부터 100% (또는 0에서 1 사이의 소수) 범위를 가집니다. 만약 섞어 넣은 하양(W)과 검정(B)의 총합이 100%(또는 1)를 넘어버리면, 물감의 원색은 싹 다 덮이고 그냥 회색(grey)이 되어버립니다. 이는 hsl()에서 채도(s)를 0%로 맞췄을 때와 동일한 원리입니다. 물론 슬래시(/) 뒤에 투명도 알파값을 추가할 수도 있습니다.
HWB 표기법 사용 예시를 보시죠:
/* 전부 똑같이 라임 그린(연두색)을 가리키는 코드들입니다. */
hwb(90 10% 10%)
hwb(90 50% 10%) /* 흰색을 왕창 섞어서 아주 밝고 연한 연두색이 됩니다 */
hwb(90deg 10% 10%)
hwb(1.5708rad 60% 0%)
hwb(.25turn 0% 40%)
/* 투명도를 추가한 라임 그린 */
hwb(90 10% 10% / 0.5)
hwb(90 10% 10% / 50%)
아래 예제는 앞서 hsl() 예제에서 사용했던 것과 똑같은 색조(Hue) 각도를 사용하지만, 채도와 명도 대신 hwb()를 통해 하양과 검정을 섞어서 색을 만들고 있습니다.
const colors = [
"hwb(90deg 50% 50%)", /* 하양 50% + 검정 50% = 그냥 회색! */
"hwb(90 0% 0%)", /* 하양 검정 하나도 안 섞음 = 100% 순수한 초록 원색 */
"hwb(0.15turn 25% 0%)",/* 흰색만 살짝 섞은 밝은 연두 */
"hwb(0.15turn 10% 25%)",/* 흰색 조금, 검은색 많이 -> 탁하고 진한 연두 */
"hwb(1turn 10% 65%)", /* 1turn(360도=빨강)에 검은색을 왕창 섞음 -> 어두운 갈색 느낌 */
"hwb(270deg 75% 10%)", /* 270도(보라)에 흰색을 왕창 섞음 -> 연한 라벤더색 */
];
MDN Playground에서 실행해보기 (Live Sample)
hsl()과 hwb()는 확실히 직관적이지만, 아주 치명적인 단점을 하나 안고 있습니다. 이 함수들에서 채도를 100%로 꽉 채우고 명도를 50%로 고정한 상태(hsl(<angle> 100% 50%) 또는 hwb(<angle> 0% 0%))에서 색조 각도만 빙글빙글 돌린다고 가정해 봅시다. 논리적으로는 모든 색이 '동일한 밝기(lightness)'를 가져야 합니다.
하지만 실제 사람의 눈과 모니터는 그렇게 동작하지 않습니다.
형형색색 쨍한 파란색(hsl(240deg 100% 50%)) 위에 흰색 글씨를 쓰면 글씨가 아주 선명하게 잘 보입니다. 하지만 완전히 쨍한 노란색(hsl(60deg 100% 50%)) 위에 똑같이 흰색 글씨를 쓴다면 어떨까요? 글씨가 아예 안 보일뿐더러 사용자 눈이 부셔서 쳐다보기도 힘들 겁니다. 분명 똑같은 명도 50%를 줬는데 왜 이런 차이가 날까요?
기존의 HSL 함수에서 말하는 '명도(lightness)'는 수학적인 상대값일 뿐, 인간의 눈이 실제로 느끼는 '지각적 밝기(perceived lightness)'를 반영하지 못했기 때문입니다. 현실 세계에서 빨강, 노랑, 파랑은 각자가 뿜어낼 수 있는 최대 채도와 밝기가 저마다 다릅니다.
"웹사이트의 테마 색상을 바꿀 때(색조 각도만 살짝 돌릴 때), 글씨가 갑자기 안 보이게 되는 대참사 없이 알아서 사람 눈에 똑같은 밝기로 보이게 할 순 없을까?"
이 꿈 같은 기능을 실현해 주는 것이 바로 CIELAB과 Oklab 색상 공간 기반의 함수들입니다!
이 두 색상 공간은 인간이 볼 수 있는 모든 색의 범위를 전부 커버합니다. CIE Lab 색상 함수에는 lch()와 lab()가 있고, Oklab 색상 함수에는 oklch()와 oklab()가 있습니다.
이 모델들의 가장 큰 존재 이유는 바로 '균일성(Uniformity)'입니다. 색상 공간 내에서 수학적으로 똑같은 거리를 이동했다면, 사람의 눈으로 봤을 때도 딱 그만큼 똑같은 수준으로 색이 달라 보여야 한다는 원리죠. 특히 Oklab은 CIELAB과 같은 형태의 모델이지만 수치적인 최적화를 빡세게 거쳐서 탄생한 녀석이라, CIELAB보다 훨씬 더 정밀하고 정확하게 사람의 지각을 반영합니다. 색조의 변화가 기가 막히게 부드럽고 균일하죠.
이 중에서 lch()와 oklch()는 명도(Lightness, L), 채도(Chroma, C), 색조(Hue, H)를 사용합니다.
반면, lab()과 oklab()은 작동 방식이 약간 달라서, 명도(L)와 빨강/초록 축(a), 노랑/파랑 축(b)이라는 직교 좌표계를 사용합니다.
어쨌든 이 함수들이 가진 최고의 무기는, 여기서 말하는 "명도(L)"가 가짜 수학적 밝기가 아니라 인간의 눈이 실제로 지각하는 밝기(perceived lightness)라는 점입니다!
이 함수들의 색조(H) 각도 역시 sRGB와 마찬가지로 숫자, 각도, 혹은 none(0deg와 동일)으로 표현됩니다. 하지만 각도를 돌려봤을 때 뽑혀 나오는 색상의 위치는 서로 다릅니다. sRGB, CIELAB, Oklab은 제각기 자신만의 우주(색상 공간)를 가지고 있기 때문이죠.
아래 예제에서 각 색상 모델별로 색조 각도를 0deg부터 360deg까지 돌렸을 때 나오는 그라데이션의 차이를 직접 눈으로 확인해 보세요. 특히 "Toggle greyscale" 체크박스를 눌러서 흑백 모드로 전환해 보면, 기존 sRGB가 밝기가 얼마나 들쭉날쭉한지, 그리고 Oklch가 밝기를 얼마나 완벽하게 유지해 주는지 극명하게 보실 수 있습니다!
<p>sRGB (<code>hsl()</code> and <code>hwb()</code>)</p>
<div id="srgb"></div>
<p>CIE Lab (<code>lch()</code>)</p>
<div id="lch"></div>
<p>OKLab (<code>oklch()</code>)</p>
<div id="oklch"></div>
<p>
<label><input type="checkbox" /> Toggle greyscale</label>
</p>
div:has(~ p input:checked) {
filter: grayscale(100%); /* 체크박스를 누르면 모든 그라데이션을 흑백으로 렌더링합니다 */
}
/* 각 모델별로 명도를 50%, 채도를 100%로 맞춘 상태에서 색조 각도만 0~360도로 돌린 그라데이션 */
#srgb {
background: linear-gradient(
to right,
hsl(0deg 100% 50%),
hsl(90deg 100% 50%),
/* ... 360도까지 생략 */
);
}
#lch {
background: linear-gradient(
to right,
lch(50% 100% 0deg),
lch(50% 100% 90deg),
/* ... 360도까지 생략 */
);
}
#oklch {
background: linear-gradient(
to right,
oklch(50% 100% 0deg),
oklch(50% 100% 90deg),
/* ... 360도까지 생략 */
);
}
MDN Playground에서 실행해보기 (Live Sample)
뒤에 있는 두 개의 그라데이션(lch, oklch)이 sRGB에 비해 스펙트럼 전반에 걸쳐 얼마나 밝기가 고르고 균일한지 눈치채셨나요? 체크박스를 눌러 흑백으로 바꿔보면 그 차이는 더욱 뼈저리게 다가옵니다. sRGB는 검은색과 흰색이 널을 뛰지만, Oklch는 완벽히 일정한 회색 바(bar)를 유지하죠!
그리고 CIE Lab(lch) 중간에 유독 파란색 계열이 이상하게 길게 퍼져있는(spread) 것이 보이실 텐데요. 사실 이건 lch()가 가진 태생적인 버그입니다. 270도에서 330도 사이의 파란색 영역에서 채도와 명도 값이 뒤틀리는 문제가 있었거든요. 이 문제를 완벽하게 고치고 계산식을 최적화해서 나온 최종 진화형이 바로 oklch() 랍니다.
정리하자면, lch()와 oklch()에서의 색조(H)는 각도입니다. 명도(Lightness)는 퍼센트 값으로 쓰거나 소수로 쓸 수 있는데, 0%면 완벽한 검은색이 됩니다.
채도(C, Chroma)는 퍼센트나 숫자로 쓸 수 있으며 "색이 얼마나 꽉 차 있는지"를 나타냅니다. hsl()의 Saturation과 비슷한 역할이죠. 0을 주면 색이 아예 빠진 회색이 됩니다. 이론적으로 채도는 최댓값이 정해져 있지 않은데(unbounded), 실무적으로 100%의 채도는 lch()에서는 150 정도, oklch()에서는 0.4 정도의 숫자 값과 맞먹습니다.
이 친구들도 당연히 맨 마지막에 슬래시(/)를 넣고 투명도 값을 줄 수 있습니다.
아래 예제는 lch()와 oklch()에서 다른 건 다 고정해 두고 '명도(Lightness)' 값만 0%부터 100%까지 10%씩 올려봤을 때 색상이 어떻게 변하는지(점점 하얗게 되는지)를 보여줍니다. 명도가 80% 이상이 되면 너무 밝아지므로 글자색을 어둡게(.dark-text) 바꿔주었습니다.
/* 컨테이너 및 레이아웃 설정 등 */
.dark-text {
color: lch(1% 40 0deg); /* 아주 어두운 빨간색(거의 검정)으로 글씨색 설정 */
}
// 명도(Lightness)를 0부터 100까지 올리면서 박스 배경색을 칠합니다.
const container = document.querySelector(".container");
// lch() 예제
for (let l = 0; l <= 100; l += 10) {
const div = document.createElement("div");
const usedL = l === 0 ? 1 : l === 100 ? 99 : l; // 0과 100은 완전한 흑/백이 되므로 살짝 깎아줍니다
div.textContent = div.style.backgroundColor = `lch(${usedL}% 40 0)`; // 명도만 계속 변함
if (usedL >= 80) div.classList.add("dark-text"); // 밝은 배경엔 어두운 글씨
container.appendChild(div);
}
// oklch() 예제도 동일하게 반복
MDN Playground에서 실행해보기 (Live Sample)
💡 강사 팁:
현시점 프론트엔드 실무에서 테마 시스템을 구축할 때 가장 각광받는 색상 모델이 바로oklch()입니다.
"우리 브랜드의 핵심 색상(Primary Color)에서 명도(L)만 10%씩 깎아서 버튼 Hover 색상이랑 Active 색상 만들어줘!" 라는 디자이너의 요청을oklch를 쓰면 수학적으로 완벽하고 부드럽게 구현해 낼 수 있거든요! 포트폴리오의 CSS 변수에oklch를 도입해 보시면 면접관들이 아주 좋아할 겁니다.
lab() 함수는 CIE Lab* 색상 공간에서 색을 표현하고, oklab() 함수는 OKLab 색상 공간에서 색을 정의합니다. 이 함수들 역시 인간의 눈이 볼 수 있는 모든 색을 표현해 내는데, 둥근 원통형 각도 대신 직교 좌표계(rectangular coordinates)를 씁니다. 즉, 명도(L), 빨강/초록 축을 왔다 갔다 하는 값(a), 노랑/파랑 축을 왔다 갔다 하는 값(b), 그리고 투명도를 사용하죠.
명도(Lightness)는 앞서 설명한 lch, oklch와 완전히 똑같이 작동합니다. (0%는 검정, 100%는 흰색)
재밌는 건 a와 b 값입니다. a 축은 마이너스 쪽으로 갈수록 초록색(-100%), 플러스 쪽으로 갈수록 빨간색(+100%)이 짙어집니다.
lab()에서는 이 값이 -125에서 125 사이의 숫자로 쓰이고, oklab()에서는 -0.4에서 0.4 사이의 숫자로 쓰입니다. (물론 퍼센트로 써도 됩니다.)
이 값들은 이론상 한계가 없는(unbounded) 값이라서 범위를 살짝 넘겨서 적어도 되지만, 모니터가 표현할 수 있는 현실적인 한계치가 존재합니다.
b 값도 똑같은 원리입니다. 마이너스로 갈수록 파란색(-100%), 플러스로 갈수록 노란색(+100%)이 됩니다.
백문이 불여일견이죠. 아래 예제는 명도(50%)를 고정해 두고, lab()에서는 a(빨강/초록) 축의 값을, oklab()에서는 b(노랑/파랑) 축의 값을 서서히 변화시키면서 색이 어떻게 그라데이션을 타는지 보여줍니다.
/* 컨테이너 등 레이아웃 스타일 생략 */
// lab()에서 a축(초록<->빨강)을 -100부터 100까지 변화시켜 봅니다.
for (let a = -100; a <= 100; a += 25) {
const div = document.createElement("div");
div.textContent = div.style.backgroundColor = `lab(50% ${a}% 0)`;
container.appendChild(div);
}
// oklab()에서 b축(파랑<->노랑)을 -0.4부터 0.4까지 변화시켜 봅니다.
for (let b = -4; b <= 4; b++) {
const div = document.createElement("div");
div.textContent = div.style.backgroundColor = `oklab(50% 0 ${b / 10})`;
container.appendChild(div);
}
MDN Playground에서 실행해보기 (Live Sample)
color() 함수색상을 정의할 때 어떤 색상 공간(color space)을 쓸지 아주 명확하게 직접 컨트롤하고 싶다면, color() 함수를 사용할 수 있습니다.
이 함수는 특히 최신 아이폰이나 맥북, 프리미엄 모니터처럼 엄청나게 넓은 색 영역(gamut)을 지원하는 고화질(High-definition) 디바이스들을 타겟팅할 때 유용합니다.
예를 들어, 기존 sRGB 모니터로는 담아낼 수 없는 미친 듯이 쨍한 파란색인 display-p3 0 0 1을 보여주고 싶다고 합시다. 만약 구형 모니터에서 이걸 억지로 그리려고 하면 에러가 나거나 탁하게 변하겠죠.
이럴 땐 @media 쿼리의 color-gamut 기능을 활용해서, 사용자의 하드웨어가 저 짱짱한 색감을 지원하는지 먼저 물어본 뒤에 선별적으로 색상을 적용할 수 있습니다.
.vibrant {
/* 일단 평범한 sRGB의 파란색을 깔아둡니다 */
background-color: color(srgb 0 0 1);
}
/* 사용자의 디스플레이가 P3 색역을 지원하는지 검사합니다! */
@media (color-gamut: p3) {
.vibrant {
/* 지원한다면 눈이 시리도록 쨍한 P3의 파란색으로 덮어씁니다! */
background-color: color(display-p3 0 0 1);
}
}
이 color() 함수를 이해하는 것은 다음에 설명할 '상대 색상(relative colors)'을 다룰 때도 중요합니다. 앞서 배웠던 옛날 방식의 hsl(), hwb(), rgb() 함수들은 눈에 보이는 색의 전체 스펙트럼을 100% 담아내지 못하는 반면, color() 함수는 훨씬 거대한 색 영역을 지원합니다. 그래서 구형 함수들을 이용해 새로운 색을 연산해 내면, 자바스크립트로 그 값을 찍어봤을 때 브라우저는 그걸 color(srgb ...) 포맷으로 변환해서 반환해 주게 됩니다.
rgb(), hsl(), hwb() 등 다양한 색상 포맷들 사이의 변환을 직접 눈으로 보고 싶다면, MDN의 색상 포맷 변환기 도구(color format converter tool)를 써보세요!
지금까지 위에서 나열한 모~~든 색상 함수들은 상대 색상(relative colors)이라는 엄청난 스킬을 구현하는 데 쓰일 수 있습니다. 이 기능을 쓰면 색상을 매번 바닥부터 새로 만들어낼 필요 없이, 이미 존재하는 다른 색상을 기준으로 삼아 새로운 색상을 파생시켜 만들어낼 수 있습니다.
원본 색상 하나만 있으면 그것의 더 밝은 버전, 어두운 버전, 채도를 높인 버전, 반투명한 버전, 심지어 완전히 반전된 버전 등을 CSS 안에서 즉석으로 뚝딱뚝딱 조색해 낼 수 있죠. 팔레트를 만들거나 동적인 테마 컬러 조정을 할 때 그야말로 사기급 성능을 보여줍니다. (자세한 문법은 각 색상 함수 문서의 'relative syntax' 부분을 참고하세요.)
color-mix() 함수color-mix() 함수는 이름 그대로 두 개의 색상을 믹서기에 넣고 갈아버립니다. 위에서 배운 아무 문법이나 써서 두 색상을 넣고, 선택적으로 "이 색은 30%, 저 색은 70% 섞어줘" 하고 비율을 적어주면, 지정된 색상 공간 안에서 두 색을 정확히 섞어낸 최종 결과물 색상을 뱉어냅니다.
light-dark() 함수light-dark() 함수를 사용하면 하나의 속성에 대해 "라이트 모드일 때 쓸 색상"과 "다크 모드일 때 쓸 색상" 두 가지를 한 번에 지정할 수 있습니다. 사용자의 운영체제나 브라우저 설정(또는 개발자의 설정)에 따라 둘 중 하나가 알아서 렌더링 됩니다.
원래는 @media (prefers-color-scheme: dark) 같은 쿼리를 복잡하게 써야만 가능했던 다크 모드 처리를, 코드 한 줄짜리 지름길(shortcut)로 해결하게 해주는 환상적인 녀석입니다.
이 페이지가 도움이 되셨나요?
[Yes][No]
기여하는 방법 알아보기(Learn how to contribute)
이 페이지의 마지막 수정일은 2025년 12월 16일이며, MDN 기여자들에 의해 수정되었습니다.
어떠신가요? 이제 #FF0000 같은 원시적인 코드에서 벗어나 oklch, color-mix, light-dark 같은 최신 CSS 색상 마법들을 포트폴리오에 적극적으로 녹여낼 자신이 생기셨나요? 면접관들이 코드 리뷰를 할 때 이런 최신 트렌드를 활용한 흔적을 본다면 눈이 번쩍 뜨일 겁니다! 파이팅입니다!