Skip to Content
PatternsTheming

Theming

테마 및 다크 모드 구현 패턴입니다.

개요

Theming 패턴은 애플리케이션의 시각적 스타일을 동적으로 변경하고, 사용자 선호도에 따라 라이트/다크 모드를 지원하며, 브랜드 일관성을 유지하는 검증된 구현 방법입니다. CSS 변수, Tailwind 다크 모드, 테마 전환, 사용자 설정 저장 등 프로덕션 환경에서 필요한 모든 테마 관리 시나리오를 다룹니다.

사용 사례:

  • 라이트/다크 모드 전환
  • 브랜드 컬러 커스터마이징
  • 사용자 선호도 저장
  • 다중 브랜드 테마 (Foundation/iCignal/Cals)
  • 접근성 향상 (고대비 모드)

사용하지 말아야 할 때:

  • 단일 브랜드 고정 디자인
  • 사용자 선택 불필요
  • 정적 마케팅 페이지

기본 패턴

1. CSS 변수 기반 테마

CSS Custom Properties를 활용한 기본 테마 패턴입니다.

/* globals.css */ :root { /* Light Mode Colors */ --color-primary: 59 130 246; /* blue-500 */ --color-secondary: 107 114 128; /* gray-500 */ --color-background: 255 255 255; /* white */ --color-text: 17 24 39; /* gray-900 */ --color-border: 229 231 235; /* gray-200 */ } .dark { /* Dark Mode Colors */ --color-primary: 96 165 250; /* blue-400 */ --color-secondary: 156 163 175; /* gray-400 */ --color-background: 17 24 39; /* gray-900 */ --color-text: 243 244 246; /* gray-100 */ --color-border: 55 65 81; /* gray-700 */ } /* Tailwind에서 사용 */ .bg-primary { background-color: rgb(var(--color-primary)); } .text-primary { color: rgb(var(--color-primary)); }
// tailwind.config.js module.exports = { darkMode: "class", // 'media' 또는 'class' theme: { extend: { colors: { primary: "rgb(var(--color-primary) / <alpha-value>)", secondary: "rgb(var(--color-secondary) / <alpha-value>)", background: "rgb(var(--color-background) / <alpha-value>)", text: "rgb(var(--color-text) / <alpha-value>)", border: "rgb(var(--color-border) / <alpha-value>)", }, }, }, };

2. 다크 모드 토글

사용자가 라이트/다크 모드를 전환하는 패턴입니다.

