Theming Warna Dinamis di React: Melampaui Variabel CSS
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.
Sebagian besar tutorial theming berhenti di toggle mode gelap. Ganti kelas pada <html>, balik beberapa variabel CSS, selesai. Itu mencakup kasus umum tetapi melewatkan masalah yang lebih menarik: bagaimana jika pengguna dapat memilih warna merek mereka sendiri? Bagaimana jika produk SaaS Anda melayani beberapa klien, masing-masing dengan identitas mereka sendiri? Bagaimana jika tema perlu menghasilkan seluruh palet harmonis dari satu nilai hex input pada saat runtime?
Di situlah theming warna dinamis dimulai — dan variabel CSS saja tidak cukup. Anda memerlukan algoritma warna runtime, status React yang bertahan saat navigasi, strategi persistensi, dan arsitektur yang tidak me-render ulang seluruh pohon komponen setiap kali slider bergerak.
Panduan ini mencakup setiap lapisan tersebut secara mendalam.
Pendekatan Variabel CSS untuk Tema React
Dasar: Arsitektur Token Semantik
Setiap sistem tema dinamis dimulai dengan fondasi yang sama: properti kustom CSS yang dinamai berdasarkan peran semantiknya, bukan nilai warnanya. Jangan namai variabel --blue-500. Namai --brand-primary. Nilainya bisa berubah; perannya tidak:
/* globals.css */
:root {
/* Token semantik — ini yang digunakan komponen */
--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;
}
Komponen mereferensikan token, bukan warna mentah:
.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);
}
Menulis Token dari JavaScript
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');
Generasi Tema Runtime dengan Algoritma Warna
Masalah dengan Set Token Manual
Jika pengguna dapat memilih warna apa saja — termasuk yang tidak biasa seperti #FF5733 atau #0D9488 — Anda tidak dapat meng-hardcode varian gelap, status hover, atau warna teks on-brand. Anda perlu menghitungnya dari input.
Menghasilkan Set Token Lengkap
import chroma from 'chroma-js';
function generateThemeTokens(brandHex) {
const brand = chroma(brandHex);
const brandLuminance = brand.luminance();
const onBrand = brandLuminance > 0.179 ? '#000000' : '#FFFFFF';
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],
'--brand-muted': scale[2],
'--on-brand': onBrand,
'--brand-50': scale[0],
'--brand-100': scale[1],
// ...
'--brand-950': scale[10],
};
}
Pasangan Warna Aksesibel
Saat menghasilkan tema dari input pengguna yang sembarang, aksesibilitas tidak bisa diasumsikan. Anda perlu memeriksa dan menyesuaikan:
function ensureContrast(foreground, background, minRatio = 4.5) {
let color = chroma(foreground);
let ratio = chroma.contrast(color.hex(), background);
let iterations = 0;
while (ratio < minRatio && iterations < 20) {
color = color.darken(0.2);
ratio = chroma.contrast(color.hex(), background);
iterations++;
}
return color.hex();
}
const safeLabel = ensureContrast('#FFD700', '#FFFFFF');
React Context untuk Manajemen Status Tema
Arsitektur ThemeContext
// 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';
export function ThemeProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ThemeState>(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return JSON.parse(stored) as ThemeState;
} catch {}
return { brandHex: '#2563EB', mode: 'system' };
});
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;
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]);
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;
}
Kelas Warna Dinamis Tailwind CSS
Tantangan dengan Tailwind dan Warna Runtime
Tailwind menghasilkan kelas utilitas saat build time. Pendekatan terbersih adalah mendefinisikan set kecil token tema Tailwind yang mengarah ke properti kustom CSS, lalu mengontrol nilai properti tersebut saat runtime:
/* 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);
}
Sekarang bg-brand, text-brand, dan hover:bg-brand-hover adalah kelas Tailwind yang valid yang mencerminkan nilai --brand-primary saat runtime:
<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
Aksi Utama
</button>
Untuk warna runtime yang benar-benar unik (seperti swatch warna), gunakan inline style:
<div
style={{ backgroundColor: swatch.hex }}
className="w-8 h-8 rounded"
aria-label={swatch.name}
/>
Mempertahankan Preferensi Warna Pengguna
localStorage untuk Persistensi Antar Sesi
const [state, setState] = useState<ThemeState>(() => {
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' };
}
});
Mencegah Kilatan Tema Default saat Muat
// 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);
document.documentElement.style.setProperty('--brand-primary', brand);
} catch(e) {}
})();
`,
}}
/>
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
Menghasilkan Varian Mode Gelap yang Aksesibel
function generateDarkTokens(brandHex) {
const brand = chroma(brandHex);
const hsl = brand.hsl();
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',
'--bg-base': '#0F172A',
'--bg-surface': '#1E293B',
'--text-primary': '#F1F5F9',
'--text-secondary': '#94A3B8',
},
};
}
Poin Utama
- Properti kustom CSS adalah fondasi — token semantik yang dinamai berdasarkan peran, bukan nilai warna, memungkinkan perubahan tema lengkap tanpa memodifikasi komponen.
- Generasi token runtime dari satu hex merek memerlukan: skala terang/gelap, pemeriksaan kontras WCAG untuk teks on-brand, dan komputasi terpisah untuk varian mode gelap.
- React context mengelola status tema dan menerapkan token ke DOM sebagai efek samping — komponen tidak perlu berlangganan context tema untuk menerapkan warna.
- Debounce perhitungan warna mahal (150ms) saat terhubung ke input kontinu seperti color picker atau slider.
- Untuk Tailwind CSS: tentukan jembatan variabel CSS di
@themesehingga utilitasbg-brandmencerminkan nilai token runtime. - Cegah kilatan tema default dengan skrip pemblokiran sinkron di
<head>yang menerapkan token paling kritis sebelum render. - Gunakan Generator Warna untuk memeriksa dan memvalidasi skala warna yang dihasilkan, dan Generator Palet untuk memverifikasi harmoni dan rasio kontras.