CSS 변수와 테마 시스템 — Custom Properties로 다크 모드 구현하기
다크 모드를 구현하려고 하는데, 색상을 일일이 바꾸지 않고 한 곳에서 관리할 수는 없을까?
CSS 변수와 테마 시스템 — Custom Properties로 다크 모드 구현하기
CSS를 작성하다 보면 같은 색상값이 여러 곳에 반복되는 걸 느낍니다. #3b82f6을 열 군데에 넣어놓고, 나중에 브랜드 색상이 바뀌면 전부 찾아서 바꿔야 합니다. 여기에 다크 모드까지 추가되면 관리가 급격히 어려워지죠.
CSS Custom Properties(흔히 "CSS 변수")는 이 문제를 브라우저 레벨에서 해결해 줍니다. 한번 정리해 보겠습니다.
CSS Custom Properties란
CSS 변수는 --로 시작하는 이름을 가진 속성입니다. 선언과 사용이 분리되어 있어요.
/* 선언 */
:root {
--primary-color: #3b82f6;
--font-size-base: 16px;
--border-radius: 8px;
}
/* 사용 — var(변수명, 폴백값) */
.button {
background-color: var(--primary-color);
font-size: var(--font-size-base);
border-radius: var(--border-radius);
}
var() 함수의 두 번째 인자는 폴백 값 입니다. 변수가 정의되지 않았거나 유효하지 않을 때 대신 사용됩니다.
.card {
/* --card-padding이 없으면 16px 사용 */
padding: var(--card-padding, 16px);
/* 폴백에 다른 변수를 넣을 수도 있음 */
color: var(--card-color, var(--text-color, #333));
}
var()의 폴백은 쉼표 뒤의 모든 내용을 하나의 값으로 인식합니다.var(--font, Helvetica, Arial, sans-serif)에서 폴백은Helvetica, Arial, sans-serif전체입니다.
스코프와 상속
CSS 변수의 강력한 점은 CSS의 상속(inheritance) 규칙을 그대로 따른다 는 것입니다.
:root에서 전역 선언
:root는 HTML 문서의 최상위 요소(<html>)를 가리킵니다. 여기에 선언하면 모든 자식 요소에서 접근 가능합니다.
:root {
--color-primary: #3b82f6;
--color-text: #1f2937;
}
요소별 오버라이드
특정 요소에서 같은 변수를 다시 선언하면, 해당 요소와 그 자손에서는 오버라이드된 값이 적용됩니다.
:root {
--spacing: 16px;
}
/* .compact 내부에서는 spacing이 8px */
.compact {
--spacing: 8px;
}
/* 두 곳 모두에서 사용 가능 */
.card {
padding: var(--spacing); /* 부모에 따라 16px 또는 8px */
}
이 특성 덕분에 컴포넌트 단위로 변수를 재정의 할 수 있습니다. 전역 변수를 바꾸지 않고도 특정 영역의 스타일만 조정할 수 있죠.
Sass 변수와의 차이
공부하다 보면 "Sass에도 $변수가 있는데 뭐가 다른 거지?" 하는 의문이 생깁니다. 핵심 차이는 동작 시점 입니다.
| 구분 | Sass 변수 ($) | CSS Custom Properties (--) |
|---|---|---|
| 동작 시점 | 컴파일 타임 | 런타임 |
| 브라우저 인식 | X (일반 값으로 치환됨) | O (변수로 존재) |
| 동적 변경 | 불가능 | JavaScript, 미디어 쿼리로 변경 가능 |
| 스코프 | 블록 스코프 (Sass 규칙) | CSS 상속 규칙 |
| 조건부 값 | 불가능 | 미디어 쿼리 안에서 재선언 가능 |
/* Sass — 빌드하면 #3b82f6으로 치환, 런타임에 변경 불가 */
$primary: #3b82f6;
.button { background: $primary; }
/* CSS — 브라우저에 변수가 살아있어 동적 변경 가능 */
:root { --primary: #3b82f6; }
.button { background: var(--primary); }
실무에서는 Sass 변수로 CSS 변수를 초기화하는 패턴도 많이 사용합니다. 두 개가 경쟁 관계가 아니라 함께 쓸 수 있는 거예요.
/* Sass 변수로 CSS 변수를 초기화 */
$brand-blue: #3b82f6;
:root {
--color-primary: #{$brand-blue};
}
다크 모드 구현
CSS 변수의 진가는 테마 전환 에서 드러납니다. 구현 방식은 크게 세 가지가 있어요.
1. prefers-color-scheme (시스템 설정 기반)
OS의 다크 모드 설정을 자동 감지합니다.
/* 라이트 모드 (기본) */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f3f4f6;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
}
/* 다크 모드 — OS 설정 감지 */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #111827;
--bg-secondary: #1f2937;
--text-primary: #f9fafb;
--text-secondary: #9ca3af;
--border-color: #374151;
}
}
/* 컴포넌트는 변수만 참조 — 테마를 몰라도 됨 */
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
장점은 코드 한 줄 없이 OS 설정만으로 자동 전환된다는 것입니다. 하지만 사용자가 사이트 내에서 직접 테마를 선택할 수는 없어요.
2. class 토글 방식
HTML 요소에 클래스를 추가/제거하여 테마를 전환합니다.
/* 라이트 (기본) */
:root {
--bg-primary: #ffffff;
--text-primary: #1f2937;
}
/* 다크 — html에 .dark 클래스가 붙으면 전환 */
:root.dark {
--bg-primary: #111827;
--text-primary: #f9fafb;
}
// 테마 토글 버튼
const toggleButton = document.getElementById('theme-toggle');
toggleButton.addEventListener('click', () => {
document.documentElement.classList.toggle('dark');
// 사용자 선택 저장
const isDark = document.documentElement.classList.contains('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
3. data-theme 속성 방식 (추천)
data-* 속성을 활용하면 두 개 이상의 테마도 쉽게 확장할 수 있습니다.
/* 기본 테마 */
:root {
--bg-primary: #ffffff;
--text-primary: #1f2937;
--accent: #3b82f6;
}
/* 다크 테마 */
[data-theme="dark"] {
--bg-primary: #111827;
--text-primary: #f9fafb;
--accent: #60a5fa;
}
/* 추가 테마도 같은 패턴으로 확장 */
[data-theme="sepia"] {
--bg-primary: #f5f0e8;
--text-primary: #433422;
--accent: #8b6914;
}
// 테마 변경
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
// 초기 로드 시 저장된 테마 복원
const saved = localStorage.getItem('theme');
if (saved) {
setTheme(saved);
}
시스템 설정 감지 + 수동 토글을 함께 쓰는 게 가장 이상적입니다. 처음에는
prefers-color-scheme으로 시작하되, 사용자가 직접 바꾸면 그 선택을localStorage에 저장해서 우선 적용하는 방식이죠.
시스템 설정 + 수동 토글 조합
function getInitialTheme() {
// 1. 사용자가 직접 선택한 테마가 있으면 우선
const saved = localStorage.getItem('theme');
if (saved) return saved;
// 2. 없으면 OS 설정을 따름
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
}
// 페이지 로드 시 적용
document.documentElement.setAttribute('data-theme', getInitialTheme());
// OS 설정 변경 감지 (사용자가 직접 선택하지 않은 경우에만)
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
document.documentElement.setAttribute(
'data-theme',
e.matches ? 'dark' : 'light'
);
}
});
테마 시스템 설계 — 시맨틱 변수
색상값을 직접 변수에 넣는 것보다, 2단계 토큰 구조 로 설계하면 유지보수가 훨씬 편해집니다.
1단계: 원시 색상 토큰 (Primitive)
색상의 실제 값을 정의합니다. 테마에 독립적인 팔레트입니다.
:root {
/* 원시 색상 — 팔레트 */
--blue-50: #eff6ff;
--blue-500: #3b82f6;
--blue-600: #2563eb;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-800: #1f2937;
--gray-900: #111827;
}
2단계: 시맨틱 변수 (Semantic)
"이 색상이 어디에 쓰이는지"를 이름으로 표현합니다.
/* 라이트 테마 — 시맨틱 변수가 원시 토큰을 참조 */
:root {
--color-bg-primary: var(--gray-50);
--color-bg-secondary: var(--gray-100);
--color-bg-accent: var(--blue-500);
--color-text-primary: var(--gray-900);
--color-text-secondary: var(--gray-800);
--color-text-on-accent: #ffffff;
--color-border: var(--gray-100);
}
/* 다크 테마 — 시맨틱 변수만 재정의 */
[data-theme="dark"] {
--color-bg-primary: var(--gray-900);
--color-bg-secondary: var(--gray-800);
--color-bg-accent: var(--blue-600);
--color-text-primary: var(--gray-50);
--color-text-secondary: var(--gray-100);
--color-text-on-accent: #ffffff;
--color-border: var(--gray-800);
}
컴포넌트에서는 시맨틱 변수만 사용 합니다. --blue-500 같은 원시 토큰을 직접 쓰지 않아요.
/* 컴포넌트는 시맨틱 변수만 참조 */
.header {
background: var(--color-bg-primary);
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
}
.button-primary {
background: var(--color-bg-accent);
color: var(--color-text-on-accent);
}
이렇게 하면 테마를 바꿀 때 시맨틱 변수 매핑만 수정하면 되고, 컴포넌트 코드는 건드리지 않아도 됩니다.
JavaScript에서 CSS 변수 읽기/쓰기
CSS 변수는 런타임에 존재하기 때문에 JavaScript에서 자유롭게 다룰 수 있습니다.
읽기 — getComputedStyle
// :root에 선언된 변수 읽기
const root = document.documentElement;
const primaryColor = getComputedStyle(root)
.getPropertyValue('--color-bg-accent');
console.log(primaryColor); // " #3b82f6" (앞에 공백이 있을 수 있음)
// 공백 제거
const trimmed = primaryColor.trim();
쓰기 — setProperty
// :root의 변수 변경 — 전체 페이지에 영향
document.documentElement.style.setProperty('--color-bg-accent', '#10b981');
// 특정 요소의 변수 변경 — 해당 요소와 자손에만 영향
const card = document.querySelector('.card');
card.style.setProperty('--card-padding', '24px');
실전 예시 — 사용자 커스텀 테마
// 사용자가 색상 피커로 선택한 값을 바로 적용
const colorPicker = document.getElementById('accent-picker');
colorPicker.addEventListener('input', (e) => {
// 실시간으로 변수 업데이트
document.documentElement.style.setProperty(
'--color-bg-accent',
e.target.value
);
});
// 변수 제거 — 원래 스타일시트의 값으로 복원
document.documentElement.style.removeProperty('--color-bg-accent');
실전 패턴
컴포넌트별 변수 스코핑
전역 변수 외에 컴포넌트 내부에서만 쓰는 변수 를 따로 선언하면, 컴포넌트의 커스터마이징이 쉬워집니다.
/* 컴포넌트 내부 변수 — 외부에서 오버라이드 가능 */
.alert {
--alert-bg: var(--color-bg-secondary);
--alert-text: var(--color-text-primary);
--alert-border: var(--color-border);
--alert-padding: 16px;
--alert-radius: 8px;
background: var(--alert-bg);
color: var(--alert-text);
border: 1px solid var(--alert-border);
padding: var(--alert-padding);
border-radius: var(--alert-radius);
}
/* 변형 — 내부 변수만 재정의 */
.alert--error {
--alert-bg: #fef2f2;
--alert-text: #991b1b;
--alert-border: #fca5a5;
}
.alert--success {
--alert-bg: #f0fdf4;
--alert-text: #166534;
--alert-border: #86efac;
}
이 패턴은 외부에서도 해당 변수를 오버라이드 할 수 있다는 게 핵심입니다. 일종의 API 역할을 하는 거예요.
/* 특정 페이지에서 alert의 패딩만 변경 */
.compact-page .alert {
--alert-padding: 8px;
}
트랜지션과 함께 사용하기
테마 전환 시 부드러운 애니메이션을 적용할 수 있습니다.
/* 테마 전환 트랜지션 */
body {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
background: var(--color-bg-secondary);
border-color: var(--border-color);
transition: background-color 0.3s ease, border-color 0.3s ease;
}
transition은 CSS 변수 자체가 아니라, 변수를 사용하는 개별 속성 에 걸어야 합니다.transition: --color-bg-primary같은 건 동작하지 않아요.
calc()와 조합
CSS 변수는 calc() 안에서도 사용할 수 있어서 간격 시스템을 만들기 좋습니다.
:root {
--spacing-unit: 4px;
}
.container {
/* 4px * 4 = 16px */
padding: calc(var(--spacing-unit) * 4);
/* 4px * 6 = 24px */
margin-bottom: calc(var(--spacing-unit) * 6);
}
/* 반응형 — 모바일에서 간격 단위를 줄임 */
@media (max-width: 768px) {
:root {
--spacing-unit: 3px;
}
}
FOUC(Flash of Unstyled Content) 방지
JavaScript로 테마를 적용하면 페이지가 잠깐 기본 테마로 번쩍이는 현상이 생길 수 있습니다. <head> 안에 인라인 스크립트를 넣어서 방지합니다.
<head>
<script>
// 렌더링 전에 테마를 적용 — FOUC 방지
(function() {
const saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
이 스크립트는 DOM 파싱을 잠깐 블로킹하지만, 크기가 매우 작기 때문에 실질적인 성능 영향은 무시할 수 있는 수준입니다.
정리
- CSS Custom Properties는
--이름으로 선언하고var(--이름, 폴백)으로 사용한다 - CSS 상속을 따르기 때문에
:root에서 전역 선언, 하위 요소에서 오버라이드가 가능하다 - Sass 변수는 컴파일 타임, CSS 변수는 런타임 — 동적 테마 전환에는 CSS 변수가 필수
- 다크 모드는
prefers-color-scheme+data-theme조합이 가장 유연하다 - 원시 토큰 → 시맨틱 변수 2단계 구조로 설계하면 테마 확장이 쉬워진다
getComputedStyle/setProperty로 JavaScript에서 변수를 읽고 쓸 수 있다- FOUC 방지를 위해
<head>에 인라인 스크립트로 테마를 먼저 적용한다