튜토리얼

React의 동적 색상 테마 지정: CSS 변수 너머

9분 읽기

대부분의 테마 튜토리얼은 어두운 모드 토글에서 중지됩니다. <html>에서 클래스를 전환하고 몇 가지 CSS 변수를 뒤집는 작업이 완료되었습니다. 이는 일반적인 경우를 다루지만 더 흥미로운 문제를 놓치고 있습니다. 사용자가 자신의 브랜드 색상을 선택할 수 있다면 어떨까요? SaaS 제품이 각각 고유한 ID를 가진 여러 클라이언트에 서비스를 제공한다면 어떻게 될까요? 테마가 런타임 시 단일 입력 16진수 값에서 전체 조화로운 팔레트를 생성해야 한다면 어떻게 될까요?

이것이 바로 동적 색상 테마가 시작되는 곳입니다. CSS 변수만으로는 충분하지 않습니다. 런타임 색상 알고리즘, 탐색 후에도 유지되는 React 상태, 지속성 전략, 슬라이더가 움직일 때마다 전체 구성 요소 트리를 다시 렌더링하지 않는 아키텍처가 필요합니다.

이 가이드에서는 각 레이어를 자세히 다룹니다.


React 테마를 위한 CSS 변수 접근 방식

기준: 시맨틱 토큰 아키텍처

모든 동적 테마 시스템은 동일한 기반, 즉 색상 값이 아닌 의미적 역할에 따라 명명된 CSS 사용자 정의 속성으로 시작됩니다. 변수 --blue-500의 이름을 지정하지 마십시오. 이름을 --brand-primary로 지정합니다. 값은 변경될 수 있습니다. 역할은 다음을 수행할 수 없습니다.

/* globals.css */
:root {
  /* Semantic tokens — these are what components use */
  --bg-base: #F8FAFC;
  --bg-surface: #FFFFFF;
  --bg-sunken: #F1F5F9;

  --text-primary: #1E293B;
  --text-secondary: #64748B;
  --text-disabled: #94A3B8;

  --brand-primary: #2563EB;
  --brand-hover: #1D4ED8;
  --brand-subtle: #EFF6FF;
  --on-brand: #FFFFFF;

  --border-default: #E2E8F0;
  --border-strong: #CBD5E1;

  --status-error: #DC2626;
  --status-success: #16A34A;
  --status-warning: #D97706;
}

구성요소 참조 토큰, 원시 색상 아님:

.card {
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  color: var(--text-primary);
}

.btn-primary {
  background: var(--brand-primary);
  color: var(--on-brand);
}

.btn-primary:hover {
  background: var(--brand-hover);
}

JavaScript에서 --brand-primary를 업데이트하면 이를 참조하는 모든 구성 요소가 즉시 업데이트됩니다. React 재렌더링 없이, prop 드릴링 없이, 컨텍스트 구독 없이도 즉시 업데이트됩니다. CSS는 캐스케이드를 처리합니다.

JavaScript에서 토큰 작성

사용자 정의 속성을 설정하기 위한 DOM API는 간단합니다.

document.documentElement.style.setProperty('--brand-primary', '#7C3AED');
document.documentElement.style.setProperty('--brand-hover', '#6D28D9');
document.documentElement.style.setProperty('--brand-subtle', '#EDE9FE');
document.documentElement.style.setProperty('--on-brand', '#FFFFFF');

다시 읽어보기:

const brand = getComputedStyle(document.documentElement)
  .getPropertyValue('--brand-primary')
  .trim();

이것이 기계적 기초입니다. 어려운 부분은 단일 브랜드 헥스에서 전체 토큰 세트를 생성하는 것입니다.


색상 알고리즘을 사용한 런타임 테마 생성

수동 토큰 세트의 문제점

사용자가 #FF5733 또는 #0D9488와 같은 특이한 색상을 포함하여 모든 색상을 선택할 수 있는 경우 어두운 변형, 마우스 오버 상태 또는 브랜드 텍스트 색상을 하드코딩할 수 없습니다. 입력에서 이를 계산해야 합니다.

전체 토큰 세트 생성

다음은 브랜드 16진수를 사용하여 테마에 필요한 모든 토큰을 생성하는 완전한 기능입니다.

import chroma from 'chroma-js';

