บทเรียนแนะนำ

การกำหนดธีมสีแบบไดนามิกใน React: เกินกว่า CSS Variables

อ่าน 2 นาที

บทช่วยสอนการกำหนดธีมส่วนใหญ่หยุดที่การสลับโหมดมืด สลับคลาสบน <html> พลิก CSS variables สองสามตัว เสร็จสิ้น นั่นครอบคลุมกรณีทั่วไปแต่พลาดปัญหาที่น่าสนใจกว่า: จะทำอย่างไรถ้าผู้ใช้สามารถเลือกสีแบรนด์ของตัวเองได้? จะทำอย่างไรถ้าผลิตภัณฑ์ SaaS ของคุณให้บริการลูกค้าหลายราย แต่ละรายมีเอกลักษณ์ของตัวเอง?

นั่นคือจุดที่การกำหนดธีมสีแบบไดนามิกเริ่มต้น — และ CSS variables เพียงอย่างเดียวไม่เพียงพอ


แนวทาง CSS Variable สำหรับธีม React

พื้นฐาน: สถาปัตยกรรม Semantic Token

ทุกระบบธีมแบบไดนามิกเริ่มต้นด้วยฐานเดียวกัน: CSS custom properties ที่ตั้งชื่อตามบทบาทเชิงความหมาย ไม่ใช่ค่าสี อย่าตั้งชื่อตัวแปรว่า --blue-500 ตั้งชื่อว่า --brand-primary:

/* globals.css */
:root {
  --bg-base: #F8FAFC;
  --bg-surface: #FFFFFF;
  --text-primary: #1E293B;
  --text-secondary: #64748B;
  --brand-primary: #2563EB;
  --brand-hover: #1D4ED8;
  --brand-subtle: #EFF6FF;
  --on-brand: #FFFFFF;
  --border-default: #E2E8F0;
  --status-error: #DC2626;
  --status-success: #16A34A;
  --status-warning: #D97706;
}

คอมโพเนนต์อ้างถึง token ไม่ใช่สีดิบ:

.card {
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  color: var(--text-primary);
}

การสร้างธีมแบบ Runtime ด้วยอัลกอริทึมสี

การสร้างชุด Token ที่สมบูรณ์

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-subtle': scale[1],
    '--on-brand': onBrand,
    '--brand-50': scale[0],
    '--brand-500': scale[5],
    '--brand-950': scale[10],
  };
}

React Context สำหรับการจัดการสถานะธีม

สถาปัตยกรรม 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';
}

const ThemeContext = createContext<any>(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);
    } catch {}
    return { brandHex: '#2563EB', mode: 'system' };
  });

  useEffect(() => {
    const tokens = generateThemeTokens(state.brandHex);
    const root = document.documentElement;
    Object.entries(tokens).forEach(([prop, value]) => {
      root.style.setProperty(prop, value as string);
    });
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }, [state]);

  const setBrand = useCallback((hex: string) => {
    setState(prev => ({ ...prev, brandHex: hex }));
  }, []);

  return (
    <ThemeContext.Provider value={{ ...state, setBrand }}>
      {children}
    </ThemeContext.Provider>
  );
}

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

คลาสสี Dynamic ของ Tailwind CSS

Tailwind สร้างคลาส utility ในเวลา build วิธีที่สะอาดที่สุดคือการกำหนดชุด token ธีม Tailwind ขนาดเล็กที่ชี้ไปยัง CSS custom properties:

/* styles.css (Tailwind v4) */
@import "tailwindcss";

@theme {
  --color-brand: var(--brand-primary);
  --color-brand-hover: var(--brand-hover);
  --color-on-brand: var(--on-brand);
}

ตอนนี้ bg-brand และ text-on-brand เป็นคลาส Tailwind ที่ถูกต้อง:

<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
  การกระทำหลัก
</button>

สำหรับสีที่ไม่ซ้ำกันอย่างแท้จริง ใช้ inline style:

<div style={{ backgroundColor: swatch.hex }} className="w-8 h-8 rounded" />

การป้องกัน Flash ของธีมเริ่มต้นเมื่อโหลด

// 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';
                  document.documentElement.style.setProperty('--brand-primary', brand);
                } catch(e) {}
              })();
            `,
          }}
        />
      </head>
      <body><ThemeProvider>{children}</ThemeProvider></body>
    </html>
  );
}

ประเด็นสำคัญ

  • CSS custom properties คือรากฐาน — semantic token ที่ตั้งชื่อตามบทบาท ไม่ใช่ค่าสี ช่วยให้เปลี่ยนธีมได้อย่างสมบูรณ์โดยไม่ต้องแก้ไขคอมโพเนนต์
  • การสร้าง token แบบ runtime จาก hex แบรนด์เดียวต้องการ: สเกลความสว่าง/ความมืด การตรวจสอบความเปรียบต่าง WCAG สำหรับข้อความ on-brand และการคำนวณแยกสำหรับตัวแปรโหมดมืด
  • React context จัดการสถานะธีมและใช้ token กับ DOM เป็น side effect — คอมโพเนนต์ไม่ต้องสมัครรับ context ธีมเพื่อใช้สี
  • Debounce การคำนวณสีที่มีค่าใช้จ่ายสูง (150ms) เมื่อเชื่อมต่อกับ input ต่อเนื่อง
  • สำหรับ Tailwind CSS: กำหนด CSS variable bridge ใน @theme เพื่อให้ utility bg-brand สะท้อนค่า token ที่ runtime
  • ใช้ เครื่องมือสร้างเฉดสี และ เครื่องมือสร้างจานสี เพื่อตรวจสอบสเกลสีที่สร้างขึ้น

สีที่เกี่ยวข้อง

แบรนด์ที่เกี่ยวข้อง

เครื่องมือที่เกี่ยวข้อง