CSS 변수와 다크 모드 구현하기: 체계적인 테마 시스템 설계
하드코딩된 색상값을 CSS 변수로 체계화하고, 시스템 다크 모드 설정을 감지하며, 사용자 선호를 저장하는 테마 전환 시스템을 구현했습니다. 디자인 토큰 설계부터 실전 컴포넌트 예시까지 전체 과정을 공유합니다.
1. 문제 상황
증상: 스타일 파편화와 테마 전환 어려움
프로젝트가 커지면서 CSS가 점점 복잡해집니다:
/* 여기저기 흩어진 색상 값들 */
.header {
background: #6366f1; /* 기본 보라색 */
}
.button {
background: #6366f1; /* 같은 색... 맞나? */
}
.button:hover {
background: #4f46e5; /* 살짝 진한 보라색? */
}
.card {
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.another-card {
border-radius: 6px; /* 8px 아니었나? */
box-shadow: 0 2px 4px rgba(0,0,0,0.15); /* 그림자도 다름 */
}
문제점:
- 같은 값이 여러 곳에 하드코딩
- 다크 모드 추가 시 모든 색상을 개별 수정해야 함
- 일관성 없는 값들이 섞임
- 테마 전환 구현이 복잡해짐
목표
/* 한 곳에서 정의, 전체에서 사용 */
:root {
--color-primary: #6366f1;
}
.header { background: var(--color-primary); }
.button { background: var(--color-primary); }
/* 다크 모드? 변수만 재정의하면 끝 */
[data-theme="dark"] {
--color-primary: #818cf8;
}
2. CSS Custom Properties 기초
기본 문법
/* 변수 선언 */
:root {
--변수명: 값;
}
/* 변수 사용 */
.selector {
property: var(--변수명);
}
/* 폴백 값 (변수 없을 때 기본값) */
.selector {
property: var(--변수명, 기본값);
}
간단한 예시
:root {
--brand-color: #6366f1;
--spacing-md: 16px;
}
.header {
background: var(--brand-color);
padding: var(--spacing-md);
}
/* 폴백 활용 */
.container {
padding: var(--container-padding, 20px);
/* --container-padding이 정의되지 않으면 20px 사용 */
}
:root는 무엇인가?
/* :root = HTML 문서의 최상위 요소 (<html>) */
/* 전역 변수를 선언할 때 사용 */
:root {
--global-var: value;
}
/* html 선택자와 동일하지만 명시도(specificity)가 더 높음 */
SCSS 변수와의 차이
// SCSS 변수 (컴파일 타임에 치환됨)
$primary: #6366f1;
.btn { background: $primary; }
// 결과: .btn { background: #6366f1; }
// CSS 변수 (런타임에 동작)
:root { --primary: #6366f1; }
.btn { background: var(--primary); }
// 결과: .btn { background: var(--primary); }
// 브라우저가 실시간으로 해석
CSS 변수의 장점:
- 런타임에 JavaScript로 변경 가능
- 미디어 쿼리로 조건부 값 설정 가능
- 상속과 캐스케이딩 적용됨
- 빌드 도구 없이도 테마 전환 가능
3. 디자인 토큰 설계
토큰 분류 체계
디자인 토큰은 계층 구조로 관리합니다:
Primitive Tokens (원시 값)
└── Semantic Tokens (의미 있는 이름)
└── Component Tokens (컴포넌트별)
3.1 색상 시스템
:root {
/* ============================================
Primary Colors (브랜드/액션 색상)
============================================ */
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
--color-primary-dim: rgba(99, 102, 241, 0.15);
/* ============================================
Semantic Colors (의미 기반)
============================================ */
--color-success: #10b981;
--color-success-dim: rgba(16, 185, 129, 0.15);
--color-warning: #f59e0b;
--color-warning-dim: rgba(245, 158, 11, 0.15);
--color-danger: #ef4444;
--color-danger-dim: rgba(239, 68, 68, 0.15);
/* ============================================
Neutral Colors (배경, 텍스트, 테두리)
============================================ */
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #f3f4f6;
--bg-hover: #e5e7eb;
--text-primary: #24292f;
--text-secondary: #57606a;
--text-muted: #8b949e;
--text-disabled: #c9d1d9;
--border-default: #d0d7de;
--border-muted: #e5e7eb;
}
3.2 스페이싱 시스템
:root {
/* ============================================
Spacing (4px 기준 배수)
============================================ */
--space-1: 4px; /* 아주 작은 간격 */
--space-2: 8px; /* 작은 간격 */
--space-3: 12px; /* 기본 간격 */
--space-4: 16px; /* 중간 간격 */
--space-5: 20px; /* 약간 큰 간격 */
--space-6: 24px; /* 큰 간격 */
--space-8: 32px; /* 섹션 간격 */
}
4px 기준 시스템의 장점:
- 대부분의 디스플레이가 4의 배수에 최적화
- 일관된 시각적 리듬 생성
- Tailwind, Material Design 등 주요 시스템과 호환
3.3 타이포그래피
:root {
/* ============================================
Typography
============================================ */
--font-size-xs: 11px;
--font-size-sm: 13px;
--font-size-base: 14px;
--font-size-lg: 16px;
--font-size-xl: 18px;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
}
3.4 기타 토큰
:root {
/* Border Radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
/* Transitions */
--transition-fast: 150ms ease-out;
--transition-base: 200ms ease-out;
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
4. 다크 모드 테마 설계
테마 변수 재정의
data-theme 속성으로 테마를 구분합니다:
/* ============================================
Default: Light Theme
============================================ */
:root {
/* 배경 */
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #f3f4f6;
--bg-hover: #e5e7eb;
/* 텍스트 */
--text-primary: #24292f;
--text-secondary: #57606a;
--text-muted: #8b949e;
/* 테두리 */
--border-default: #d0d7de;
--border-muted: #e5e7eb;
/* 액센트 */
--color-primary: #0969da;
--color-primary-hover: #0550ae;
--color-primary-dim: rgba(9, 105, 218, 0.1);
/* 시맨틱 */
--color-danger: #cf222e;
--color-success: #1a7f37;
/* 그림자 */
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* ============================================
Dark Theme
============================================ */
[data-theme="dark"] {
/* 배경 */
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-hover: #30363d;
/* 텍스트 */
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
/* 테두리 */
--border-default: #30363d;
--border-muted: #21262d;
/* 액센트 (다크 모드에서 더 밝게) */
--color-primary: #58a6ff;
--color-primary-hover: #79c0ff;
--color-primary-dim: rgba(88, 166, 255, 0.15);
/* 시맨틱 */
--color-danger: #f85149;
--color-success: #3fb950;
/* 그림자 (더 강하게) */
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
}
컴포넌트에서 변수 사용
변수를 사용하면 테마 전환 시 자동으로 색상이 바뀝니다:
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-4);
box-shadow: var(--shadow-md);
}
.card-title {
color: var(--text-primary);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
}
.card-description {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.btn-primary {
background: var(--color-primary);
color: white;
transition: background var(--transition-fast);
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
5. 시스템 설정 연동 (prefers-color-scheme)
CSS만으로 시스템 테마 감지
/* 기본값: 라이트 테마 */
:root {
--bg-primary: #ffffff;
--text-primary: #24292f;
/* ... */
}
/* 시스템이 다크 모드일 때 자동 적용 */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #0d1117;
--text-primary: #e6edf3;
/* ... */
}
}
JavaScript로 시스템 설정 감지
// 현재 시스템 테마 확인
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
// 시스템 테마 변경 감지
function watchSystemTheme(callback) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
callback(e.matches ? 'dark' : 'light');
});
}
// 사용 예
watchSystemTheme((theme) => {
console.log('시스템 테마 변경:', theme);
});
6. 사용자 선호 저장과 테마 전환
테마 관리 모듈
const THEME_KEY = 'userTheme';
// 테마 초기화
async function initTheme() {
// 1. 저장된 사용자 선호 확인
const saved = await chrome.storage.local.get([THEME_KEY]);
if (saved[THEME_KEY]) {
// 저장된 테마 적용
setTheme(saved[THEME_KEY]);
} else {
// 시스템 설정 따르기
setTheme(getSystemTheme());
}
}
// 현재 시스템 테마 확인
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
// 테마 설정
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
// 현재 테마 가져오기
function getCurrentTheme() {
return document.documentElement.getAttribute('data-theme') || 'light';
}
// 테마 토글
async function toggleTheme() {
const current = getCurrentTheme();
const next = current === 'dark' ? 'light' : 'dark';
setTheme(next);
await chrome.storage.local.set({ [THEME_KEY]: next });
return next;
}
테마 토글 버튼 UI
<button id="themeToggle" class="btn-icon" aria-label="테마 변경">
<!-- 아이콘은 JS로 동적 생성 -->
</button>
const themeToggleBtn = document.getElementById('themeToggle');
// 아이콘 업데이트 (DOM API 사용)
function updateThemeIcon() {
const isDark = getCurrentTheme() === 'dark';
// 기존 아이콘 제거
themeToggleBtn.textContent = '';
// SVG 아이콘을 DOM API로 안전하게 생성
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '20');
svg.setAttribute('height', '20');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
if (isDark) {
// 해 아이콘 (다크 모드일 때 표시)
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', '12');
circle.setAttribute('cy', '12');
circle.setAttribute('r', '5');
svg.appendChild(circle);
// 추가 라인들...
} else {
// 달 아이콘 (라이트 모드일 때 표시)
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z');
svg.appendChild(path);
}
themeToggleBtn.appendChild(svg);
}
// 클릭 핸들러
themeToggleBtn.addEventListener('click', async () => {
await toggleTheme();
updateThemeIcon();
});
// 초기화
initTheme();
updateThemeIcon();
7. 페이지 간 테마 동기화
크롬 확장에서의 테마 동기화
여러 페이지 (popup, options 등)에서 테마를 동기화합니다:
// storage 변경 감지
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'local' && changes[THEME_KEY]) {
const newTheme = changes[THEME_KEY].newValue;
setTheme(newTheme);
updateThemeIcon();
}
});
완전한 테마 모듈
// theme.js - 재사용 가능한 테마 모듈
const ThemeManager = {
STORAGE_KEY: 'userTheme',
async init() {
// 저장된 테마 또는 시스템 테마 적용
const saved = await chrome.storage.local.get([this.STORAGE_KEY]);
const theme = saved[this.STORAGE_KEY] || this.getSystemTheme();
this.apply(theme);
// 스토리지 변경 감지 (다른 페이지에서 변경 시)
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes[this.STORAGE_KEY]) {
this.apply(changes[this.STORAGE_KEY].newValue);
}
});
},
getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
},
getCurrent() {
return document.documentElement.getAttribute('data-theme') || 'light';
},
apply(theme) {
document.documentElement.setAttribute('data-theme', theme);
// 커스텀 이벤트 발생 (UI 업데이트용)
window.dispatchEvent(new CustomEvent('themechange', { detail: theme }));
},
async toggle() {
const next = this.getCurrent() === 'dark' ? 'light' : 'dark';
this.apply(next);
await chrome.storage.local.set({ [this.STORAGE_KEY]: next });
return next;
},
async reset() {
// 시스템 설정으로 초기화
await chrome.storage.local.remove(this.STORAGE_KEY);
this.apply(this.getSystemTheme());
}
};
// 사용
ThemeManager.init();
document.getElementById('themeToggle').addEventListener('click', () => {
ThemeManager.toggle();
});
// 테마 변경 이벤트 리스닝
window.addEventListener('themechange', (e) => {
console.log('테마 변경됨:', e.detail);
updateThemeIcon(e.detail);
});
8. 실전 컴포넌트 예시
버튼 시스템
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-md);
border: 1px solid transparent;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-default);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-danger {
background: transparent;
color: var(--color-danger);
border-color: var(--color-danger);
}
.btn-danger:hover {
background: var(--color-danger);
color: white;
}
카드 컴포넌트
.card {
background: var(--bg-primary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-4);
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
}
.card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
.card-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-muted);
margin-bottom: var(--space-3);
}
.card-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.card-content {
color: var(--text-secondary);
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
}
토글 스위치
.toggle {
--toggle-width: 44px;
--toggle-height: 24px;
--toggle-padding: 2px;
--toggle-thumb-size: calc(var(--toggle-height) - var(--toggle-padding) * 2);
position: relative;
display: inline-block;
width: var(--toggle-width);
height: var(--toggle-height);
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-track {
position: absolute;
inset: 0;
background: var(--bg-tertiary);
border-radius: var(--radius-full);
transition: background var(--transition-base);
}
.toggle-thumb {
position: absolute;
top: var(--toggle-padding);
left: var(--toggle-padding);
width: var(--toggle-thumb-size);
height: var(--toggle-thumb-size);
background: white;
border-radius: 50%;
box-shadow: var(--shadow-sm);
transition: transform var(--transition-base);
}
.toggle input:checked + .toggle-track {
background: var(--color-primary);
}
.toggle input:checked + .toggle-track .toggle-thumb {
transform: translateX(calc(var(--toggle-width) - var(--toggle-thumb-size) - var(--toggle-padding) * 2));
}
9. 접근성 고려사항
색상 대비
WCAG 2.1 AA 기준: 텍스트는 최소 4.5:1 대비율 필요
:root {
/* 흰색 배경(#fff) 기준 대비율 */
--text-primary: #24292f; /* 대비율 12.6:1 - AAA */
--text-secondary: #57606a; /* 대비율 7.0:1 - AAA */
--text-muted: #8b949e; /* 대비율 3.9:1 - 본문 부적합 */
}
[data-theme="dark"] {
/* 어두운 배경(#0d1117) 기준 대비율 */
--text-primary: #e6edf3; /* 대비율 13.1:1 - AAA */
--text-secondary: #8b949e; /* 대비율 5.2:1 - AA */
--text-muted: #6e7681; /* 대비율 3.3:1 - 본문 부적합 */
}
Focus 상태
/* 키보드 네비게이션을 위한 명확한 포커스 표시 */
.btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* :focus-visible vs :focus
- :focus: 모든 포커스 (마우스 클릭 포함)
- :focus-visible: 키보드 포커스만 */
애니메이션 축소 설정 존중
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
10. JavaScript로 CSS 변수 조작
변수 읽기/쓰기
// CSS 변수 읽기
function getCSSVariable(name) {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
}
const primaryColor = getCSSVariable('--color-primary');
console.log(primaryColor); // "#6366f1"
// CSS 변수 쓰기
function setCSSVariable(name, value) {
document.documentElement.style.setProperty(name, value);
}
setCSSVariable('--color-primary', '#8b5cf6');
동적 테마 커스터마이징
// 사용자가 선택한 액센트 색상 적용
async function setAccentColor(color) {
setCSSVariable('--color-primary', color);
// hover 색상 자동 계산 (약간 어둡게)
const hoverColor = adjustBrightness(color, -15);
setCSSVariable('--color-primary-hover', hoverColor);
// 저장
await chrome.storage.local.set({ accentColor: color });
}
// 밝기 조절 유틸리티
function adjustBrightness(hex, percent) {
const num = parseInt(hex.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = Math.max(0, Math.min(255, (num >> 16) + amt));
const G = Math.max(0, Math.min(255, ((num >> 8) & 0x00FF) + amt));
const B = Math.max(0, Math.min(255, (num & 0x0000FF) + amt));
return `#${(1 << 24 | R << 16 | G << 8 | B).toString(16).slice(1)}`;
}
11. Before/After 비교
Before: 하드코딩된 스타일
.button {
background: #6366f1;
padding: 8px 16px;
border-radius: 6px;
transition: all 150ms ease;
}
.button:hover {
background: #4f46e5;
}
/* 다크 모드? 모든 색상을 개별 오버라이드... */
@media (prefers-color-scheme: dark) {
.button {
background: #818cf8;
}
.button:hover {
background: #a5b4fc;
}
.card {
background: #1f2937;
border-color: #374151;
/* 모든 컴포넌트 반복... */
}
}
After: CSS 변수 기반 디자인 토큰
.button {
background: var(--color-primary);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.button:hover {
background: var(--color-primary-hover);
}
/* 다크 모드? 변수만 재정의하면 끝 */
[data-theme="dark"] {
--color-primary: #818cf8;
--color-primary-hover: #a5b4fc;
--bg-primary: #0d1117;
--border-default: #30363d;
/* 모든 컴포넌트에 자동 적용! */
}
12. 베스트 프랙티스
DO (해야 할 것)
- 모든 색상, 간격, 크기를 변수로 정의
- 의미 있는 이름 사용 (
--color-dangernot--red) - 일관된 명명 규칙 (
--{category}-{name}) - 4px/8px 기준 스페이싱 시스템
- 호버/포커스 상태에도 변수 사용
- 테마별로 변수만 재정의
DON'T (하지 말아야 할 것)
- 색상값 직접 하드코딩
- 컴포넌트별로 다크 모드 스타일 개별 작성
- 의미 없는 이름 (
--var1,--color3) - 너무 많은 변수 (실제 사용하는 것만)
- 복잡한 calc() 중첩 남용
파일 구조 권장
styles/
_variables.css # CSS 변수 정의 (라이트/다크)
_base.css # 기본 요소 스타일
_components.css # 컴포넌트 스타일
main.css # 메인 파일 (@import)
또는 단일 파일에서 섹션으로 구분:
/* ============================================
1. Design Tokens (Variables)
============================================ */
/* ============================================
2. Dark Theme Overrides
============================================ */
/* ============================================
3. Base Styles
============================================ */
/* ============================================
4. Components
============================================ */
13. FAQ
Q: CSS 변수의 브라우저 지원은?
A: 모든 모던 브라우저에서 지원됩니다 (Chrome 49+, Firefox 31+, Safari 9.1+, Edge 15+). 크롬 확장이나 모던 웹 앱에서는 걱정할 필요 없습니다.
Q: 시스템 테마와 사용자 선택 중 어느 것이 우선?
A: 일반적으로 사용자 선택 > 시스템 설정 순서입니다. 사용자가 명시적으로 선택하면 그것을 존중하고, 선택하지 않았을 때만 시스템 설정을 따릅니다.
const savedTheme = await storage.get('theme');
const theme = savedTheme || getSystemTheme();
Q: 테마 전환 시 깜빡임(flash)이 발생해요
A: 페이지 로드 전에 테마를 적용해야 합니다. <head>에 인라인 스크립트를 추가하거나, CSS에서 기본 테마를 설정하세요.
<head>
<script>
// 가능한 빨리 테마 적용
const theme = localStorage.getItem('theme') ||
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
</script>
</head>
Q: CSS 변수로 애니메이션 할 수 있나요?
A: CSS 변수 자체는 애니메이션되지 않지만, @property로 등록하면 가능합니다:
@property --progress {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
.progress-bar {
--progress: 0;
width: calc(var(--progress) * 100%);
transition: --progress 500ms ease;
}
14. 참고 자료
- MDN: CSS Custom Properties
- MDN: prefers-color-scheme
- CSS-Tricks: A Complete Guide to Custom Properties
- Chrome Extension: Storage API
- WCAG 2.1 Contrast Requirements
시리즈 목차
- JavaScript URL 비교와 정규화
- Web Crypto API로 안전한 해싱 구현하기
- CSS 변수와 다크 모드 구현하기 ← 현재 글
- 크롬 확장 프로젝트 구조 정리하기