function generateThemeTokens(brandHex) {
  const brand = chroma(brandHex);

  // Determine if on-brand text should be black or white
  // Based on WCAG relative luminance
  const brandLuminance = brand.luminance();
  const onBrand = brandLuminance > 0.179 ? '#000000' : '#FFFFFF';

  // Generate a scale from light tint to dark shade in OKLCH
  // for perceptually uniform steps
  const scale = chroma.scale([
    chroma(brandHex).brighten(2.5).desaturate(0.5).hex(),
    brandHex,
    chroma(brandHex).darken(2.5).hex(),
  ]).mode('oklch').colors(11);

  return {
    '--brand-primary': brandHex,
    '--brand-hover': chroma(brandHex).darken(0.5).hex(),
    '--brand-active': chroma(brandHex).darken(1).hex(),
    '--brand-subtle': scale[1],   // Very light tint
    '--brand-muted': scale[2],    // Light tint
    '--on-brand': onBrand,

    // Scale primitives for complete palette access
    '--brand-50': scale[0],
    '--brand-100': scale[1],
    '--brand-200': scale[2],
    '--brand-300': scale[3],
    '--brand-400': scale[4],
    '--brand-500': scale[5],
    '--brand-600': scale[6],
    '--brand-700': scale[7],
    '--brand-800': scale[8],
    '--brand-900': scale[9],
    '--brand-950': scale[10],
  };
}

function applyTheme(brandHex) {
  const tokens = generateThemeTokens(brandHex);
  const root = document.documentElement;
  Object.entries(tokens).forEach(([prop, value]) => {
    root.style.setProperty(prop, value);
  });
}

접근 가능한 색상 쌍

임의의 사용자 입력으로 테마를 생성하는 경우 접근성을 가정할 수 없습니다. 사용자가 선택한 색상은 흰색에 비해 대비가 좋지 않을 수 있습니다. 다음 사항을 확인하고 조정해야 합니다.

function ensureContrast(foreground, background, minRatio = 4.5) {
  let color = chroma(foreground);
  let ratio = chroma.contrast(color.hex(), background);

  // If contrast is insufficient, darken the foreground until it passes
  let iterations = 0;
  while (ratio < minRatio && iterations < 20) {
    color = color.darken(0.2);
    ratio = chroma.contrast(color.hex(), background);
    iterations++;
  }

  return color.hex();
}

// For a brand-colored text label on white background
const safeLabel = ensureContrast('#FFD700', '#FFFFFF'); // Yellow adjusted to pass AA

흰색이나 기타 고정된 표면에 나타나는 텍스트 요소에 대한 토큰을 생성할 때 이를 적용합니다.

보색 및 강조 색상

팔레트 생성기는 단일 기본 색상이 완전한 조화 계열을 생성하는 방법을 보여줍니다. 색조 회전을 사용하여 코드에서 해당 논리를 복제할 수 있습니다.

function generateComplementaryAccent(brandHex) {
  const hsl = chroma(brandHex).hsl();
  // Rotate hue 180° for complement, shift lightness to maintain balance
  const complementHue = (hsl[0] + 180) % 360;
  return chroma.hsl(complementHue, hsl[1] * 0.9, hsl[2]).hex();
}

function generateAnalogousAccent(brandHex, degrees = 30) {
  const hsl = chroma(brandHex).hsl();
  const analogousHue = (hsl[0] + degrees) % 360;
  return chroma.hsl(analogousHue, hsl[1], hsl[2]).hex();
}

테마 상태 관리를 위한 React 컨텍스트

ThemeContext 아키텍처

테마 컨텍스트는 사용자 상호 작용(색상 선택, 다크 모드 전환)과 DOM 수준 토큰 애플리케이션 간의 다리 역할을 합니다. 얇아야 합니다. 상태를 저장하고 이를 DOM에 적용하며 setter를 노출합니다. 색상 계산은 컨텍스트 외부에서 발생합니다.

// contexts/ThemeContext.tsx
import {
  createContext,
  useContext,
  useEffect,
  useState,
  useCallback,
  type ReactNode,
} from 'react';
import { generateThemeTokens } from '../lib/theme-generator';

interface ThemeState {
  brandHex: string;
  mode: 'light' | 'dark' | 'system';
}

