Tutorial

Theming Warna Dinamis di React: Melampaui Variabel CSS

Baca 5 menit

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);
}
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 @theme sehingga utilitas bg-brand mencerminkan 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.

Warna Terkait

Merek Terkait

Alat Terkait