테마 시스템
Compound Theme 아키텍처 — 5개 프리셋 × light/dark 모드를 하나의 data-theme 속성으로 통합 관리합니다.
reopt design업데이트
개요
opt-ui의 테마 시스템은 프리셋(preset)과 모드(mode)를 합쳐서 하나의 compound theme name으로 관리합니다. 기본 통합에서는 OptThemeProvider가 상태를 관리하고, data-theme HTML 속성 하나로 프리셋과 라이트/다크 모드를 동시에 제어합니다.
| 프리셋 | Light | Dark |
|---|---|---|
| Default | default | default-dark |
| Minimal | minimal | minimal-dark |
| Natural | natural | 없음 (force-light) |
| Pro | pro | pro-dark |
| Mono Dark | mono-dark | mono-dark-dark |
CSS 변수 체계
모든 시각적 속성은 --opt-* 네임스페이스의 CSS 커스텀 프로퍼티로 정의됩니다. Tailwind v4의 @theme inline으로 유틸리티 클래스에 매핑되어 있어 bg-surface, text-text-primary, border-border 등을 직접 사용합니다.
| 카테고리 | CSS 변수 | Tailwind 유틸리티 |
|---|---|---|
| 배경 | --opt-bg, --opt-bg-subtle, --opt-bg-muted | bg-bg, bg-bg-subtle, bg-bg-muted |
| Surface | --opt-surface, --opt-surface-raised, --opt-surface-overlay | bg-surface, bg-surface-raised |
| Accent | --opt-accent, --opt-accent-hover, --opt-accent-subtle | bg-accent, text-accent, ring-accent |
| 텍스트 | --opt-text, --opt-text-secondary, --opt-text-tertiary | text-text-primary, text-text-secondary |
| 테두리 | --opt-border, --opt-border-hover, --opt-border-subtle | border-border, border-border-hover |
| 상태 | --opt-success, --opt-warning, --opt-danger, --opt-info | text-success, bg-danger-subtle |
| 차트 | --opt-chart-1 ~ --opt-chart-5 | fill-chart-1, stroke-chart-2 |
| 타이포그래피 | --opt-font-heading, --opt-font-body, --opt-tracking-heading, --opt-weight-heading | font-heading, font-sans (body) |
| 스페이싱 | --opt-space-section, --opt-space-group, --opt-space-element | gap-section, gap-group, gap-element |
Radius, Shadow, Spacing도 CSS 변수로 제어되어 프리셋 전환만으로 전체 룩앤필이 바뀝니다:
/* 프리셋별 오버라이드 예시 — Pro (Sharp/Dense) vs Mono Dark (OLED) */
[data-theme="pro"] { --opt-radius-md: 0.25rem; --opt-shadow-md: 0 2px 6px rgba(0,80,200,.08); }
[data-theme="mono-dark"] { --opt-radius-md: 0.25rem; --opt-space-section: 0.75rem; }
[data-theme="minimal"] { --opt-radius-md: 0.125rem; --opt-shadow-md: none; }프리셋별 차별화
각 프리셋은 색상뿐 아니라 배경 틴트, 스페이싱 밀도, 타이포그래피가 모두 달라 전환 시 완전히 다른 인터페이스처럼 보입니다.
| 프리셋 | 배경 틴트 | 밀도 (section / group / element) | 헤딩 폰트 | 본문 폰트 |
|---|---|---|---|---|
| Default | White | 1.5 / 1 / 0.5rem | Geist Sans | Geist Sans |
| Minimal | White | 1.5 / 1 / 0.5rem | Geist Sans | Geist Sans |
| Natural | Warm parchment | 1.75 / 1.25 / 0.625rem (넉넉) | Playfair Display | Lora |
| Pro | Cool gray | 0.875 / 0.5 / 0.25rem (초촘촘) | Geist Mono | Geist Sans |
| Mono Dark | OLED black | 0.75 / 0.5 / 0.25rem (고밀도) | Geist Mono | Geist Sans |
타이포그래피는 CSS 변수 기반이므로 h1~h6 태그에 자동 적용됩니다. globals.css 에서 다음과 같이 설정되어 있습니다:
body { font-family: var(--opt-font-body); }
h1, h2, h3, h4, h5, h6 {
font-family: var(--opt-font-heading);
letter-spacing: var(--opt-tracking-heading);
font-weight: var(--opt-weight-heading);
}dark: 변형의 동작 원리
Tailwind v4의 @custom-variant로 dark: 접두사를 오버라이드합니다. 기존 .dark 클래스 대신 data-theme이 -dark로 끝나는지 확인합니다.
/* globals.css */
@import "tailwindcss";
@custom-variant dark (&:where([data-theme$="-dark"], [data-theme$="-dark"] *));이로써 앱 전체의 dark: 유틸리티 클래스가 수정 없이 compound theme과 연동됩니다. CSS 프리셋 파일에서는 light/dark 셀렉터만 분리하면 됩니다:
/* presets/mono-dark.css */
[data-theme="mono-dark"] { --opt-bg: hsl(220 15% 98%); --opt-text: hsl(220 15% 12%); }
[data-theme="mono-dark-dark"] { --opt-bg: #000; --opt-text: hsl(220 12% 94%); }Provider 설정
기본 통합은 앱 루트에서 OptThemeProvider로 앱을 감싸는 방식입니다. provider가 프리셋/모드 상태를 관리하고, 계산된 compound theme 이름을 data-theme 속성에 반영합니다.
import { OptThemeProvider } from "@reopt-ai/opt-ui";
export function Providers({ children }: { children: React.ReactNode }) {
return <OptThemeProvider defaultPreset="default">{children}</OptThemeProvider>;
}useOptTheme API
useOptTheme() 훅은 프리셋과 모드를 읽고 변경하는 유일한 인터페이스입니다.
const {
preset, // "default" | "minimal" | "natural" | "pro" | "mono-dark"
setPreset, // (p: ThemePreset) => void
mode, // "light" | "dark" | "system"
setMode, // (m: ThemeMode) => void
resolvedMode, // "light" | "dark" — system 모드 해석 결과
} = useOptTheme();| 속성 | 타입 | 설명 |
|---|---|---|
preset | ThemePreset | 현재 선택된 프리셋 |
setPreset | (p: ThemePreset) => void | 프리셋 변경 (localStorage 자동 저장) |
mode | ThemeMode | 사용자가 선택한 모드 (light / dark / system) |
setMode | (m: ThemeMode) => void | 모드 변경 (localStorage 자동 저장) |
resolvedMode | "light" | "dark" | system 모드일 때 OS 설정을 반영한 최종 모드 |
사용 예시
ThemeSwitcher Shell은 프리셋 그리드와 모드 토글을 함께 제공하는 완성된 UI입니다. 직접 구현하려면 useOptTheme()를 사용합니다.
// 1. ThemeSwitcher Shell 사용 (가장 간단)
import { ThemeSwitcher } from "@reopt-ai/opt-ui";
<ThemeSwitcher /> // 프리셋 그리드 + 모드 토글
<ThemeSwitcher variant="compact" /> // 프리셋 스와치 + 모드 아이콘// 2. 커스텀 모드 토글 구현
import { Moon, Sun } from "lucide-react";
import { useOptTheme } from "@reopt-ai/opt-ui";
function ModeToggle() {
const { mode, setMode, resolvedMode } = useOptTheme();
return (
<button onClick={() => setMode(resolvedMode === "light" ? "dark" : "light")}>
{resolvedMode === "light" ? <Moon className="size-4" /> : <Sun className="size-4" />}
</button>
);
}// 3. Command Palette에서 모드 전환
import { useOptTheme } from "@reopt-ai/opt-ui";
const { setMode } = useOptTheme();
switch (cmd.id) {
case "theme-light": setMode("light"); break;
case "theme-dark": setMode("dark"); break;
}Force-light 프리셋
FORCE_LIGHT_PRESETS에 포함된 프리셋(현재 Natural)은 모드 설정과 무관하게 항상 라이트 모드로 렌더링됩니다. CSS 프리셋 파일에 dark 섹션이 없으며, compound theme 이름에 -dark 접미사가 붙지 않습니다.
import { FORCE_LIGHT_PRESETS } from "@reopt-ai/opt-ui";
// Natural 프리셋 여부 확인
if (FORCE_LIGHT_PRESETS.has(preset)) {
// 모드 토글 비활성화, 항상 light
}FOUC 방지
페이지 로드 시 테마 깜빡임(FOUC)을 방지하기 위해 <head>에 인라인 스크립트를 삽입합니다. 이 스크립트는 React 하이드레이션 전에 실행되어 data-theme 속성을 즉시 설정합니다.
// app/layout.tsx — <head> 안에 삽입
import { createThemeBootScript } from "@reopt-ai/opt-ui";
<script
dangerouslySetInnerHTML={{
__html: createThemeBootScript(),
}}
/>이 helper는 opt-preset, opt-mode, 레거시 opt-theme까지 읽어서 provider와 같은 규칙으로 compound theme 이름을 계산합니다.
새 프리셋 추가하기
새 테마 프리셋을 추가하려면 아래 5단계를 따릅니다:
- CSS 파일 생성:
packages/opt-ui/src/lib/theme/presets/my-theme.css에[data-theme="my-theme"]와[data-theme="my-theme-dark"]셀렉터 정의 - globals.css에 import:
@import ".../presets/my-theme.css"; - 타입 추가:
ThemePreset유니온에"my-theme"추가,CompoundTheme에"my-theme" | "my-theme-dark"추가 - next-themes interop를 쓴다면:
ALL_COMPOUND_THEMES배열에 두 compound 이름 추가 - Theme registry 추가: label, description, swatches 정의
force-light 프리셋으로 만들려면 CSS에 dark 셀렉터를 생략하고 FORCE_LIGHT_PRESETS Set에 추가합니다. 초기 렌더용 스크립트는 createThemeBootScript()가 같은 규칙을 자동으로 사용합니다.
저장소 & 마이그레이션
테마 설정은 localStorage에 두 개의 키로 저장됩니다:
| 키 | 값 | 기본값 |
|---|---|---|
opt-preset | ThemePreset | "default" |
opt-mode | ThemeMode | "system" |
기존 opt-theme 키는 첫 로드 시 자동으로 opt-preset으로 마이그레이션 됩니다. 쿠키는 더 이상 사용하지 않습니다.
Theme Builder
인터랙티브 Theme Builder를 사용하면 코드를 작성하지 않고도 53개 CSS 토큰을 실시간으로 편집할 수 있습니다. 5개 프리셋 중 하나를 기반으로 시작하여, 수정된 토큰만 포함된 커스텀 프리셋 CSS를 다운로드합니다.
| 기능 | 설명 |
|---|---|
| 라이브 미리보기 | 8개 탭(Essential, Forms, Data, Feedback, Nav, Type, Layers, Palette)으로 토큰 변경 효과를 즉시 확인 |
| 토큰 검색 & 필터 | 이름/변수명 검색, “변경된 토큰만” 필터로 빠르게 탐색 |
| 색상 그룹 스트립 | Accent, Status, Chart 색상을 그룹별 스워치로 한 눈에 편집. 인라인 HSL 슬라이더 지원 |
| WCAG 대비 표시 | 텍스트 색상 편집 시 Surface 배경 대비 AAA/AA/Fail 자동 계산 |
| Accent 자동 파생 | 기본 Accent 색상 변경 시 hover/active/subtle/fg 5개 토큰 자동 계산 |
| Undo / Redo | Ctrl+Z / Ctrl+Shift+Z (최대 50단계) |
| CSS 내보내기 | [data-theme="..."] 형식 CSS 다운로드 또는 클립보드 복사 |
| CSS 가져오기 | 기존 커스텀 프리셋 CSS 파일을 불러와서 계속 편집 |
키보드 단축키: C 코드 패널 토글, L/D 모드 전환, 1-6 프리셋 빠른 전환, Ctrl+S CSS 다운로드
내보내기(export) 참조
// @reopt-ai/opt-ui에서 사용 가능한 테마 관련 export
// 컴포넌트 & 훅
export { OptThemeProvider, useOptTheme } from "@reopt-ai/opt-ui";
export { ThemeSwitcher } from "@reopt-ai/opt-ui";
// 상수 & 유틸리티
export { FORCE_LIGHT_PRESETS } from "@reopt-ai/opt-ui"; // ReadonlySet<ThemePreset>
export { ALL_COMPOUND_THEMES } from "@reopt-ai/opt-ui"; // next-themes interop용 CompoundTheme[]
export { getCompoundTheme } from "@reopt-ai/opt-ui"; // (preset, mode) => CompoundTheme
export { createThemeBootScript } from "@reopt-ai/opt-ui"; // 초기 렌더용 data-theme script
// 타입
export type { ThemePreset } from "@reopt-ai/opt-ui"; // "default" | "minimal" | ... | "mono-dark"
export type { ThemeMode } from "@reopt-ai/opt-ui"; // "light" | "dark" | "system"
export type { CompoundTheme } from "@reopt-ai/opt-ui"; // "default" | "default-dark" | ...