interface ThemeContextValue extends ThemeState {
  setBrand: (hex: string) => void;
  setMode: (mode: 'light' | 'dark' | 'system') => void;
  resolvedMode: 'light' | 'dark';
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

const STORAGE_KEY = 'app-theme';

function getStoredTheme(): ThemeState {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (stored) return JSON.parse(stored) as ThemeState;
  } catch {}
  return { brandHex: '#2563EB', mode: 'system' };
}

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<ThemeState>(getStoredTheme);

  const systemDark =
    typeof window !== 'undefined'
      ? window.matchMedia('(prefers-color-scheme: dark)').matches
      : false;

  const resolvedMode: 'light' | 'dark' =
    state.mode === 'system'
      ? systemDark ? 'dark' : 'light'
      : state.mode;

  // Apply CSS tokens whenever state changes
  useEffect(() => {
    const tokens = generateThemeTokens(state.brandHex);
    const root = document.documentElement;
    Object.entries(tokens).forEach(([prop, value]) => {
      root.style.setProperty(prop, value as string);
    });
    root.setAttribute('data-theme', resolvedMode);
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }, [state, resolvedMode]);

  // React to system preference changes
  useEffect(() => {
    if (state.mode !== 'system') return;
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e: MediaQueryListEvent) => {
      document.documentElement.setAttribute(
        'data-theme',
        e.matches ? 'dark' : 'light',
      );
    };
    media.addEventListener('change', handler);
    return () => media.removeEventListener('change', handler);
  }, [state.mode]);

  const setBrand = useCallback((hex: string) => {
    setState(prev => ({ ...prev, brandHex: hex }));
  }, []);

  const setMode = useCallback((mode: 'light' | 'dark' | 'system') => {
    setState(prev => ({ ...prev, mode }));
  }, []);

  return (
    <ThemeContext.Provider value={{ ...state, setBrand, setMode, resolvedMode }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx;
}

색상 선택기 구성요소

컨텍스트가 있으면 색상 선택기는 얇은 UI 레이어가 됩니다.

// components/BrandColorPicker.tsx
import { useTheme } from '../contexts/ThemeContext';
import { useState, useRef } from 'react';

export function BrandColorPicker() {
  const { brandHex, setBrand } = useTheme();
  const [localHex, setLocalHex] = useState(brandHex);
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setLocalHex(value);

    // Debounce the expensive token generation
    clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => {
      if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
        setBrand(value);
      }
    }, 150);
  };

  return (
    <div className="flex items-center gap-3">
      <label htmlFor="brand-picker" className="text-sm font-medium">
        Brand Color
      </label>
      <div className="relative">
        <input
          id="brand-picker"
          type="color"
          value={localHex}
          onChange={handleChange}
          className="sr-only"
        />
        <label
          htmlFor="brand-picker"
          className="block w-8 h-8 rounded-md cursor-pointer ring-2 ring-offset-2"
          style={{
            backgroundColor: localHex,
            // ring color uses the brand token
            '--tw-ring-color': 'var(--brand-primary)',
          } as React.CSSProperties}
          aria-label={`Current brand color: ${localHex}`}
        />
      </div>
      <input
        type="text"
        value={localHex}
        onChange={handleChange}
        className="w-24 px-2 py-1 text-sm font-mono border rounded"
        placeholder="#2563EB"
        aria-label="Brand color hex value"
      />
    </div>
  );
}

150ms 디바운스가 중요합니다. 이 기능이 없으면 generateThemeTokens는 16진수 입력을 입력할 때 모든 키 입력에서 실행되므로 불필요하게 비용이 많이 듭니다.


Tailwind CSS 동적 색상 클래스

Tailwind 및 런타임 색상에 대한 과제

Tailwind는 빌드 시 유틸리티 클래스를 생성합니다. bg-brand-500와 같은 클래스는 Tailwind의 스캐너가 소스 파일에서 찾은 경우에만 스타일시트에 존재합니다. 런타임에 결정된 색상(예: 사용자가 방금 선택한 색상)은 스타일시트에 포함되지 않습니다.

두 가지 솔루션이 있으며 서로 다른 요구 사항을 충족합니다.

해결 방법 1: CSS 변수 브리지