"use client"; import { useEffect, useState } from "react"; import { Button } from "@vortex/ui-foundation"; export default function ThemeToggle() { const [theme, setTheme] = useState<"light" | "dark">("light"); useEffect(() => { // 저장된 테마 또는 시스템 선호도 로드 const savedTheme = localStorage.getItem("theme") as "light" | "dark"; const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") .matches ? "dark" : "light"; const initialTheme = savedTheme || systemTheme; setTheme(initialTheme); document.documentElement.classList.toggle("dark", initialTheme === "dark"); }, []); const toggleTheme = () => { const newTheme = theme === "light" ? "dark" : "light"; setTheme(newTheme); localStorage.setItem("theme", newTheme); document.documentElement.classList.toggle("dark", newTheme === "dark"); }; return ( <Button onClick={toggleTheme} variant="outline" aria-label={`${theme === "light" ? "다크" : "라이트"} 모드로 전환`} > {theme === "light" ? ( <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /> </svg> ) : ( <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /> </svg> )} <span className="ml-2"> {theme === "light" ? "다크 모드" : "라이트 모드"} </span> </Button> ); }

고급 패턴

3. Theme Context 구현

애플리케이션 전역 테마 관리 패턴입니다.

"use client"; import { createContext, useContext, useEffect, useState, ReactNode, } from "react"; type Theme = "light" | "dark" | "system"; interface ThemeContextType { theme: Theme; setTheme: (theme: Theme) => void; resolvedTheme: "light" | "dark"; } const ThemeContext = createContext<ThemeContextType | undefined>(undefined); export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setTheme] = useState<Theme>("system"); const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light"); useEffect(() => { // 초기 테마 로드 const savedTheme = (localStorage.getItem("theme") as Theme) || "system"; setTheme(savedTheme); // 시스템 테마 감지 const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleChange = () => { if (savedTheme === "system") { const newResolvedTheme = mediaQuery.matches ? "dark" : "light"; setResolvedTheme(newResolvedTheme); updateDOMTheme(newResolvedTheme); } }; handleChange(); // 초기 실행 mediaQuery.addEventListener("change", handleChange); return () => mediaQuery.removeEventListener("change", handleChange); }, []); const updateDOMTheme = (newTheme: "light" | "dark") => { document.documentElement.classList.remove("light", "dark"); document.documentElement.classList.add(newTheme); setResolvedTheme(newTheme); }; const handleSetTheme = (newTheme: Theme) => { setTheme(newTheme); localStorage.setItem("theme", newTheme); if (newTheme === "system") { const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") .matches ? "dark" : "light"; updateDOMTheme(systemTheme); } else { updateDOMTheme(newTheme); } }; return ( <ThemeContext.Provider value={{ theme, setTheme: handleSetTheme, resolvedTheme }} > {children} </ThemeContext.Provider> ); } export function useTheme() { const context = useContext(ThemeContext); if (context === undefined) { throw new Error("useTheme must be used within ThemeProvider"); } return context; } // 사용 예시 export function ThemeSelector() { const { theme, setTheme } = useTheme(); return ( <div className="flex gap-2"> <button onClick={() => setTheme("light")} className={`px-4 py-2 rounded ${ theme === "light" ? "bg-blue-600 text-white" : "bg-gray-200" }`} > 라이트 </button> <button onClick={() => setTheme("dark")} className={`px-4 py-2 rounded ${ theme === "dark" ? "bg-blue-600 text-white" : "bg-gray-200" }`} > 다크 </button> <button onClick={() => setTheme("system")} className={`px-4 py-2 rounded ${ theme === "system" ? "bg-blue-600 text-white" : "bg-gray-200" }`} > 시스템 </button> </div> ); }

4. 다중 브랜드 테마

Foundation/iCignal/Cals 브랜드별 테마 패턴입니다.

