React에서의 색상 테마: CSS 변수와 Context
Embed This Widget
Add the script tag and a data attribute to embed this widget.
Embed via iframe for maximum compatibility.
<iframe src="https://colorfyi.com/iframe/entity//" width="420" height="400" frameborder="0" style="border:0;border-radius:10px;max-width:100%" loading="lazy"></iframe>
Paste this URL in WordPress, Medium, or any oEmbed-compatible platform.
https://colorfyi.com/entity//
Add a dynamic SVG badge to your README or docs.
[](https://colorfyi.com/entity//)
Use the native HTML custom element.
테마는 규모에서 유지 관리해야 할 때까지 단순해 보이는 문제 중 하나입니다. 라이트와 다크 모드 사이의 단일 토글은 간단합니다. 여러 브랜드를 서비스하거나, 사용자 맞춤 팔레트를 제공하거나, 페이지 리로드 없이 즉시 테마를 전환해야 하는 제품은 더 신중한 아키텍처가 필요합니다.
React의 컴포넌트 모델과 CSS 커스텀 속성은 테마를 위한 자연스러운 페어링입니다. CSS 변수는 선언적으로 색상 값을 처리하고, React Context는 테마 상태를 관리하며, Tailwind CSS(현대 프로젝트에서)는 두 가지를 연결합니다. 이 가이드는 각 레이어를 다룹니다. 테마 아키텍처 구조 방법, CSS 변수 기반 구현, React 상태 연결, 멀티 브랜드 시나리오 처리.
테마 아키텍처 패턴
패턴 1: CSS 변수만 사용 (JavaScript 상태 없음)
기본적인 다크 모드 지원을 위한 가장 간단한 패턴은 React 상태가 전혀 필요하지 않습니다. prefers-color-scheme 미디어 쿼리가 커스텀 속성 값을 변경하고 모든 컴포넌트가 자동으로 업데이트됩니다:
/* globals.css */
:root {
--color-bg: #F8FAFC;
--color-text: #1E293B;
--color-brand: #2563EB;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0F172A;
--color-text: #F1F5F9;
--color-brand: #60A5FA;
}
}
컴포넌트는 var(--color-bg)를 사용하고 현재 테마에 대해 알 필요가 없습니다. 브라우저가 모든 것을 처리합니다.
사용 시기: OS 수준의 기본 설정을 존중하고 사용자 제어 토글이 필요 없는 사이트.
한계: 사용자가 앱 내에서 OS 설정을 재정의할 방법이 없습니다.
패턴 2: 데이터 속성 테마 전환
<html> 요소에 data-theme 속성을 추가하여 활성 테마를 명시적이고 재정의 가능하게 만드세요. SSR과 호환되고, 잘못된 테마의 플래시를 피하며, localStorage 지속성과 사소하게 결합됩니다:
/* 기본: 라이트 */
[data-theme="light"],
:root {
--color-bg: #F8FAFC;
--color-text: #1E293B;
--color-brand: #2563EB;
--color-surface: #FFFFFF;
--color-border: #E2E8F0;
}
/* 다크 */
[data-theme="dark"] {
--color-bg: #0F172A;
--color-text: #F1F5F9;
--color-brand: #60A5FA;
--color-surface: #1E293B;
--color-border: #334155;
}
// 첫 번째 페인트 전에 테마 적용 (<head>의 인라인 스크립트)
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
사용 시기: 사용자 제어 토글이 필요한 대부분의 프로덕션 앱. 데이터 속성 접근 방식은 현재 업계 표준입니다. Tailwind CSS의 다크 모드 구현, Radix UI, 대부분의 현대 디자인 시스템에서 사용됩니다.
패턴 3: 클래스 기반 테마 전환
데이터 속성과 유사하지만 CSS 클래스를 사용합니다. Tailwind의 darkMode: 'class' 설정은 이 패턴에 의존합니다:
.dark {
--color-bg: #0F172A;
--color-text: #F1F5F9;
}
<html>에 dark 클래스를 추가하면 다크 토큰 값이 활성화됩니다. 그러면 Tailwind는 이 클래스가 있을 때 dark: 접두사 유틸리티를 적용합니다.
사용 시기: class 다크 모드 전략으로 Tailwind CSS를 사용하는 프로젝트.
색상을 위한 CSS 변수: 토큰 시스템
설명적 명명보다 시맨틱 명명
토큰 시스템에서 가장 중요한 결정은 명명입니다. 색상 값으로 명명하기(--blue-500, --gray-900)는 변수를 독립적으로 이해하기 쉽지만 테마를 적용하기 불가능합니다. --blue-500을 보라로 변경하면 이름 시맨틱이 깨집니다.
시맨틱 역할로 명명하기(--color-brand, --color-text-muted)는 컴포넌트가 올바른 상태를 유지하면서 테마 전반에 걸쳐 값을 완전히 변경할 수 있습니다:
:root {
/* ---- 색상 기본 (컴포넌트에서 직접 사용하지 않음) ---- */
--blue-500: #3B82F6;
--blue-700: #1D4ED8;
--slate-50: #F8FAFC;
--slate-800: #1E293B;
--slate-900: #0F172A;
/* ---- 시맨틱 토큰 (컴포넌트에서 사용) ---- */
/* 텍스트 */
--text-primary: var(--slate-800);
--text-secondary: #64748B;
--text-disabled: #94A3B8;
--text-inverse: var(--slate-50);
/* 서피스 */
--bg-base: var(--slate-50);
--bg-elevated: #FFFFFF;
--bg-sunken: #F1F5F9;
/* 브랜드 */
--brand: var(--blue-700);
--brand-hover: #1E40AF;
--brand-subtle: #EFF6FF;
--on-brand: #FFFFFF;
/* 피드백 */
--error: #DC2626;
--error-bg: #FEF2F2;
--success: #16A34A;
--success-bg: #F0FDF4;
--warning: #D97706;
--warning-bg: #FFFBEB;
}
[data-theme="dark"] {
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-disabled: #475569;
--text-inverse: #0F172A;
--bg-base: #0F172A;
--bg-elevated: #1E293B;
--bg-sunken: #0D1426;
--brand: #60A5FA;
--brand-hover: #93C5FD;
--brand-subtle: #172554;
--on-brand: #0F172A;
--error: #F87171;
--error-bg: #1C0A0A;
--success: #4ADE80;
--success-bg: #052E16;
--warning: #FCD34D;
--warning-bg: #1C1000;
}
이중 레이어 시스템(기본 색상 + 시맨틱 토큰)은 두 가지 장점을 제공합니다: 참조를 위한 원시 색상 팔레트와 컴포넌트 사용을 위한 시맨틱 토큰.
색상 스케일 생성
토큰 시스템을 구축하기 전에 원시 색상 팔레트가 필요합니다. 쉐이드 생성기는 단일 브랜드 색상에서 전체 50–950 스케일을 생성합니다. Tailwind CSS에서 사용하는 것과 동일한 스케일 패턴입니다. 브랜드 헥스 코드를 입력하면 기본 토큰으로 사용할 준비가 된 완전한 어두운-밝은 변형 세트를 얻습니다.
예를 들어, 브랜드 파란색으로 #2563EB를 입력하면 다음을 생성합니다:
blue-50: #EFF6FFblue-100: #DBEAFEblue-500: #3B82F6blue-700: #1D4ED8blue-900: #1E3A8Ablue-950: #172554
이 기본 값들이 시맨틱 토큰을 채워 다크 모드 색상이 라이트 모드 색상과 조화롭게 관련되도록 합니다.
테마 상태를 위한 React Context
ThemeContext 패턴
사용자 제어 테마 토글이 있는 앱의 경우, React Context가 상태 관리 레이어를 제공합니다. 컨텍스트는 활성 테마 이름을 저장하고 토글 함수를 노출합니다:
// contexts/ThemeContext.tsx
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark'; // 실제 적용된 테마 ('system' 해결)
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
});
const systemPrefersDark = typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
const resolvedTheme: 'light' | 'dark' =
theme === 'system'
? (systemPrefersDark ? 'dark' : 'light')
: theme;
useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', resolvedTheme);
localStorage.setItem('theme', theme);
}, [theme, resolvedTheme]);
// theme === 'system'일 때 시스템 기본 설정 변경 감지
useEffect(() => {
if (theme !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
document.documentElement.setAttribute(
'data-theme',
media.matches ? 'dark' : 'light'
);
};
media.addEventListener('change', handler);
return () => media.removeEventListener('change', handler);
}, [theme]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
};
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme는 ThemeProvider 내에서 사용해야 합니다');
return ctx;
}
ThemeProvider는 애플리케이션(또는 관련 서브트리)을 감싸고 useTheme 훅은 컴포넌트 트리 어디에서나 테마 상태를 노출합니다:
// components/ThemeToggle.tsx
import { useTheme } from '../contexts/ThemeContext';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label={`${theme === 'dark' ? '라이트' : '다크'} 모드로 전환`}
style={{
background: 'var(--bg-elevated)',
color: 'var(--text-primary)',
border: '1px solid var(--color-border)',
padding: '8px 16px',
borderRadius: '8px',
cursor: 'pointer',
}}
>
{theme === 'dark' ? '라이트' : '다크'}
</button>
);
}
컴포넌트는 시각적 스타일을 위해 CSS 커스텀 속성을 사용합니다. 색상을 적용하기 위해 resolvedTheme을 읽을 필요가 없습니다. 컨텍스트는 현재 테마 상태를 표시하거나 제어하는 UI에만 필요합니다.
잘못된 테마 플래시 방지 (FOTWT)
서버사이드 렌더링 앱은 잘못된 테마 플래시 문제에 직면합니다: 서버는 사용자의 테마 기본 설정을 모르고 HTML을 렌더링하고, 브라우저는 잠시 그 HTML을 표시한 다음 React가 수화되어 올바른 테마를 적용합니다. 이것은 눈에 보이는 플래시를 일으킵니다.
해결책은 페이지가 렌더링되기 전에 localStorage를 읽고 테마 속성을 적용하는 <head>의 블로킹 인라인 스크립트입니다:
<!-- <head>에서, CSS 링크 전에 -->
<script>
(function() {
try {
var stored = localStorage.getItem('theme');
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored === 'dark' || stored === 'light'
? stored
: (systemDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch(e) {}
})();
</script>
Next.js에서 이것은 _document.tsx 또는 App Router의 루트 layout.tsx에 들어갑니다. 이 스크립트는 CSS가 파싱되기 전에 동기적으로 실행되므로 플래시가 없습니다.
Tailwind CSS 테마
Tailwind v3: 클래스 기반 다크 모드
Tailwind v3에서 다크 모드는 tailwind.config.js에 darkMode: 'class'를 설정해야 합니다. 이렇게 하면 Tailwind가 <html> 요소에 .dark 클래스가 있을 때만 dark: 유틸리티를 적용합니다:
// tailwind.config.js
module.exports = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#EFF6FF',
100: '#DBEAFE',
500: '#3B82F6',
600: '#2563EB',
700: '#1D4ED8',
900: '#1E3A8A',
950: '#172554',
},
},
},
},
};
컴포넌트는 다크 모드 변형에 dark: 접두사를 사용합니다:
<div className="bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100">
<button className="bg-brand-600 dark:bg-brand-500 text-white hover:bg-brand-700 dark:hover:bg-brand-400">
기본 액션
</button>
</div>
테마 토글은 document.documentElement에 .dark 클래스를 설정합니다. 데이터 속성 패턴과 동일한 접근 방식이지만 클래스를 사용합니다.
Tailwind v4: CSS 우선 설정
Tailwind v4는 CSS 커스텀 속성을 네이티브로 사용하여 모든 설정을 CSS로 이동시킵니다:
/* styles.css */
@import "tailwindcss";
@theme {
--color-brand-50: #EFF6FF;
--color-brand-100: #DBEAFE;
--color-brand-500: #3B82F6;
--color-brand-600: #2563EB;
--color-brand-700: #1D4ED8;
}
Tailwind v4는 이 커스텀 속성에서 유틸리티 클래스를 생성하며 JSX에서 즉시 사용 가능합니다: className="bg-brand-600 text-white". v4의 다크 모드 설정은 @variant dark를 사용합니다:
@variant dark (&:where([data-theme="dark"] *)) {
/* Tailwind는 이 선택자를 기반으로 dark: 유틸리티를 적용합니다 */
}
이 변형은 Tailwind에게 조상이 data-theme="dark"를 가질 때 dark: 유틸리티를 적용하도록 지시하며, 데이터 속성 패턴과 네이티브로 통합됩니다.
멀티 브랜드 테마 전략
도전 과제
여러 클라이언트를 서비스하는 SaaS 플랫폼, 화이트 레이블 제품, 또는 여러 제품 브랜드에서 공유되는 디자인 시스템은 다크/라이트 변형뿐만 아니라 완전히 다른 색상 정체성(각각 자체 브랜드 기본 색상, 악센트, 상태 색상, 중성)을 처리해야 합니다.
CSS 변수 재정의로서의 브랜드 토큰
가장 유지 관리 가능한 접근 방식은 기본 스타일시트에서 브랜드 불가지론적 시맨틱 토큰을 정의하고 브랜드당 기본 색상 할당을 재정의합니다:
/* 기본 테마 — 모든 브랜드에서 동일한 토큰 이름 */
:root {
/* 브랜드 기본 색상 — 브랜드당 재정의 */
--brand-primary-raw: 37 99 235; /* #2563EB (RGB 채널) */
--brand-accent-raw: 99 102 241; /* #6366F1 (RGB 채널) */
/* 시맨틱 토큰 — 기본 색상에서 계산 */
--brand-primary: rgb(var(--brand-primary-raw));
--brand-primary-hover: color-mix(in srgb, rgb(var(--brand-primary-raw)) 80%, black);
--brand-primary-subtle: color-mix(in srgb, rgb(var(--brand-primary-raw)) 10%, white);
}
/* 브랜드: Acme Corp (파랑) */
[data-brand="acme"] {
--brand-primary-raw: 37 99 235; /* #2563EB */
--brand-accent-raw: 16 185 129; /* #10B981 */
}
/* 브랜드: Globex Inc (보라) */
[data-brand="globex"] {
--brand-primary-raw: 124 58 237; /* #7C3AED */
--brand-accent-raw: 245 158 11; /* #F59E0B */
}
/* 브랜드: Initech (초록) */
[data-brand="initech"] {
--brand-primary-raw: 22 163 74; /* #16A34A */
--brand-accent-raw: 239 68 68; /* #EF4444 */
}
원시 RGB 채널 기법(37 99 235)을 사용하면 추가 토큰 없이 불투명도 변형이 가능합니다:
.overlay {
background-color: rgba(var(--brand-primary-raw), 0.1);
}
React 멀티 브랜드 Context
브랜드를 처리하도록 테마 컨텍스트를 확장합니다:
// contexts/ThemeContext.tsx
type Brand = 'acme' | 'globex' | 'initech' | 'default';
type Theme = 'light' | 'dark';
interface ThemeContextValue {
theme: Theme;
brand: Brand;
setTheme: (theme: Theme) => void;
setBrand: (brand: Brand) => void;
}
export function ThemeProvider({ initialBrand = 'default', children }: {
initialBrand?: Brand;
children: React.ReactNode;
}) {
const [theme, setTheme] = useState<Theme>('light');
const [brand, setBrand] = useState<Brand>(initialBrand);
useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
root.setAttribute('data-brand', brand);
}, [theme, brand]);
return (
<ThemeContext.Provider value={{ theme, brand, setTheme, setBrand }}>
{children}
</ThemeContext.Provider>
);
}
라우팅이나 인증 레이어에서 초기 브랜드를 설정합니다. 각 클라이언트의 서브도메인이나 테넌트 ID가 브랜드 식별자에 매핑됩니다.
쉐이드 생성기로 브랜드 스케일 생성
각 브랜드에 대해 한두 개의 헥스 값뿐만 아니라 완전한 색상 스케일이 필요합니다. 쉐이드 생성기를 사용하여 각 브랜드의 기본 색상과 악센트 색상에 대해 50–950 스케일을 생성하세요. 브랜드의 기본 헥스를 입력하고 어두운에서 밝은 변형의 전체 범위를 얻으세요.
기본 #7C3AED가 있는 Globex Inc의 경우 쉐이드 생성기가 전체 보라 스케일을 생성합니다. #16A34A가 있는 Initech의 경우 전체 초록 스케일. 이 스케일들은 각 브랜드의 기본 토큰을 채워서 --brand-primary-subtle(가장 밝은 색조)과 --brand-primary-hover(더 어두운 누른 상태)가 각 브랜드 정체성 내에서 조화롭게 일관되도록 합니다.
핵심 요약
- CSS 커스텀 속성이 기반입니다: 모든 컴포넌트에서 조건부로 클래스를 적용하는 대신 시맨틱 토큰(
--text-primary,--brand-primary)을 정의하고 테마당 값을 변경하세요. - 데이터 속성 테마 전환(
data-theme="dark")이 가장 유연한 패턴입니다. JavaScript로 재정의 가능하고, SSR과 호환되며,prefers-color-scheme미디어 쿼리와 결합 가능합니다. - 이중 레이어 토큰 시스템(기본 색상 스케일 + 시맨틱 역할 토큰)은 컴포넌트를 깔끔하게 유지하면서 완전한 테마 유연성을 허용합니다: 기본 색상이 팔레트를 정의하고, 시맨틱이 색상이 사용되는 방식을 정의합니다.
- 쉐이드 생성기를 사용하여 브랜드 기본 색상에서 완전한 50–950 색상 스케일을 생성하세요. 철저한 토큰 시스템에 필요한 라이트 및 다크 변형의 전체 범위를 제공합니다.
- React Context는 테마 상태(활성 테마 이름)와 사이드 이펙트(데이터 속성 설정, localStorage 지속)를 관리합니다. 컴포넌트는 올바른 색상을 적용하기 위해 컨텍스트를 읽을 필요가 없습니다.
- 잘못된 테마 플래시 방지: 브라우저가 픽셀을 렌더링하기 전에
localStorage를 읽고 테마 속성을 적용하는<head>의 블로킹 인라인 스크립트로. - Tailwind CSS 통합: v3는
dark:접두사와 함께darkMode: 'class'를 사용합니다. v4는 네이티브 CSS 변수 지원이 있는 CSS 우선@theme설정을 사용합니다. - 멀티 브랜드 테마는 테마 시스템 위에 다른 데이터 속성(
data-brand)을 쌓아 브랜드당 기본 토큰 값을 재정의하면서 시맨틱 토큰과 컴포넌트는 변경 없이 유지합니다.