Tutorial

Color Theming di React: CSS Variables dan Context

Baca 7 menit

Theming adalah salah satu masalah yang terlihat sederhana sampai Anda harus memeliharanya dalam skala besar. Toggle tunggal antara mode terang dan gelap cukup mudah. Produk yang melayani beberapa merek, menawarkan palet yang dapat dikustomisasi pengguna, atau perlu beralih tema secara instan tanpa reload halaman memerlukan arsitektur yang lebih disengaja.

Model komponen React dan CSS custom properties adalah pasangan alami untuk theming. CSS variables menangani nilai warna secara deklaratif; React Context mengelola state tema; dan Tailwind CSS (dalam proyek modern) menjembatani keduanya. Panduan ini mencakup setiap lapisan — cara menyusun arsitektur tema, mengimplementasikan fondasi CSS variable, menghubungkan state React, dan menangani skenario multi-brand.


Pola Arsitektur Tema

Pola 1: CSS Variables Saja (Tanpa JavaScript State)

Pola paling sederhana untuk dukungan dark mode dasar tidak memerlukan state React sama sekali. Media query prefers-color-scheme mengubah nilai custom property, dan setiap komponen diperbarui secara otomatis:

/* globals.css */
:root {
  --color-bg: #F8FAFC;
  --color-text: #1E293B;
  --color-brand: #2563EB;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0F172A;
    --color-text: #F1F5F9;
    --color-brand: #60A5FA;
  }
}

Komponen menggunakan var(--color-bg) dan tidak perlu tahu tentang tema saat ini. Browser menangani semuanya.

Kapan digunakan: Situs di mana Anda ingin menghormati preferensi tingkat OS dan tidak memerlukan toggle yang dikontrol pengguna.

Keterbatasan: Tidak ada cara bagi pengguna untuk mengganti pengaturan OS mereka dalam aplikasi.

Pola 2: Pergantian Tema Data Attribute

Tambahkan atribut data-theme pada elemen <html> untuk membuat tema aktif eksplisit dan dapat diganti. Ini kompatibel dengan SSR, menghindari flash tema yang salah, dan mudah dikombinasikan dengan persistensi localStorage:

/* Default: light */
[data-theme="light"],
:root {
  --color-bg: #F8FAFC;
  --color-text: #1E293B;
  --color-brand: #2563EB;
  --color-surface: #FFFFFF;
  --color-border: #E2E8F0;
}

/* Dark */
[data-theme="dark"] {
  --color-bg: #0F172A;
  --color-text: #F1F5F9;
  --color-brand: #60A5FA;
  --color-surface: #1E293B;
  --color-border: #334155;
}
// Terapkan tema sebelum render pertama (inline script di <head>)
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);

Kapan digunakan: Sebagian besar aplikasi produksi yang membutuhkan toggle yang dikontrol pengguna. Pendekatan data attribute adalah standar industri saat ini — digunakan oleh implementasi dark mode Tailwind CSS, Radix UI, dan sebagian besar sistem desain modern.

Pola 3: Pergantian Tema Berbasis Class

Mirip dengan data-attribute, tetapi menggunakan CSS class. Konfigurasi darkMode: 'class' Tailwind bergantung pada pola ini:

.dark {
  --color-bg: #0F172A;
  --color-text: #F1F5F9;
}

Menambahkan class dark ke <html> mengaktifkan nilai token dark. Tailwind kemudian menerapkan utilitas dengan prefix dark: ketika class ini ada.

Kapan digunakan: Proyek yang menggunakan Tailwind CSS dengan strategi dark mode class.


CSS Variables untuk Warna: Sistem Token

Penamaan Semantik vs. Penamaan Deskriptif

Keputusan terpenting dalam sistem token adalah penamaan. Penamaan berdasarkan nilai warna (--blue-500, --gray-900) membuat variabel mudah dipahami secara terpisah tetapi tidak mungkin di-theme — mengubah --blue-500 menjadi ungu merusak semantik namanya.

Penamaan berdasarkan peran semantik (--color-brand, --color-text-muted) memungkinkan nilai berubah sepenuhnya antar tema sementara komponen tetap benar:

:root {
  /* ---- Color Primitives (tidak digunakan langsung di komponen) ---- */
  --blue-500: #3B82F6;
  --blue-700: #1D4ED8;
  --slate-50: #F8FAFC;
  --slate-800: #1E293B;
  --slate-900: #0F172A;

  /* ---- Semantic Tokens (digunakan di komponen) ---- */
  /* Text */
  --text-primary: var(--slate-800);
  --text-secondary: #64748B;
  --text-disabled: #94A3B8;
  --text-inverse: var(--slate-50);

  /* Surfaces */
  --bg-base: var(--slate-50);
  --bg-elevated: #FFFFFF;
  --bg-sunken: #F1F5F9;

  /* Brand */
  --brand: var(--blue-700);
  --brand-hover: #1E40AF;
  --brand-subtle: #EFF6FF;
  --on-brand: #FFFFFF;

  /* Feedback */
  --error: #DC2626;
  --error-bg: #FEF2F2;
  --success: #16A34A;
  --success-bg: #F0FDF4;
  --warning: #D97706;
  --warning-bg: #FFFBEB;
}

[data-theme="dark"] {
  --text-primary: #F1F5F9;
  --text-secondary: #94A3B8;
  --text-disabled: #475569;
  --text-inverse: #0F172A;

  --bg-base: #0F172A;
  --bg-elevated: #1E293B;
  --bg-sunken: #0D1426;

  --brand: #60A5FA;
  --brand-hover: #93C5FD;
  --brand-subtle: #172554;
  --on-brand: #0F172A;

  --error: #F87171;
  --error-bg: #1C0A0A;
  --success: #4ADE80;
  --success-bg: #052E16;
  --warning: #FCD34D;
  --warning-bg: #1C1000;
}

Sistem dua lapisan (primitif + semantic tokens) memberikan yang terbaik dari kedua dunia: palet warna mentah sebagai referensi, dan semantic tokens untuk penggunaan komponen.

Membuat Skala Warna Anda

Sebelum membangun sistem token, Anda memerlukan palet warna mentah. Shade Generator menghasilkan skala penuh 50–950 dari satu warna merek — pola skala yang sama digunakan oleh Tailwind CSS. Masukkan kode hex merek Anda dan dapatkan set lengkap varian gelap-ke-terang yang siap digunakan sebagai primitive tokens.


React Context untuk Theme State

Pola ThemeContext

Untuk aplikasi dengan toggle tema yang dikontrol pengguna, React Context menyediakan lapisan manajemen state. Context menyimpan nama tema aktif dan mengekspos fungsi toggle:

// contexts/ThemeContext.tsx
import { createContext, useContext, useEffect, useState } from 'react';

type Theme = 'light' | 'dark' | 'system';

interface ThemeContextValue {
  theme: Theme;
  resolvedTheme: 'light' | 'dark';
  setTheme: (theme: Theme) => void;
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<Theme>(() => {
    if (typeof window === 'undefined') return 'system';
    return (localStorage.getItem('theme') as Theme) || 'system';
  });

  const systemPrefersDark = typeof window !== 'undefined'
    ? window.matchMedia('(prefers-color-scheme: dark)').matches
    : false;

  const resolvedTheme: 'light' | 'dark' =
    theme === 'system'
      ? (systemPrefersDark ? 'dark' : 'light')
      : theme;

  useEffect(() => {
    const root = document.documentElement;
    root.setAttribute('data-theme', resolvedTheme);
    localStorage.setItem('theme', theme);
  }, [theme, resolvedTheme]);

  useEffect(() => {
    if (theme !== 'system') return;
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = () => {
      document.documentElement.setAttribute(
        'data-theme',
        media.matches ? 'dark' : 'light'
      );
    };
    media.addEventListener('change', handler);
    return () => media.removeEventListener('change', handler);
  }, [theme]);

  const setTheme = (newTheme: Theme) => {
    setThemeState(newTheme);
  };

  return (
    <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx;
}

ThemeProvider membungkus aplikasi Anda dan hook useTheme mengekspos state tema di mana saja dalam pohon komponen:

// components/ThemeToggle.tsx
import { useTheme } from '../contexts/ThemeContext';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
      style={{
        background: 'var(--bg-elevated)',
        color: 'var(--text-primary)',
        border: '1px solid var(--color-border)',
        padding: '8px 16px',
        borderRadius: '8px',
        cursor: 'pointer',
      }}
    >
      {theme === 'dark' ? '☀ Light' : '☾ Dark'}
    </button>
  );
}

Mencegah Flash of Wrong Theme (FOTWT)

Aplikasi yang dirender sisi server menghadapi masalah flash tema yang salah: server merender HTML tanpa mengetahui preferensi tema pengguna, browser menampilkan HTML itu sebentar, kemudian React menghidrasi dan menerapkan tema yang benar — menyebabkan kilatan yang terlihat.

Solusinya adalah script inline yang memblokir di <head> yang membaca localStorage dan menerapkan atribut tema sebelum halaman dirender:

