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
✅ 권장 사항
-
시스템 선호도 존중
prefers-color-scheme미디어 쿼리 사용- 초기 로드 시 시스템 설정 반영
- 사용자 선택 우선
-
플래시 방지
- HTML에 초기 테마 클래스 적용
<script>태그로 블로킹 실행- SSR 시 쿠키/헤더 활용
-
접근성
- 충분한 색상 대비 (4.5:1)
- 고대비 모드 지원
- 테마 변경 시 포커스 유지
-
성능
- CSS 변수 활용 (재계산 최소화)
- 전환 애니메이션 간결하게
- 불필요한 리렌더링 방지
-
사용자 경험
- 명확한 테마 전환 UI
- 설정 저장 및 동기화
- 자동 저장 (로그인 시)
⚠️ 피해야 할 것
-
UX 문제
- 테마 전환 시 플래시 (FOUC)
- 느린 전환 애니메이션
- 모든 컴포넌트 리렌더링
-
접근성 문제
- 낮은 색상 대비
- 색상만으로 의미 전달
- 고대비 모드 미지원
-
성능 문제
- 인라인 스타일 남용
- 불필요한 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 예제는 곧 제공될 예정입니다.
로컬에서 실행하기
-
프로젝트 생성
npx @vortex/cli init my-theme-project --template next-app cd my-theme-project -
Tailwind 다크 모드 설정
// tailwind.config.js module.exports = { darkMode: "class", // ... }; -
컴포넌트 추가
# 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 -
코드 복사 및 실행
pnpm dev
관련 패턴
- Accessibility Patterns - 접근 가능한 테마
- State Management - 테마 상태 관리
- Performance Optimization - 테마 전환 최적화
Last updated on