"use client"; import { createContext, useContext, useState, ReactNode } from "react"; type Brand = "foundation" | "icignal" | "cals"; interface BrandTheme { primary: string; secondary: string; accent: string; } const brandThemes: Record<Brand, BrandTheme> = { foundation: { primary: "rgb(59 130 246)", // blue-500 secondary: "rgb(107 114 128)", // gray-500 accent: "rgb(16 185 129)", // green-500 }, icignal: { primary: "rgb(33 150 243)", // iCignal Blue #2196f3 secondary: "rgb(76 175 80)", // iCignal Green #4caf50 accent: "rgb(255 152 0)", // iCignal Orange #ff9800 }, cals: { primary: "rgb(233 30 99)", // Cals Pink #e91e63 secondary: "rgb(3 169 244)", // Cals Blue #03a9f4 accent: "rgb(156 39 176)", // Cals Purple #9c27b0 }, }; const BrandContext = createContext< | { brand: Brand; setBrand: (brand: Brand) => void; theme: BrandTheme; } | undefined >(undefined); export function BrandThemeProvider({ children }: { children: ReactNode }) { const [brand, setBrand] = useState<Brand>("foundation"); useEffect(() => { const savedBrand = (localStorage.getItem("brand") as Brand) || "foundation"; setBrand(savedBrand); }, []); const handleSetBrand = (newBrand: Brand) => { setBrand(newBrand); localStorage.setItem("brand", newBrand); // CSS 변수 업데이트 const theme = brandThemes[newBrand]; document.documentElement.style.setProperty( "--color-primary", theme.primary ); document.documentElement.style.setProperty( "--color-secondary", theme.secondary ); document.documentElement.style.setProperty("--color-accent", theme.accent); }; return ( <BrandContext.Provider value={{ brand, setBrand: handleSetBrand, theme: brandThemes[brand] }} > {children} </BrandContext.Provider> ); } export function useBrand() { const context = useContext(BrandContext); if (!context) throw new Error("useBrand must be used within BrandThemeProvider"); return context; } // 사용 예시 export function BrandSelector() { const { brand, setBrand } = useBrand(); return ( <div className="flex gap-2"> <button onClick={() => setBrand("foundation")} className={`px-4 py-2 rounded ${ brand === "foundation" ? "bg-blue-600 text-white" : "bg-gray-200" }`} > Foundation </button> <button onClick={() => setBrand("icignal")} className={`px-4 py-2 rounded ${ brand === "icignal" ? "bg-blue-500 text-white" : "bg-gray-200" }`} > iCignal </button> <button onClick={() => setBrand("cals")} className={`px-4 py-2 rounded ${ brand === "cals" ? "bg-pink-500 text-white" : "bg-gray-200" }`} > Cals </button> </div> ); }

5. 테마 전환 애니메이션

부드러운 테마 전환 효과 패턴입니다.

"use client"; import { useEffect, useState } from "react"; export function useThemeTransition() { const [isTransitioning, setIsTransitioning] = useState(false); const transitionTheme = (callback: () => void) => { // View Transition API 지원 확인 if (!document.startViewTransition) { callback(); return; } setIsTransitioning(true); document .startViewTransition(() => { callback(); }) .finished.finally(() => { setIsTransitioning(false); }); }; return { transitionTheme, isTransitioning }; } // CSS 애니메이션 // globals.css /* ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.5s; } ::view-transition-old(root) { animation-name: fade-out; } ::view-transition-new(root) { animation-name: fade-in; } @keyframes fade-out { to { opacity: 0; } } @keyframes fade-in { from { opacity: 0; } } */ // 사용 예시 export function AnimatedThemeToggle() { const [theme, setTheme] = useState<"light" | "dark">("light"); const { transitionTheme } = useThemeTransition(); const toggleTheme = () => { transitionTheme(() => { const newTheme = theme === "light" ? "dark" : "light"; setTheme(newTheme); document.documentElement.classList.toggle("dark", newTheme === "dark"); localStorage.setItem("theme", newTheme); }); }; return ( <button onClick={toggleTheme} className="px-4 py-2 bg-primary text-white rounded-lg transition-colors" > {theme === "light" ? "다크 모드" : "라이트 모드"} </button> ); }

6. 고대비 모드

접근성을 위한 고대비 테마 패턴입니다.

"use client" import { useEffect, useState } from 'react' export function useHighContrast() { const [isHighContrast, setIsHighContrast] = useState(false) useEffect(() => { // 저장된 설정 로드 const saved = localStorage.getItem('highContrast') === 'true' setIsHighContrast(saved) updateDOM(saved) // 시스템 고대비 모드 감지 const mediaQuery = window.matchMedia('(prefers-contrast: high)') const handleChange = () => { const systemHighContrast = mediaQuery.matches setIsHighContrast(systemHighContrast) updateDOM(systemHighContrast) } mediaQuery.addEventListener('change', handleChange) return () => mediaQuery.removeEventListener('change', handleChange) }, []) const updateDOM = (enabled: boolean) => { document.documentElement.classList.toggle('high-contrast', enabled) } const toggle = () => { const newValue = !isHighContrast setIsHighContrast(newValue) localStorage.setItem('highContrast', String(newValue)) updateDOM(newValue) } return { isHighContrast, toggle } } // CSS // globals.css /* .high-contrast { --color-text: 0 0 0; /* 순수 검정 */ --color-background: 255 255 255; /* 순수 흰색 */ --color-border: 0 0 0; /* 순수 검정 */ } .high-contrast.dark { --color-text: 255 255 255; /* 순수 흰색 */ --color-background: 0 0 0; /* 순수 검정 */ --color-border: 255 255 255; /* 순수 흰색 */ } */ // 사용 예시 export function HighContrastToggle() { const { isHighContrast, toggle } = useHighContrast() return ( <button onClick={toggle} className="px-4 py-2 border-2 border-current rounded" aria-pressed={isHighContrast} > {isHighContrast ? '고대비 해제' : '고대비 활성화'} </button> ) }

Best Practices

✅ 권장 사항

  1. 시스템 선호도 존중

    • prefers-color-scheme 미디어 쿼리 사용
    • 초기 로드 시 시스템 설정 반영
    • 사용자 선택 우선
  2. 플래시 방지

    • HTML에 초기 테마 클래스 적용
    • <script> 태그로 블로킹 실행
    • SSR 시 쿠키/헤더 활용
  3. 접근성

    • 충분한 색상 대비 (4.5:1)
    • 고대비 모드 지원
    • 테마 변경 시 포커스 유지
  4. 성능

    • CSS 변수 활용 (재계산 최소화)
    • 전환 애니메이션 간결하게
    • 불필요한 리렌더링 방지
  5. 사용자 경험

    • 명확한 테마 전환 UI
    • 설정 저장 및 동기화
    • 자동 저장 (로그인 시)

⚠️ 피해야 할 것

  1. UX 문제

    • 테마 전환 시 플래시 (FOUC)
    • 느린 전환 애니메이션
    • 모든 컴포넌트 리렌더링
  2. 접근성 문제

    • 낮은 색상 대비
    • 색상만으로 의미 전달
    • 고대비 모드 미지원
  3. 성능 문제

    • 인라인 스타일 남용
    • 불필요한 Context 렌더링
    • 큰 CSS 번들

플래시 방지 (FOUC)

<!-- pages/_document.tsx --> <html> <head> <script dangerouslySetInnerHTML={{ __html: ` (function() { const theme = localStorage.getItem('theme') || 'system'; const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; const appliedTheme = theme === 'system' ? systemTheme : theme; document.documentElement.classList.add(appliedTheme); })(); ` }} /> </head> <body> <main /> </body> </html>

Foundation 예제

범용 다크 모드

Foundation 컴포넌트로 구현한 중립적인 다크 모드입니다.

import { useState } from "react"; import { Button, Card } from "@vortex/ui-foundation"; export default function FoundationTheme() { const [theme, setTheme] = useState("light"); return ( <div className={theme === "dark" ? "dark" : ""}> <div className="min-h-screen bg-background text-text p-4"> <Button onClick={() => setTheme(theme === "light" ? "dark" : "light")}> 테마 전환 </Button> <Card className="mt-4 p-6"> <h3 className="text-lg font-bold">다크 모드 카드</h3> <p>자동으로 색상이 변경됩니다</p> </Card> </div> </div> ); }

전체 예제 보기 →


iCignal 예제

iCignal 브랜드 테마

iCignal Blue 브랜드 컬러를 적용한 테마입니다.

import "@vortex/ui-icignal/theme"; import { Card } from "@vortex/ui-icignal"; export default function ISignalTheme() { return ( <div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4"> <Card className="p-6 border-blue-500"> <h3 className="text-xl font-bold text-blue-600 dark:text-blue-400"> iCignal Analytics </h3> <p className="text-gray-700 dark:text-gray-300"> 브랜드 컬러가 자동 적용됩니다 </p> </Card> </div> ); }

전체 예제 보기 →


Cals 예제

Cals 브랜드 테마

Cals Pink 브랜드 컬러를 적용한 테마입니다.

import "@vortex/ui-cals/theme"; import { Card, Badge } from "@vortex/ui-cals"; export default function CalsTheme() { return ( <div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4"> <Card className="p-6 border-pink-500"> <div className="flex items-center gap-2 mb-2"> <h3 className="text-xl font-bold text-pink-600 dark:text-pink-400"> Cals 예약 시스템 </h3> <Badge variant="confirmed">확정</Badge> </div> <p className="text-gray-700 dark:text-gray-300"> 예약 상태별 컬러가 다크 모드에서도 유지됩니다 </p> </Card> </div> ); }

전체 예제 보기 →


CodeSandbox

CodeSandbox 예제는 곧 제공될 예정입니다.

로컬에서 실행하기

  1. 프로젝트 생성

    npx @vortex/cli init my-theme-project --template next-app cd my-theme-project
  2. Tailwind 다크 모드 설정

    // tailwind.config.js module.exports = { darkMode: "class", // ... };
  3. 컴포넌트 추가

    # Foundation npx @vortex/cli add button card --package foundation # iCignal npx @vortex/cli add card --package icignal # Cals npx @vortex/cli add card badge --package cals
  4. 코드 복사 및 실행

    pnpm dev

관련 패턴

Last updated on