<!-- Di <head> Anda, sebelum link CSS apa pun -->
<script>
  (function() {
    try {
      var stored = localStorage.getItem('theme');
      var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      var theme = stored === 'dark' || stored === 'light'
        ? stored
        : (systemDark ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    } catch(e) {}
  })();
</script>

Tailwind CSS Themes

Tailwind v3: Dark Mode Berbasis Class

Di Tailwind v3, dark mode memerlukan pengaturan darkMode: 'class' di tailwind.config.js. Ini membuat Tailwind menerapkan utilitas dark: hanya ketika class .dark ada di elemen <html>:

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        brand: {
          50: '#EFF6FF',
          500: '#3B82F6',
          600: '#2563EB',
          700: '#1D4ED8',
          950: '#172554',
        },
      },
    },
  },
};

Komponen menggunakan prefix dark: untuk varian dark mode:

<div className="bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100">
  <button className="bg-brand-600 dark:bg-brand-500 text-white">
    Primary Action
  </button>
</div>

Tailwind v4: Konfigurasi CSS-First

Tailwind v4 memindahkan konfigurasi sepenuhnya ke CSS, menggunakan CSS custom properties secara native:

/* styles.css */
@import "tailwindcss";

@theme {
  --color-brand-50: #EFF6FF;
  --color-brand-500: #3B82F6;
  --color-brand-600: #2563EB;
  --color-brand-700: #1D4ED8;
}

Strategi Multi-Brand Theming

Tantangannya

Platform SaaS yang melayani beberapa klien, produk white-label, atau sistem desain yang dibagikan di beberapa merek produk perlu menangani tidak hanya varian gelap/terang tetapi identitas warna yang sepenuhnya berbeda — masing-masing dengan merek utama, aksen, warna status, dan netral mereka sendiri.

Brand Tokens sebagai CSS Variable Overrides

Pendekatan yang paling mudah dipelihara mendefinisikan semantic tokens yang tidak bergantung merek dalam stylesheet dasar, kemudian mengganti penugasan primitif per merek:

/* Tema dasar — nama token yang sama untuk semua merek */
:root {
  --brand-primary-raw: 37 99 235; /* #2563EB dalam channel RGB */
  --brand-primary: rgb(var(--brand-primary-raw));
  --brand-primary-hover: color-mix(in srgb, rgb(var(--brand-primary-raw)) 80%, black);
}

/* Merek: Acme Corp (biru) */
[data-brand="acme"] {
  --brand-primary-raw: 37 99 235;   /* #2563EB */
}

/* Merek: Globex Inc (ungu) */
[data-brand="globex"] {
  --brand-primary-raw: 124 58 237;  /* #7C3AED */
}

/* Merek: Initech (hijau) */
[data-brand="initech"] {
  --brand-primary-raw: 22 163 74;   /* #16A34A */
}

Menghasilkan Skala Merek dengan Shade Generator

Untuk setiap merek, Anda memerlukan skala warna lengkap. Gunakan Shade Generator untuk menghasilkan skala 50–950 untuk warna utama dan aksen setiap merek.


Poin-poin Utama

  • CSS custom properties adalah fondasinya: Definisikan semantic tokens (--text-primary, --brand-primary) dan ubah nilainya per tema, daripada menerapkan class secara kondisional di setiap komponen.
  • Pergantian tema data-attribute (data-theme="dark") adalah pola paling fleksibel — dapat diganti oleh JavaScript, kompatibel dengan SSR, dan dapat dikombinasikan dengan media query prefers-color-scheme.
  • Sistem token dua lapisan (skala warna primitif + semantic role tokens) memungkinkan fleksibilitas tema penuh sambil menjaga komponen tetap bersih.
  • Gunakan Shade Generator untuk menghasilkan skala warna lengkap 50–950 dari merek utama Anda.
  • React Context mengelola state tema (nama tema aktif) dan side effects (menyetel atribut data, menyimpan ke localStorage); komponen tidak perlu membaca context untuk menerapkan warna yang benar.
  • Cegah flash tema yang salah dengan script inline yang memblokir di <head> yang membaca localStorage dan menerapkan atribut tema sebelum browser merender piksel apa pun.
  • Integrasi Tailwind CSS: v3 menggunakan darkMode: 'class' dengan prefix dark:; v4 menggunakan konfigurasi @theme CSS-first dengan dukungan CSS variable native.
  • Multi-brand theming menumpuk atribut data lain (data-brand) di atas sistem tema, mengganti nilai primitive token per merek sambil menjaga semantic tokens dan komponen tidak berubah.

Warna Terkait

Merek Terkait

Alat Terkait