가장 깔끔한 접근 방식은 CSS 사용자 정의 속성을 가리키는 작은 Tailwind 테마 토큰 세트를 정의한 다음 런타임에 해당 속성의 값을 제어하는 ​​것입니다.

/* styles.css (Tailwind v4) */
@import "tailwindcss";

@theme {
  --color-brand: var(--brand-primary);
  --color-brand-hover: var(--brand-hover);
  --color-brand-subtle: var(--brand-subtle);
  --color-on-brand: var(--on-brand);
}

이제 bg-brand, text-brandhover:bg-brand-hover--brand-primary가 런타임에 보유하는 모든 값을 반영하는 유효한 Tailwind 클래스입니다.

<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
  Primary Action
</button>

JavaScript를 통해 --brand-primary를 변경하면 bg-brand로 모든 요소가 즉시 업데이트되므로 다시 렌더링할 필요가 없습니다.

솔루션 2: 일회성 동적 색상을 위한 인라인 스타일

구성 요소에 고유한 런타임 색상(예: 팔레트 뷰어의 색상 견본)이 필요한 경우 인라인 스타일을 사용하세요. Tailwind의 bg-[#FF5733] 임의 값 구문은 빌드 시 알려진 정적 값에 대해 작동하지만 JSX의 템플릿 리터럴 내부 bg-[${dynamicHex}]에는 작동하지 않습니다. 클래스는 스타일시트에 존재하지 않습니다.

// Correct for truly dynamic colors:
<div
  style={{ backgroundColor: swatch.hex }}
  className="w-8 h-8 rounded"
  aria-label={swatch.name}
/>

// This will NOT work at runtime (class not in stylesheet):
<div className={`bg-[${swatch.hex}]`} /> // Wrong

사용자 색상 기본 설정 유지

세션 간 지속성을 위한 localStorage

위의 ThemeContext는 이미 useEffectlocalStorage에 씁니다. SSR 호환성의 핵심 패턴은 상태를 느리게 초기화하는 것입니다. 즉, 서버 렌더링 중에는 절대 localStorage를 클라이언트에서만 읽는 함수를 useState에 전달하는 것입니다.

const [state, setState] = useState<ThemeState>(() => {
  // This function only runs on the client, after hydration
  if (typeof window === 'undefined') {
    return { brandHex: '#2563EB', mode: 'system' };
  }
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    return stored ? JSON.parse(stored) : { brandHex: '#2563EB', mode: 'system' };
  } catch {
    return { brandHex: '#2563EB', mode: 'system' };
  }
});

로드 시 기본 테마 플래시 방지

서버 렌더링 앱(Next.js App Router)의 경우 구성 요소 트리는 브라우저에 도달하기 전에 서버에서 렌더링됩니다. 기본 테마로 초기화하면 사용자 정의 테마를 저장한 사용자에게 깜박임이 표시됩니다. 지속되는 보라색이 로드되기 전에 기본 파란색이 잠시 나타납니다.

해결책은 브라우저가 픽셀을 렌더링하기 전에 저장된 테마를 적용하는 <head>의 차단 인라인 스크립트입니다.

// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                try {
                  var stored = JSON.parse(localStorage.getItem('app-theme') || '{}');
                  var brand = stored.brandHex || '#2563EB';
                  var mode = stored.mode || 'system';
                  var resolvedMode = mode === 'system'
                    ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
                    : mode;
                  document.documentElement.setAttribute('data-theme', resolvedMode);
                  // Minimal inline tokens to prevent flash
                  document.documentElement.style.setProperty('--brand-primary', brand);
                } catch(e) {}
              })();
            `,
          }}
        />
      </head>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

인라인 스크립트는 가장 중요한 토큰(브랜드 색상, 테마 모드 속성)만 동기적으로 적용합니다. 전체 토큰 세트는 수화 후 ThemeProvider에 의해 적용됩니다. 그러나 주요 시각적 토큰이 이미 정확하므로 사용자에게는 플래시가 표시되지 않습니다.

쿠키를 통한 서버측 지속성

인증된 앱의 경우 테마를 쿠키(서버에서 읽을 수 있음)에 저장하면 SSR이 첫 번째 바이트부터 올바른 테마로 활성화됩니다.

// middleware.ts (Next.js)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const brandHex = request.cookies.get('brand-hex')?.value ?? '#2563EB';
  const mode = request.cookies.get('theme-mode')?.value ?? 'system';

  // Pass theme to the page via response headers or search params
  const response = NextResponse.next();
  response.headers.set('x-brand-hex', brandHex);
  response.headers.set('x-theme-mode', mode);
  return response;
}

루트 layout.tsx 서버 구성 요소의 헤더를 읽고 초기 테마를 ThemeProvider에 소품으로 전달합니다. 공급자는 서버에 알려진 테마로 초기화하여 플래시를 완전히 제거합니다.


접근 가능한 다크 모드 변형 생성

브랜드 색상에서 밝은 테마를 생성하는 경우 어두운 모드 버전도 필요합니다. 과제: 밝은 배경에서 잘 작동하는 동일한 브랜드 육각형(예: #2563EB)은 어두운 표면에서 WCAG 대비 요구 사항을 충족하지 못하는 경우가 많습니다.

강력한 접근 방식은 동일한 브랜드 입력에서 두 개의 토큰 세트(밝은 색, 어두운 색)를 생성합니다.

function generateDarkTokens(brandHex) {
  const brand = chroma(brandHex);
  const hsl = brand.hsl();

  // For dark mode, lighten the brand to ensure contrast against dark surfaces
  const darkBrand = chroma.hsl(hsl[0], hsl[1] * 0.9, Math.max(0.55, hsl[2] + 0.15));

  return {
    '[data-theme="dark"]': {
      '--brand-primary': darkBrand.hex(),
      '--brand-hover': darkBrand.lighten(0.3).hex(),
      '--brand-subtle': darkBrand.darken(2).desaturate(0.5).hex(),
      '--on-brand': '#000000', // Often black on lightened brand in dark mode
      '--bg-base': '#0F172A',
      '--bg-surface': '#1E293B',
      '--text-primary': '#F1F5F9',
      '--text-secondary': '#94A3B8',
    },
  };
}

data-theme="dark" 속성 패턴을 통해 다크 토큰을 적용합니다. ThemeProviderdocument.documentElement.setAttribute('data-theme', 'dark')를 설정하면 CSS 캐스케이드가 다크 모드 토큰 값을 적용합니다.

생성된 색상 쌍이 WCAG를 통과하는지 확인하려면 팔레트 생성기를 사용하여 전체 크기를 시각화하고 단계 간 명암비를 확인하세요.


주요 내용

  • CSS 사용자 정의 속성이 기초입니다. 색상 값이 아닌 역할별로 이름이 지정된 시맨틱 토큰을 사용하면 구성 요소를 수정하지 않고도 전체 테마를 변경할 수 있습니다.
  • 단일 브랜드 16진수에서 런타임 토큰을 생성하려면 명도/어두움 규모, 브랜드 텍스트에 대한 WCAG 대비 확인, 어두운 모드 변형에 대한 별도 계산이 필요합니다.
  • React 컨텍스트는 테마 상태(활성 브랜드 16진수, 밝은/어두운 모드)를 관리하고 부작용으로 DOM에 토큰을 적용합니다. 구성 요소는 색상을 적용하기 위해 테마 컨텍스트를 구독할 필요가 없습니다.
  • 색상 선택기나 슬라이더와 같은 연속 입력에 연결된 경우 비용이 많이 드는 색상 계산(150ms)을 디바운싱합니다.
  • Tailwind CSS의 경우: @theme에서 CSS 변수 브리지를 정의하여 bg-brand 유틸리티가 런타임 토큰 값을 반영하도록 합니다. 정말로 고유한 요소별 동적 색상에만 인라인 스타일을 사용하세요.
  • 렌더링 전에 가장 중요한 토큰을 적용하는 <head>의 동기 차단 스크립트를 사용하여 기본 테마의 플래시를 방지합니다.
  • 사용자별 테마가 있는 SSR 앱의 경우 서버가 올바른 초기 HTML을 렌더링할 수 있도록 테마를 쿠키에 저장합니다.
  • Shade Generator를 사용하여 전체 50-950 범위에 대해 생성된 색상 스케일을 검사하고 검증하고, Palette Generator를 사용하여 전체 테마 팔레트의 조화와 대비 비율을 확인합니다.

관련 색상

관련 브랜드

관련 도구