65.1 다크모드, 색을 어디에 모아둬야 해요?
다크모드를 처음 넣을 때 가장 흔한 삽질부터 얘기할게요. 버튼도 검게, 배경도 검게, 글자는 밝게... 이렇게 화면 곳곳의 색을 일일이 손으로 바꾸는 거예요. 저도 그랬어요. 그러다 다크 테마 색조 하나 손보려면 수십 군데를 찾아 고쳐야 하고, 하나 빠뜨리면 거기만 하얗게 튀어서 눈이 아파요.
정답은 색을 한곳에 모아두는 거예요. 화면 여기저기에 #ffffff 같은 값을 흩뿌리지 말고, 이름표를 붙여 딱 한 군데에 모으는 거죠. 그 이름표가 바로 CSS 변수(custom property, 사용자 정의 속성)예요. 이름은 두 개의 하이픈으로 시작해요.
상황: 화면 전체가 쓸 색을 :root 한곳에 이름표를 붙여 모아둬요.
:root {
--bg: #ffffff;
--text: #1a1a1a;
--card: #f5f5f5;
--border: #e0e0e0;
}
여기서 :root는 문서의 가장 바깥(html 요소)을 가리키는 선택자예요. 여기 정의한 변수는 그 안의 모든 요소가 물려받아요(상속). 그래서 페이지 어디서든 이 이름표를 꺼내 쓸 수 있죠. 색의 본진을 한곳에 세운 거예요. 이 한 걸음이 다크모드의 절반이에요.
65.2 이름표는 어떻게 꺼내 써요?
모아뒀으면 이제 꺼내 써야죠. 색을 직접 적는 대신, 아까 붙인 이름표를 var()로 불러와요. 괄호 안에 이름을 넣으면, 그 자리에 변수에 담긴 값이 대신 들어가요.
상황: 색을 직접 쓰지 않고, 이름표를 var()로 불러 써요.
body {
background: var(--bg);
color: var(--text);
}
.card {
background: var(--card);
border: 1px solid var(--border);
}
이제 컴포넌트 CSS 어디에도 진짜 색값이 없어요. 전부 var(--이름)으로 본진을 가리키기만 해요. 이게 왜 중요하냐면, 나중에 --bg의 값 하나만 바꾸면 그걸 참조하던 body, .card, 그 밖의 모든 곳이 한꺼번에 따라 바뀌기 때문이에요. 색을 바꾸는 스위치를 한 개로 줄인 거죠.
참고로 var()엔 대비책도 넣을 수 있어요. var(--bg, white)처럼 쓰면, 혹시 --bg가 정의 안 됐을 때 뒤의 white를 대신 써요. 안전장치로 알아두면 좋아요.
65.3 다크 테마는 어떻게 갈아끼워요?
드디어 핵심이에요. 색을 이름표로 모아뒀으니, 다크모드는 그 이름표의 값만 통째로 갈아끼우면 끝이에요. 방법은 html에 표시 하나를 붙이고, 그 표시가 있을 때의 변수 값을 새로 정의하는 거예요. 저는 data-theme이라는 속성을 즐겨 써요.
상황: html에 data-theme="dark"가 붙으면 같은 이름표의 값만 어두운 색으로 바꿔요.
[data-theme="dark"] {
--bg: #121212;
--text: #eaeaea;
--card: #1e1e1e;
--border: #2c2c2c;
}
여기 [data-theme="dark"]는 data-theme 속성이 dark인 요소를 고르는 선택자예요. html에 그 표시가 붙는 순간, --bg 같은 이름표의 값이 어두운 쪽으로 다시 정해져요. 컴포넌트 CSS는 한 글자도 안 건드렸는데, var()가 참조하는 값이 바뀌니 화면 전체가 통째로 뒤집혀요. 라이트 때 쓰던 .card 규칙 그대로, 배경만 #f5f5f5에서 #1e1e1e로 바뀌는 거죠.
이제 그 표시를 켜고 끄는 버튼만 있으면 돼요. 자바스크립트로 html의 data-theme을 바꿔주면 되고요.
상황: 버튼을 누르면 html의 data-theme를 dark와 light 사이에서 뒤집어요.
const root = document.documentElement;
btn.addEventListener('click', () => {
const now = root.getAttribute('data-theme');
const next = now === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
});
document.documentElement이 바로 html 요소예요. 버튼을 누를 때마다 지금 값을 확인해서 반대쪽으로 바꿔주면, 그 순간 CSS가 새 변수 값을 적용해 화면이 전환돼요. 딱 이게 다크모드의 뼈대예요.
65.4 클래스랑 data 속성 중 뭘 써요?
여기서 자주 나오는 질문이에요. 표시를 붙이는 방식은 두 가지가 있거든요. 하나는 방금 쓴 data 속성, 다른 하나는 클래스예요. 클래스로 하면 이렇게 생겨요.
상황: html에 dark 클래스가 붙으면 어두운 값으로 바꿔요.
html.dark {
--bg: #121212;
--text: #eaeaea;
}
둘 다 잘 돌아가요. 차이는 확장성에 있어요. 클래스는 있냐 없냐의 두 갈래라 라이트냐 다크냐 딱 두 개일 때 깔끔해요. 반면 data-theme은 값을 여러 개 가질 수 있어요. dark, sepia(세피아, 종이색), 고대비처럼 테마가 셋 이상으로 늘어날 걸 안다면 data 속성이 편해요. [data-theme="sepia"] 한 블록만 더 추가하면 되니까요.
정답은 없어요. 두 갈래면 클래스, 여러 갈래로 클 것 같으면 data 속성. 저는 나중을 생각해서 data-theme 쪽을 기본으로 잡아두는 편이에요. 어느 쪽을 고르든 var()로 색을 모아둔 구조는 똑같으니, 나중에 갈아타도 컴포넌트는 손댈 게 없어요.
65.5 색값을 왜 두 벌만 정하면 돼요?
이 구조가 편한 진짜 이유가 하나 더 있어요. 이름표에 역할로 이름을 붙였기 때문이에요. 무슨 말이냐면, 변수 이름을 --white나 --gray처럼 색 자체로 짓지 말라는 거예요. 그러면 다크에서 --white가 검정이 되는 웃긴 일이 벌어져요.
대신 --bg(배경), --text(글자), --border(테두리)처럼 쓰임새로 지어요. 이걸 시맨틱 토큰(semantic token, 의미로 이름 붙인 색)이라고 해요. 그러면 라이트에서든 다크에서든 --text는 언제나 글자색이고, 값만 밝은 색과 어두운 색으로 각각 정해두면 돼요.
상황: 역할 이름 하나에, 라이트와 다크 두 벌의 값만 짝지어 둬요.
:root {
--text: #1a1a1a; /* 라이트: 진한 글자 */
--text-muted: #6b7280; /* 흐린 보조 글자 */
}
[data-theme="dark"] {
--text: #eaeaea; /* 다크: 밝은 글자 */
--text-muted: #9ca3af;
}
그래서 색을 수십 개 관리하는 게 아니라, 역할 몇 개에 두 벌씩만 정해두면 돼요. 새 컴포넌트를 만들 때도 진짜 색을 고민할 필요 없이 var(--text)만 갖다 쓰면, 그 컴포넌트는 공짜로 다크모드에 대응돼요. 색을 역할로 묶는 이 습관 하나가 나중의 일을 통째로 줄여줘요.
65.6 오늘 배운 다크모드 기초를 정리해볼게요
다크모드의 뼈대를 세웠어요. 핵심은 딱 하나, 색을 이름표로 모아두는 것이었죠.
먼저 :root에 CSS 변수로 색을 모으고, 컴포넌트에선 진짜 색 대신 var(--이름)으로 꺼내 써요. 그리고 html에 [data-theme="dark"] 표시가 붙을 때의 변수 값만 새로 정의하면, 컴포넌트를 안 건드려도 화면 전체가 뒤집혀요. 표시는 클래스나 data 속성으로 붙이는데, 테마가 여럿으로 클 것 같으면 data-theme이 편해요. 변수 이름은 색이 아니라 역할(--bg, --text)로 지어, 값을 두 벌씩만 관리하면 되고요.
이 구조의 힘은 "색을 한곳에서만 바꾼다"에 있어요. 다크모드가 어려워 보이는 이유는 대개 색이 온 코드에 흩어져 있어서예요. 그걸 이름표로 모아두는 순간, 다크모드는 값 한 벌 갈아끼우는 일로 줄어들어요. 다음 걸음은 이 전환을 사용자 OS 설정에 맞추는 쪽인데, 그 준비가 여기서 다 끝났어요.