안녕하세요! 프론트엔드 개발의 세계에 오신 것을 환영합니다. 오늘 배울 내용은 CSS 색상을 다루는 데 있어 마치 마법 같은 기능인 상대 색상(Relative colors)입니다.
CSS 색상 모듈(CSS colors module)은 상대 색상 구문(relative color syntax)을 정의하고 있습니다. 이 구문을 사용하면 특정 기준이 되는 색상(origin color)을 바탕으로 새로운 CSS <color> 값을 만들어낼 수 있습니다.
기준 색상을 바탕으로 조금 더 밝게, 더 어둡게, 더 채도가 높게, 반투명하게, 혹은 아예 반전된 색상 등을 프로그램적으로 뚝딱 만들어 낼 수 있는 아주 강력한 기능이죠. 이 기능 덕분에 전체 웹사이트의 컬러 팔레트를 구성하고 관리하는 작업이 훨씬 더 스마트하고 효율적으로 변했습니다!
이 글에서는 상대 색상 구문이 무엇인지, 어떤 옵션들이 있는지 살펴보고, 이해를 돕기 위한 다양한 예제들을 함께 분석해 볼 거예요.
상대적인 CSS 색상 값을 만드는 기본 구문 구조는 다음과 같습니다:
color-function(from origin-color channel1 channel2 channel3)
color-function(from origin-color channel1 channel2 channel3 / alpha)
/* color() 함수를 사용할 때는 컬러 스페이스(color space)가 포함됩니다 */
color(from origin-color colorspace channel1 channel2 channel3)
color(from origin-color colorspace channel1 channel2 channel3 / alpha)
상대 색상은 절대 색상(absolute colors)을 만들 때 쓰는 것과 똑같은 색상 함수(color functions)들을 사용하지만, 안에 들어가는 매개변수(parameters)가 조금 다릅니다:
rgb(), hsl() 같은 기본 색상 함수(color-function() 부분)를 씁니다. 어떤 함수를 고를지는 새로 만들어낼 상대 색상(즉, 출력 색상(output color))에 어떤 색상 모델을 쓰고 싶은지에 따라 결정하면 됩니다.from이라는 키워드를 적고, 그다음 우리가 기준으로 삼을 원본 색상(origin color)을 넣어줍니다. 이 원본 색상은 어떤 색상 모델을 사용하든 상관없이 유효한 <color> 값이면 다 됩니다! 심지어 CSS 사용자 지정 속성(CSS custom property, CSS 변수)에 들어있는 값, 시스템 색상, currentColor, 심지어 다른 상대 색상까지도 원본 색상이 될 수 있어요.color() 함수를 사용한다면, 출력될 색상의 colorspace를 적어주어야 합니다.channel1, channel2, channel3 부분). 여기에 어떤 채널이 들어가는지는 여러분이 선택한 색상 함수에 따라 다릅니다. 예를 들어 hsl()을 썼다면 색조(hue), 채도(saturation), 명도(lightness) 값을 차례대로 정의해야 하죠. 각 채널의 값으로는 아예 새로운 값을 줄 수도 있고, 원본과 똑같은 값을 그대로 유지할 수도 있고, 원본 색상의 채널 값을 활용해 계산된 상대적인 값을 줄 수도 있습니다./) 뒤에 <alpha-value> (투명도) 값을 적어줄 수 있습니다. 만약 알파 채널 값을 따로 적지 않으면, 출력 색상의 알파 값은 (100%가 아니라) origin-color가 가지고 있던 원래의 알파 값을 그대로 물려받게 됩니다.브라우저는 이 코드를 보면 먼저 원본 색상을 지금 사용 중인 색상 함수와 호환되는 형태로 변환합니다. 그런 다음, 이 색상을 개별 색상 채널들(원본 색상에 알파 값이 있다면 알파 채널까지)로 잘게 쪼갭니다(destructures). 이렇게 쪼개진 채널 값들은 함수 안에서 우리가 재사용할 수 있도록 알맞은 이름표를 달고 나타납니다!
rgb() 함수 안에서는 r, g, b, alpha라는 이름으로 사용할 수 있고,lab() 함수 안에서는 l, a, b, alpha로, hwb() 함수 안에서는 h, w, b, alpha로 쓸 수 있습니다.💡 강사님의 꿀팁: 문법이 길어 보여도 원리는 단순합니다.
rgb(from 기준색상 r g b / alpha)형식에서,from뒤에 어떤 색에서 가져올지를 적고, 그 색상에서 추출된r,g,b,alpha라는 변수들을 뒤쪽 수식에서 내 마음대로 주무르는 겁니다!
백문이 불여일견이죠. 실제로 작동하는 코드를 보겠습니다. 아래 CSS는 두 개의 <div> 요소에 스타일을 줍니다. 하나는 절대 색상인 red를 그대로 썼고, 다른 하나는 똑같은 red 값을 기준으로 삼아 rgb() 함수로 만든 상대 색상을 적용했습니다.
<div id="container">
<div class="item" id="one"></div>
<div class="item" id="two"></div>
</div>
#container {
display: flex;
width: 100vw;
height: 100vh;
box-sizing: border-box;
}
.item {
flex: 1;
margin: 20px;
}
#one {
background-color: red;
}
#two {
background-color: rgb(from red 200 g b / alpha);
}
MDN Playground에서 이 예제 실행해보기 (Run example in MDN Playground)
두 번째 상자(#two)에 적용된 상대 색상은 rgb() 함수를 사용하고 있습니다.
먼저 red를 기준 색상(origin color)으로 받아와서, 브라우저가 이걸 rgb()가 이해할 수 있는 값인 rgb(255 0 0)으로 변환합니다. 그다음 새로운 출력 색상을 만드는데, Red(빨강) 채널의 값은 강제로 200으로 덮어씁니다. 그리고 Green(초록), Blue(파랑), 그리고 투명도(alpha) 채널의 값은 원본 색상에서 추출된 g, b, alpha 변수를 그대로 가져다 씁니다. (원본 색상이 순수 빨강이었으니 g와 b는 둘 다 0일 테고, alpha는 100%겠죠.)
그 결과, 쨘! rgb(200 0 0)이라는 살짝 더 어두운 붉은색이 탄생하게 됩니다. 만약 우리가 빨강 채널에 255를 주거나 그냥 추출된 변수 r을 썼다면, 출력 결과는 원본과 완벽하게 똑같은 색상이 되었을 겁니다. 브라우저가 계산을 끝낸 최종 출력 색상(계산값, computed value)은 rgb(200 0 0)과 완벽히 동일한 sRGB color() 값인 color(srgb 0.784314 0 0) 형태가 됩니다.
참고: 브라우저는 계산을 하기 전에 무조건 우리가 넘겨준 기준 색상(위의 예제에선
red)을 현재 겉을 감싸고 있는 함수(위의 경우rgb())와 호환되는 형태로 변환하는 작업부터 수행합니다. 이 계산 과정은 함수에 맞춰 진행되지만, 최종적으로 출력되는 색상 값은 해당 색상의 '컬러 스페이스'에 따라 달라집니다:
- 구식 sRGB 기반 함수들(
hsl(),hwb(),rgb())은 인간이 볼 수 있는 전체 색상 스펙트럼을 다 담아내지 못하는 한계가 있습니다. 그래서 이 한계를 피하고자 이 함수들의 결과물은 내부적으로color(srgb)로 직렬화(serialized)됩니다. JavaScript에서 요소의 색상 값을 찍어보면color(srgb ...)형태로 반환되는 걸 보실 수 있을 거예요.- 반면, 최신 색상 함수들(
lab(),oklab(),lch(),oklch())을 사용하면, 상대 색상의 결과물이 사용했던 그 최신 함수 문법 그대로 표현됩니다.lab()을 썼다면 결괏값도lab()값으로 튀어나오는 거죠.
아래에 있는 코드들은 문법만 조금씩 다를 뿐, 결과적으로는 모두 완벽하게 동일한 붉은색을 만들어내는 코드들입니다:
red
rgb(255 0 0)
rgb(from red 255 0 0)
rgb(from red 255 0 0 / 1)
rgb(from red 255 0 0 / 100%)
rgb(from red 255 g b)
rgb(from red r 0 0)
rgb(from red r g b / 1)
rgb(from red r g b / 100%)
rgb(from red r g b)
rgb(from red r g b / alpha)
/* `red` 색상은 g와 b가 모두 0으로 똑같기 때문에, 서로 자리를 바꿔도 결과가 같습니다 */
rgb(from red r g g)
rgb(from red r b b)
rgb(from red 255 g g)
rgb(from red 255 b b)
여기서 아주 중요한 개념 하나를 짚고 넘어가야 합니다. 함수 내부에서 사용할 수 있도록 브라우저가 분해해서 던져주는 '원본 색상의 채널 값(변수들)'과, 개발자가 최종적으로 만들어내는 '출력 색상의 채널 값'은 완전히 별개라는 사실입니다!
다시 한번 강조하지만, 상대 색상을 선언하면 원본 색상의 채널 값들이 분해되어 함수 내부에서 변수처럼 사용될 수 있습니다. 아래 예제를 보면, rgb() 함수 안에서 추출된 r, g, b 변수들을 그대로 출력 색상 채널의 위치에 꽂아 넣었습니다. 이건 원본 색상과 100% 똑같은 색상을 출력하겠다는 의미죠:
rgb(from red r g b)
하지만, 출력 값을 명시할 때 반드시 원본 색상의 변수들을 다 사용해야 하는 것은 아닙니다. 순서만 맞게(예를 들어 rgb()라면 무조건 빨강, 초록, 파랑 순서로) 적어주면 되고, 그 자리에 들어갈 값은 해당 채널에 유효하기만 하다면 여러분이 원하는 어떤 값이든 상관없습니다. 이 점이 바로 상대 CSS 색상을 엄청나게 유연하게 만들어주는 핵심 비결입니다!
예를 들어, 기준 색상으로 red를 던져주고선 결과물로는 난데없이 쌩뚱맞은 blue를 뽑아낼 수도 있습니다:
rgb(from red 0 0 255)
/* 출력된 색상은 rgb(0 0 255), 즉 완벽한 파란색(full blue)이 됩니다 */
참고: 물론 위의 예제처럼 원본 색상과 똑같은 색을 그대로 뽑아내거나, 원본 색상을 전혀 활용하지 않고 아예 엉뚱한 색을 뽑아낸다면 굳이 '상대 색상' 문법을 쓸 이유가 없겠죠? 실무에서는 그냥 절대 색상(absolute color) 값을 적는 게 훨씬 빠를 테니까요. 하지만 원리와 작동 방식을 이해하기 위한 훌륭한 출발점으로써 이런 것도 '가능하다'라는 걸 보여드리는 겁니다.
심지어 제공받은 변수들을 마구 뒤섞거나 똑같은 변수를 여러 번 반복해서 쓸 수도 있습니다! 다음 코드는 약간 어두운 빨간색(rgb(200 0 0))을 입력으로 받아서, 출력 색상의 r, g, b 세 채널 모두에 원본의 r 채널 값(200)을 욱여넣었습니다. 결과적으로 밝은 회색(light gray)이 만들어지게 되죠:
rgb(from rgb(200 0 0) r r r)
/* 세 채널 모두 200이 들어가므로, 출력은 rgb(200 200 200)인 밝은 회색이 됩니다 */
다음 코드는 원본 색상의 r, g, b 변수들을 가져다 쓰되, 그 순서를 완전히 거꾸로(b, g, r 순) 뒤집어서 배치해 보았습니다:
rgb(from rgb(200 170 0) b g r)
/* 결과는 rgb(0 170 200)이라는 전혀 다른 색이 출력됩니다 */
위의 예제들에서는 상대 색상을 만들 때 오직 rgb() 함수만 사용했지만, 상대 색상 구문은 현대적인 CSS 색상 함수라면 어디든 다 적용할 수 있습니다! color(), hsl(), hwb(), lab(), lch(), oklab(), oklch(), 그리고 rgb()까지 모조리 지원하죠. 기본적인 구문 구조는 다 똑같지만, 사용하는 함수에 맞춰서 원본 색상을 담고 있는 추출된 변수들의 '이름'만 조금씩 다를 뿐입니다.
아래에 각 색상 함수별로 상대 색상 구문을 어떻게 쓰는지 예제를 모아두었습니다. (이해를 돕기 위해 출력 색상이 원본 색상과 똑같이 나오도록 변수들을 기본 순서대로 배치한 가장 심플한 형태입니다.)
/* 알파 채널을 생략한 경우와 포함한 경우의 color() 함수 */
color(from red a98-rgb r g b)
color(from red a98-rgb r g b / alpha)
color(from red xyz-d50 x y z)
color(from red xyz-d50 x y z / alpha)
/* 알파 채널을 생략한 경우와 포함한 경우의 hsl() 함수 */
hsl(from red h s l)
hsl(from red h s l / alpha)
/* 알파 채널을 생략한 경우와 포함한 경우의 hwb() 함수 */
hwb(from red h w b)
hwb(from red h w b / alpha)
/* 알파 채널을 생략한 경우와 포함한 경우의 lab() 함수 */
lab(from red l a b)
lab(from red l a b / alpha)
/* 알파 채널을 생략한 경우와 포함한 경우의 lch() 함수 */
lch(from red l c h)
lch(from red l c h / alpha)
/* 알파 채널을 생략한 경우와 포함한 경우의 oklab() 함수 */
oklab(from red l a b)
oklab(from red l a b / alpha)
/* 알파 채널을 생략한 경우와 포함한 경우의 oklch() 함수 */
oklch(from red l c h)
oklch(from red l c h / alpha)
/* 알파 채널을 생략한 경우와 포함한 경우의 rgb() 함수 */
rgb(from red r g b)
rgb(from red r g b / alpha)
다시 한번 짚고 넘어가자면, 원본 색상이 애초에 무슨 색상 모델로 만들어졌는지는 우리가 출력 색상을 만들기 위해 사용하는 함수와 전혀 일치할 필요가 없습니다. 이 엄청난 유연성 덕분에 개발이 진짜 편해집니다! 보통 실무에서는 원본 색상이 무슨 시스템으로 정의되었는지 개발자가 아예 모르는 경우도 허다합니다. (그냥 남이 만들어둔 CSS 변수(사용자 지정 속성) 하나만 띡 던져져 있는 경우가 많거든요.) 그럴 때도 전혀 당황할 필요 없이, 그 변수를 내가 다루기 편한 hsl() 함수 안에 원본 색상으로 집어넣고 명도(lightness) 값만 살짝 바꿔주면 아주 손쉽게 '밝은 버전의 색상'을 만들어 낼 수 있습니다.
상대 색상을 만들 때, CSS 사용자 지정 속성(CSS 변수)에 들어있는 값을 원본 색상(origin color)으로 던져주거나, 출력 채널 값을 정의하는 수식 안에서 사용할 수도 있습니다. 예제를 통해 어떻게 활용하는지 보시죠!
아래 CSS 코드에서는 두 개의 사용자 지정 속성(CSS 변수)을 먼저 정의합니다:
--base-color에는 우리 브랜드의 핵심 색상인 purple(보라색)을 담았습니다. 여기선 키워드를 썼지만, 상대 색상의 원본으로는 어떤 색상 문법을 쓰든 다 잘 먹힙니다.--standard-opacity에는 반투명한 박스들을 만들 때 일관되게 적용할 우리 브랜드의 표준 투명도 값인 0.75를 저장했습니다.이제 두 개의 <div> 요소에 배경색을 칠해볼 건데요. 첫 번째 박스에는 절대 색상인 우리의 브랜드 보라색(--base-color)을 쨍하게 입힐 거고, 두 번째 박스에는 그 브랜드 컬러를 기준으로 삼되, 아까 저장해 둔 표준 투명도 값을 알파 채널에 추가하여 변형시킨 상대 색상을 입힐 겁니다.
<div id="container">
<div class="item" id="one"></div>
<div class="item" id="two"></div>
</div>
#container {
display: flex;
width: 100vw;
height: 100vh;
box-sizing: border-box;
background-image: repeating-linear-gradient(
45deg,
white,
white 24px,
black 25px,
black 50px
);
}
.item {
flex: 1;
margin: 20px;
}
:root {
--base-color: purple;
--standard-opacity: 0.75;
}
#one {
background-color: var(--base-color);
}
#two {
background-color: hwb(from var(--base-color) h w b / var(--standard-opacity));
}
결과 화면을 확인해 보세요:
MDN Playground에서 이 예제 실행해보기 (Run example in MDN Playground)
💡 강사님의 꿀팁: 이 패턴은 디자인 시스템을 만들 때 정말정말 유용합니다! 메인 컬러 변수 딱 하나만 정의해 두고, 호버(hover) 상태나 비활성화(disabled) 상태의 색상을 상대 색상 문법으로 알아서 파생되게 만들어두면 나중에 메인 컬러를 바꿔달라는 요청이 왔을 때 변수 딱 한 줄만 고치면 사이트 전체의 색상이 알아서 쫙 바뀌는 마법을 경험할 수 있어요!
출력 색상의 채널 값을 계산할 때 calc()와 같은 CSS 수학 함수(math functions)들을 적극적으로 활용할 수 있습니다. 어떻게 하는 건지 예제를 통해 알아볼까요?
아래 CSS는 3개의 <div> 요소에 각각 다른 배경색을 깔아주는 코드입니다. 가운데 있는 상자는 아무 변형 없이 순수한 --base-color를 그대로 가집니다. 하지만 왼쪽 상자는 베이스 컬러를 살짝 '밝게' 만든 변형 버전을, 오른쪽 상자는 살짝 '어둡게' 만든 변형 버전을 칠해줄 거예요.
이 변형 색상들은 상대 색상 구문을 이용해 아주 스마트하게 만들어졌습니다. --base-color를 원본 색상으로 삼아 lch() 함수 안으로 던져 넣은 뒤, 밝기를 조절하기 위해 추출된 명도(lightness) 변수인 l 값을 calc() 함수로 요리조리 계산해서 출력 색상의 명도 채널에 쏙 집어넣었죠! 밝은 색상은 원본 명도 l에 20을 더했고(calc(l + 20)), 어두운 색상은 l에서 20을 뺐습니다(calc(l - 20)).
<div id="container">
<div class="item" id="one"></div>
<div class="item" id="two"></div>
<div class="item" id="three"></div>
</div>
#container {
display: flex;
width: 100vw;
height: 100vh;
box-sizing: border-box;
}
.item {
flex: 1;
margin: 20px;
}
:root {
--base-color: orange;
}
#one {
background-color: lch(from var(--base-color) calc(l + 20) c h);
}
#two {
background-color: var(--base-color);
}
#three {
background-color: lch(from var(--base-color) calc(l - 20) c h);
}
결과 화면을 확인해 보세요:
MDN Playground에서 이 예제 실행해보기 (Run example in MDN Playground)
이 예제는 이름이 정해져 있는 키워드 색상(named color)의 알파 채널(투명도)을 어떻게 마음대로 주무를 수 있는지 보여줍니다. 부모 래퍼 컨테이너와 그 안에 들어있는 자식 요소 모두 똑같은 teal 색상을 배경으로 가지고 있어요. 이 두 배경을 시각적으로 구분하기 위해, 우리는 상대 색상 기능과 calc() 함수, 그리고 CSS 변수(사용자 지정 속성)를 조합해서 알파 채널의 투명도 값을 변형시켜 볼 겁니다.
<div class="container">
<div class="item"></div>
</div>
.container {
padding: 60px;
}
.item {
height: 60px;
}
div {
background-color: rgb(
from teal r g b / calc(alpha * var(--alpha-multiplier))
);
}
.container {
--alpha-multiplier: 0.3;
}
.item {
--alpha-multiplier: 1;
}
수식 안에서 투명도 값은 alpha라는 키워드 변수로 가져와 쓸 수 있습니다. 위 코드에서는 calc(alpha * var(--alpha-multiplier))라는 멋진 수식을 사용해서, 원본 색상의 alpha 값에 우리가 CSS 변수로 지정해 둔 --alpha-multiplier(곱셈기) 값을 곱해서 새로운 투명도를 만들어 냈습니다! 바깥쪽 컨테이너는 이 곱셈기 변수 값이 1.0보다 작은 0.3이기 때문에 반투명한 옅은 색으로 보이게 됩니다. (안쪽 자식은 1을 곱했으니 투명도가 안 변하고 진하게 보이겠죠!)
결과 화면을 확인해 보세요:
MDN Playground에서 이 예제 실행해보기 (Run example in MDN Playground)
<number> 값으로 해석됩니다 (Channel values resolve to <number> values)상대 색상 문법 안에서 채널 값을 가지고 더하기 빼기 같은 수식 계산을 하려면, 원본 색상에서 분해되어 튀어나오는 채널 변수들이 수학 계산이 가능한 깔끔한 <number>(숫자) 값으로 변환(resolve)되어야 합니다. 그래야 아까 본 lch() 예제처럼 원본 명도 변수인 l에 숫자를 자유롭게 더하거나 뺄 수 있거든요. 만약 우리가 calc(l + 20%)라고 퍼센트를 더하려고 시도한다면, CSS는 이를 유효하지 않은 망가진 색상으로 취급해 버립니다. 왜냐하면 변수 l은 순수한 숫자 <number> 타입인데, 거기에 데이터 타입이 다른 <percentage>(퍼센트) 값을 더하려고 했기 때문입니다!
<percentage>) 단위로 지정되어 있던 채널 값들은 모두, 출력 색상 함수가 계산하기 딱 좋은 순수 숫자(<number>) 값으로 깔끔하게 변환되어 제공됩니다.<hue>(색조)로 지정되어 있던 값들은 0부터 360 사이(포함)의 깔끔한 숫자로 변환됩니다.어떤 색상 함수가 원본 채널 값을 구체적으로 어떤 숫자로 변환해 주는지 자세히 알고 싶다면, 각 색상 함수들의 설명 페이지를 확인해 보세요!
상대 색상 구문은 아주 멋진 최신 기술이기 때문에, 사용자의 브라우저가 이 문법을 지원하는지 @supports 앳룰을 사용해서 안전하게 확인한 뒤 적용하는 것이 좋습니다.
예를 들면 이렇게요:
@supports (color: hsl(from white h s l)) {
/* 브라우저가 상대 색상 구문을 이해하는군요! 마음껏 hsl() 상대 색상을 작성하세요 */
}
참고: 이 문서 외에도, 각각의 함수를 사용해서 상대 색상 구문을 다루는 아주 다양한 심화 예제들이 함수별 전용 페이지에 정리되어 있습니다. 꼭 한 번씩 들러서 확인해 보세요!
color(),hsl(),hwb(),lab(),lch(),oklab(),oklch(),rgb().
여러분이 베이스 컬러(기준색)와 컬러 팔레트 묶음의 '종류'를 선택하면, 브라우저가 그 기준색을 바탕으로 아주 그럴싸한 조화로운 컬러 팔레트(색상 조합)를 알아서 계산해서 보여주는 신기한 예제입니다! 선택할 수 있는 팔레트 종류의 원리는 다음과 같아요:
<hue> 데이터 타입 문서를 확인해 보세요). 여기서는 베이스 컬러와, 베이스 컬러의 색조(hue) 채널에 +180도를 더한 색, 이렇게 두 가지 색상으로 정의됩니다.전체 HTML 코드를 살펴볼게요. 눈여겨볼 만한 핵심 포인트는 다음과 같습니다:
--base-color 커스텀 변수(사용자 지정 속성)가 id="container"인 <div> 태그의 인라인 style 속성 안에 바로 박혀있습니다. 이렇게 한 이유는, 나중에 자바스크립트로 이 변수 값을 실시간으로 업데이트하기 편하게 만들기 위해서예요! 페이지가 켜지자마자 빨간색 기반의 팔레트가 보이도록 초기값으로 #ff0000(red)을 넣어두었습니다. (원래 정석대로라면 짱짱하게 <html> 태그에 선언하는 게 맞지만, MDN 라이브 샘플 환경의 제약 때문에 부득이하게 여기에 넣었답니다.)<input type="color"> 태그를 써서 만들었습니다. 사용자가 이 컬러 피커(color picker)에서 새로운 색을 고르면, 자바스크립트가 쏜살같이 달려가 --base-color CSS 변수 값을 그 색깔로 덮어씌웁니다. 변수 값이 바뀌면 연쇄 작용으로 새로운 컬러 팔레트가 눈앞에 펼쳐지죠! 화면에 보이는 모든 조화로운 색상들은 바로 이 --base-color를 뼈대로 삼아 계산된 '상대 색상(relative colors)'들이기 때문입니다.<input type="radio"> 묶음은 사용자가 원하는 팔레트 '종류(테마)'를 고를 수 있게 해 줍니다. 여기서 종류를 선택하면 자바스크립트가 container <div>에 선택된 팔레트 이름의 '클래스(class)'를 달아줍니다. 그리고 CSS에서는 후손 선택자(descendant selectors, 예: .comp :nth-child(1))를 활용해서, 선택된 클래스에 맞는 색상만 자식 <div>들에게 샤라락 입히고, 해당 팔레트에 필요 없는 여분의 <div> 노드들은 안 보이게 숨겨버립니다.container <div> 안에는, 뚝딱뚝딱 계산된 팔레트 색상들을 직접 화면에 보여줄 여러 개의 빈 자식 <div>들이 들어있습니다. 초기에 페이지가 열릴 때는 '보색(complementary)' 테마가 보이도록 comp라는 클래스를 미리 세팅해 두었어요.Play (Run example in MDN Playground)
<div>
<h1>Color palette generator</h1>
<form>
<div id="color-picker">
<label for="color">Select a base color:</label>
<input type="color" id="color" name="color" value="#ff0000" />
</div>
<div>
<fieldset>
<legend>Select a color palette type:</legend>
<div>
<input
type="radio"
id="comp"
name="palette-type"
value="comp"
checked />
<label for="comp">Complementary</label>
</div>
<div>
<input
type="radio"
id="triadic"
name="palette-type"
value="triadic" />
<label for="triadic">Triadic</label>
</div>
<div>
<input
type="radio"
id="tetradic"
name="palette-type"
value="tetradic" />
<label for="tetradic">Tetradic</label>
</div>
<div>
<input
type="radio"
id="monochrome"
name="palette-type"
value="monochrome" />
<label for="monochrome">Monochrome</label>
</div>
</fieldset>
</div>
</form>
<div id="container" class="comp">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
아래 CSS 코드에는 오직 핵심인 '팔레트 색상을 조립하는 부분'만 남겨두었습니다. 각각의 테마에 맞게 후손 선택자(descendant selectors)를 사용해서 각 자식 <div>마다 알맞게 계산된 background-color를 쏙쏙 꽂아주는 원리를 주의 깊게 살펴보세요. 우리는 요소의 태그 이름보다는 "HTML 상에서 몇 번째에 위치한 <div>인지"가 더 중요하기 때문에, 이를 콕 집어내기 위해 가상 클래스 :nth-child를 썼습니다.
가장 밑에 있는 규칙을 보시면 일반 형제 결합자(general sibling selector, ~)를 썼죠? 이걸 이용해 각 팔레트 타입에서 색상을 칠하고 남은 빈 잉여 <div> 요소들을 한꺼번에 찾아서 display: none 처리해 화면에서 완전히 지워버립니다.
실제 칠해지는 색상들은 --base-color 원본과, 그 뼈대에서 파생되어 나온 상대 색상(relative colors)들로 이루어집니다. 상대 색상을 계산할 때는 강력한 lch() 함수를 썼습니다! 원본인 --base-color를 집어넣고, 계산식에 따라 명도(lightness)나 색조(hue) 채널을 요리조리 더하고 빼서 우리가 원하는 출력 색상들을 빚어낸 것이죠.
Play (Run example in MDN Playground)
/* 보색 (Complementary colors) */
/* 베이스 컬러, 그리고 베이스 컬러의 색조(hue) 채널에 +180도를 한 색상 */
.comp :nth-child(1) {
background-color: var(--base-color);
}
.comp :nth-child(2) {
background-color: lch(from var(--base-color) l c calc(h + 180));
}
/* Safari 16.4+ 등 구형 문법을 위한 @supports 처리 (각도 계산 시 deg 단위 명시 필요) */
@supports (color: lch(from red l c calc(h + 180deg))) {
.comp :nth-child(2) {
background-color: lch(from var(--base-color) l c calc(h + 180deg));
}
}
/* 삼색 조화 (Triadic colors) */
/* 베이스 컬러, 색조 -120도 색상, 그리고 색조 +120도 색상 */
.triadic :nth-child(1) {
background-color: var(--base-color);
}
.triadic :nth-child(2) {
background-color: lch(from var(--base-color) l c calc(h - 120));
}
.triadic :nth-child(3) {
background-color: lch(from var(--base-color) l c calc(h + 120));
}
/* Safari 구형 문법 대응 */
@supports (color: lch(from red l c calc(h + 120deg))) {
.triadic :nth-child(2) {
background-color: lch(from var(--base-color) l c calc(h - 120deg));
}
.triadic :nth-child(3) {
background-color: lch(from var(--base-color) l c calc(h + 120deg));
}
}
/* 사색 조화 (Tetradic colors) */
/* 베이스 컬러, 그리고 색조 +90도, +180도, +270도 색상 */
.tetradic :nth-child(1) {
background-color: var(--base-color);
}
.tetradic :nth-child(2) {
background-color: lch(from var(--base-color) l c calc(h + 90));
}
.tetradic :nth-child(3) {
background-color: lch(from var(--base-color) l c calc(h + 180));
}
.tetradic :nth-child(4) {
background-color: lch(from var(--base-color) l c calc(h + 270));
}
/* Safari 구형 문법 대응 */
@supports (color: lch(from red l c calc(h + 90deg))) {
.tetradic :nth-child(2) {
background-color: lch(from var(--base-color) l c calc(h + 90deg));
}
.tetradic :nth-child(3) {
background-color: lch(from var(--base-color) l c calc(h + 180deg));
}
.tetradic :nth-child(4) {
background-color: lch(from var(--base-color) l c calc(h + 270deg));
}
}
/* 단색 조화 (Monochrome colors) */
/* 베이스 컬러, 그리고 명도(lightness) 채널을 각각 -20, -10, +10, +20 조절한 색상 */
.monochrome :nth-child(1) {
background-color: lch(from var(--base-color) calc(l - 20) c h);
}
.monochrome :nth-child(2) {
background-color: lch(from var(--base-color) calc(l - 10) c h);
}
.monochrome :nth-child(3) {
background-color: var(--base-color);
}
.monochrome :nth-child(4) {
background-color: lch(from var(--base-color) calc(l + 10) c h);
}
.monochrome :nth-child(5) {
background-color: lch(from var(--base-color) calc(l + 20) c h);
}
/* 각 팔레트에서 쓰지 않는 잉여 블록들 숨기기 */
.comp :nth-child(2) ~ div,
.triadic :nth-child(3) ~ div,
.tetradic :nth-child(4) ~ div {
display: none;
}
@supports 테스팅 꿀팁방금 본 예제 코드에서 @supports 블록이 등장한 거 보셨나요? 상대 색상 문법의 '초기 초안 스펙'을 따랐던 구버전 브라우저들을 위해 백업용 background-color 코드를 추가로 적어준 겁니다. Safari의 초기 구현 버전이 바로 이 낡은 스펙을 따랐기 때문에 발생한 문제인데요, 예전 스펙에서는 원본 색상 채널 값이 맥락에 따라 <number> 혹은 아예 '다른 단위 타입'으로 제멋대로 변환되었습니다. 그래서 덧셈 뺄셈 계산을 할 때 단위(deg 등)를 꼭 붙여줘야 에러가 안 났었죠. (정말 혼란스러웠습니다!) 다행히 최신 구현 스펙에서는 모든 원본 색상 채널 값이 깔끔하게 단위가 없는 순수 <number>로 통일되어서 단위 없이 수식 계산이 가능해졌습니다.
@supports로 테스트 코드를 짤 때 유의할 점이 있습니다. 테스트 조건식(color: lch(from red l c calc(h + 90deg)) 부분)은 실제로 우리가 변화를 주고 싶은 완벽한 그 코드와 똑같을 필요가 없습니다. 문법적인 차이(단위 유무 등)를 브라우저가 인지하는지 테스트하는 게 목적이므로, 그 문법이 들어간 가장 심플하고 짧은 코드 조각을 던져보는 것이 최고의 방법입니다.
그리고 @supports 테스트 조건식 안에 사용자 지정 속성(CSS 변수)을 쓰면 안 됩니다! 변수에 어떤 값이 들어있든 테스트는 항상 무조건 '통과(positive)'로 뜹니다. 왜냐하면 커스텀 속성 값은 일반 CSS 속성에 할당되어 실제로 렌더링 될 때야 비로소 유효한지 아닌지 검사받기 때문이죠. 이 맹점을 피하기 위해 모든 테스트 코드에서 var(--base-color) 대신 red라는 확실한 키워드를 쓴 겁니다.
우리의 영리한 자바스크립트는 이런 일을 합니다:
change 이벤트 리스너를 달았습니다. 사용자가 버튼을 클릭해 다른 테마를 고르면 setContainer() 함수가 발동합니다. 이 함수는 id="container"인 <div>의 class를 라디오 버튼 값(팔레트 이름)으로 확 바꿔치기합니다. 그러면 CSS에 의해 자식 <div>들에게 선택된 팔레트의 멋진 색상들이 즉시 입혀지게 되죠.input 이벤트 리스너를 달았습니다. 마우스로 쭉 끌어서 새로운 색상을 고르는 즉시 setBaseColor() 함수가 팽팽 돌아갑니다. 이 함수는 CSS의 --base-color 커스텀 속성(변수) 값을 사용자가 고른 바로 그 새 색상으로 실시간 업데이트해 줍니다.Play (Run example in MDN Playground)
const form = document.forms[0];
const radios = form.elements["palette-type"];
const colorPicker = form.elements["color"];
const containerElem = document.getElementById("container");
for (const radio of radios) {
radio.addEventListener("change", setContainer);
}
colorPicker.addEventListener("input", setBaseColor);
function setContainer(e) {
const palType = e.target.value;
console.log("radio changed");
containerElem.setAttribute("class", palType);
}
function setBaseColor(e) {
console.log("color changed");
containerElem.style.setProperty("--base-color", e.target.value);
}
결과는 아래와 같습니다! 이 예제는 상대 CSS 색상 기능이 얼마나 강력한지 짐작할 수 있게 해 줍니다. 우리는 그저 기준이 되는 단 하나의 CSS 변수(--base-color)만 요리조리 바꿨을 뿐인데, 수많은 상대 색상들이 알아서 계산되어 나오면서 전체적인 컬러 팔레트가 살아 숨 쉬듯 실시간으로 생성되고 업데이트됩니다!
참고: 페이지를 새로고침하면 아래 위젯에서 직접 색상을 고르고 테마를 바꿔보며 놀 수 있습니다.
이번 예제는 눈길을 사로잡는 화려한 카드 뷰를 보여줍니다. 제목과 텍스트가 예쁘게 들어있죠. 그런데 카드 아래에 비밀 무기가 하나 숨겨져 있습니다. 바로 슬라이더(<input type="range">) 컨트롤입니다! 슬라이더 손잡이를 좌우로 슥슥 끌면, 자바스크립트가 --hue라는 이름의 커스텀 속성값을 새로운 슬라이더 값(각도)으로 계속 업데이트합니다.
그리고 이 한 가지 변수 업데이트가 전체 UI의 색상 테마를 극적으로 탈바꿈시킵니다:
--base-color는 lch() 함수 안에서 방금 전달받은 --hue 값으로 자신의 색조(hue)를 바꾼 '상대 색상'입니다.--base-color를 기준으로 파생된 상대 색상들입니다. 결과적으로 슬라이더 하나만 움직이면 --base-color가 바뀌고, 도미노처럼 다른 모든 색상들이 완벽한 조화를 이루며 실시간으로 슉슉 바뀝니다!예제의 HTML 구조는 아래와 같습니다.
<main> 태그가 거대한 래퍼 역할을 해서, 그 안의 카드와 입력 폼을 한 덩어리로 묶어 화면 정중앙에 띄워줍니다.<section> 태그 안에는 카드의 실제 내용물인 <h1>과 <p> 태그들이 예쁘게 들어있습니다.<form> 태그는 색조를 조절할 슬라이더(<input type="range">)와 그 이름표(<label>)를 깔끔하게 품고 있습니다.Play (Run example in MDN Playground)
<main>
<section>
<h1>A love of colors</h1>
<p>
Colors, the vibrant essence of our surroundings, are truly awe-inspiring.
From the fiery warmth of reds to the calming coolness of blues, they bring
unparalleled richness to our world. Colors stir emotions, ignite
creativity, and shape perceptions, acting as a universal language of
expression. In their brilliance, colors create a visually enchanting
tapestry that invites admiration and sparks joy.
</p>
</section>
<form>
<label for="hue-adjust">Adjust the hue:</label>
<input
type="range"
name="hue-adjust"
id="hue-adjust"
value="240"
min="0"
max="360" />
</form>
</main>
CSS를 살펴보면 최상위 :root에 기본 --hue 변수 값을 박아두었고, 이 변수를 기반으로 전체 색상 테마를 잡아주는 세 가지 상대 lch() 색상 변수들을 세팅했습니다. 배경 화면 전체를 채우는 둥근 방사형 그라디언트(radial gradient)에도 이 색상이 쓰였네요!
마법의 3단 콤보 상대 색상들은 다음과 같습니다:
--base-color: 이 녀석이 모든 색상의 기준점입니다! 원본 색상으로 red를 슬쩍 빌려왔지만 사실 무슨 색이든 상관없습니다. 핵심은 추출된 색조(hue) 값을 갖다 버리고, 우리가 슬라이더로 조작할 --hue 커스텀 변수 값으로 색조를 강제 교체했다는 겁니다!--bg-color: --base-color를 기준으로 명도(lightness) 값만 40만큼 뻥튀기해서 훨씬 밝게 만든 배경 전용 옅은 테마 색상입니다.--complementary-color: 베이스 컬러를 기준으로 색상환을 반 바퀴(180도) 삥 돌아간 완벽한 보색(complementary color)입니다. --base-color의 색조(h) 값에 180을 더해서 완성했죠. 이 튀는 색상은 강조 포인트에 쓰입니다.나머지 CSS를 쭉 훑어보시면서 이 세 가지 변수가 어떻게 재사용되고 있는지 눈여겨보세요. 배경(background)은 물론, 테두리(border), 글자 그림자인 text-shadow, 심지어 슬라이더 폼의 포인트 색상인 accent-color까지 모조리 이 변수들로 도배되어 있습니다!
참고: 코드가 너무 길어지는 걸 막기 위해 상대 색상이 쓰인 핵심 CSS 부분만 추려냈습니다.
Play (Run example in MDN Playground)
:root {
/* 기본 색조(hue) 각도 값 */
--hue: 240;
/* 핵심! 상대 색상(Relative color) 정의 파트 */
--base-color: lch(from red l c var(--hue));
--bg-color: lch(from var(--base-color) calc(l + 40) c h);
--complementary-color: lch(from var(--base-color) l c calc(h + 180));
background: radial-gradient(ellipse at center, white 20%, var(--base-color));
}
/* Safari 16.4+ 등 구형 문법을 위한 백업 코드 (@supports) */
@supports (color: lch(from red l c calc(h + 180deg))) {
body {
--complementary-color: lch(from var(--base-color) l c calc(h + 180deg));
}
}
/* 박스 꾸미기 */
section {
background-color: var(--bg-color);
border: 3px solid var(--base-color);
border-radius: 20px;
box-shadow: 10px 10px 30px rgb(0 0 0 / 0.5);
}
h1 {
background-color: var(--base-color);
text-shadow:
1px 1px 1px var(--complementary-color),
-1px -1px 1px var(--complementary-color),
0 0 3px var(--complementary-color);
}
/* 슬라이더 컨트롤 폼 꾸미기 */
form {
background-color: var(--bg-color);
border: 3px solid var(--base-color);
}
input {
accent-color: var(--complementary-color);
}
자바스크립트에서는 슬라이더 컨트롤에 input 이벤트 리스너를 딱 하나 달아주었습니다. 사용자가 손잡이를 잡고 이리저리 새로운 값으로 이동시키면 setHue() 함수가 즉각 발동합니다. 이 함수는 HTML의 <html> 태그(:root)에 인라인 스타일로 새로운 --hue 커스텀 속성값을 밀어 넣어, CSS에 적어둔 원래의 기본값(240)을 즉시 덮어씌웁니다.
Play (Run example in MDN Playground)
const rootElem = document.querySelector(":root");
const slider = document.getElementById("hue-adjust");
slider.addEventListener("input", setHue);
function setHue(e) {
rootElem.style.setProperty("--hue", e.target.value);
}
더 깊이 파고들고 싶으신 분들을 위해 준비했습니다!
<color> 데이터 타입 (Data type)이 문서가 학습에 도움이 되셨나요? (Was this page helpful to you?)
[Yes][No]
문서 기여 방법 알아보기: Learn how to contribute
최종 수정일: 2025년 12월 16일 (MDN contributors 작성)
이 문서를 GitHub에서 보기 | 문서의 문제점 신고하기