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

Color Theming ใน React: CSS Variables และ Context

อ่าน 3 นาที

Theming เป็นหนึ่งในปัญหาที่ดูเหมือนง่ายจนกว่าคุณจะต้องดูแลในระดับที่ใหญ่ขึ้น การสลับระหว่างโหมดสว่างและมืดเพียงครั้งเดียวนั้นไม่ยาก แต่ผลิตภัณฑ์ที่บริการหลายแบรนด์ เสนอจานสีที่ผู้ใช้ปรับแต่งได้ หรือต้องสลับธีมทันทีโดยไม่ต้องโหลดหน้าซ้ำ จำเป็นต้องมีสถาปัตยกรรมที่รอบคอบมากขึ้น

โมเดลคอมโพเนนต์ของ React และ CSS custom properties เป็นคู่ที่เหมาะสมตามธรรมชาติสำหรับ theming CSS variables จัดการค่าสีแบบ declarative; React Context จัดการ state ธีม; และ Tailwind CSS (ในโปรเจกต์สมัยใหม่) เชื่อมทั้งสองเข้าด้วยกัน


รูปแบบสถาปัตยกรรมธีม

รูปแบบที่ 1: CSS Variables เท่านั้น (ไม่มี JavaScript State)

รูปแบบที่ง่ายที่สุดสำหรับการรองรับ dark mode พื้นฐานไม่ต้องการ state ของ React เลย media query prefers-color-scheme เปลี่ยนค่า custom property และทุกคอมโพเนนต์อัปเดตโดยอัตโนมัติ:

/* 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;
  }
}

คอมโพเนนต์ใช้ var(--color-bg) และไม่จำเป็นต้องรู้เกี่ยวกับธีมปัจจุบัน เบราว์เซอร์จัดการทุกอย่าง

เมื่อใดควรใช้: ไซต์ที่คุณต้องการเคารพการตั้งค่าระดับ OS และไม่ต้องการ toggle ที่ผู้ใช้ควบคุม

ข้อจำกัด: ผู้ใช้ไม่มีทางแทนที่การตั้งค่า OS ของตนในแอป

รูปแบบที่ 2: Data Attribute Theme Switching

เพิ่มแอตทริบิวต์ data-theme บนองค์ประกอบ <html> เพื่อทำให้ธีมที่ใช้งานอยู่ชัดเจนและแทนที่ได้ รูปแบบนี้เข้ากันได้กับ SSR หลีกเลี่ยง flash ของธีมที่ผิด และรวมกับการคงอยู่ของ localStorage ได้อย่างง่ายดาย:

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

[data-theme="dark"] {
  --color-bg: #0F172A;
  --color-text: #F1F5F9;
  --color-brand: #60A5FA;
  --color-surface: #1E293B;
  --color-border: #334155;
}

เมื่อใดควรใช้: แอปการผลิตส่วนใหญ่ที่ต้องการ toggle ที่ผู้ใช้ควบคุม รูปแบบ data attribute เป็นมาตรฐานอุตสาหกรรมปัจจุบัน — ใช้โดย Tailwind CSS dark mode, Radix UI และระบบดีไซน์สมัยใหม่ส่วนใหญ่

รูปแบบที่ 3: Class-Based Theme Switching

คล้ายกับ data-attribute แต่ใช้ CSS class การกำหนดค่า darkMode: 'class' ของ Tailwind อาศัยรูปแบบนี้:

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

เมื่อใดควรใช้: โปรเจกต์ที่ใช้ Tailwind CSS พร้อมกลยุทธ์ dark mode แบบ class


CSS Variables สำหรับสี: ระบบ Token

การตั้งชื่อแบบ Semantic แทนแบบ Descriptive

การตัดสินใจที่สำคัญที่สุดในระบบ token คือการตั้งชื่อ การตั้งชื่อตามค่าสี (--blue-500, --gray-900) ทำให้ตัวแปรเข้าใจง่ายแต่ไม่สามารถทำธีมได้ การตั้งชื่อตามบทบาท semantic (--color-brand, --color-text-muted) ช่วยให้ค่าเปลี่ยนแปลงได้อย่างสมบูรณ์ในแต่ละธีมในขณะที่คอมโพเนนต์ยังคงถูกต้อง:

:root {
  /* ---- Color Primitives (ไม่ใช้โดยตรงในคอมโพเนนต์) ---- */
  --blue-500: #3B82F6;
  --blue-700: #1D4ED8;
  --slate-50: #F8FAFC;
  --slate-800: #1E293B;
  --slate-900: #0F172A;

  /* ---- Semantic Tokens (ใช้ในคอมโพเนนต์) ---- */
  --text-primary: var(--slate-800);
  --text-secondary: #64748B;
  --bg-base: var(--slate-50);
  --brand: var(--blue-700);
  --brand-hover: #1E40AF;
  --error: #DC2626;
  --success: #16A34A;
  --warning: #D97706;
}

[data-theme="dark"] {
  --text-primary: #F1F5F9;
  --text-secondary: #94A3B8;
  --bg-base: #0F172A;
  --brand: #60A5FA;
  --brand-hover: #93C5FD;
  --error: #F87171;
  --success: #4ADE80;
  --warning: #FCD34D;
}

ระบบสองชั้น (primitives + semantic tokens) ให้ความยืดหยุ่นธีมอย่างสมบูรณ์พร้อมทั้งรักษาคอมโพเนนต์ให้สะอาด

การสร้างสเกลสีของคุณ

ก่อนสร้างระบบ token คุณต้องมีจานสีดิบ Shade Generator สร้างสเกล 50–950 ที่สมบูรณ์จากสีแบรนด์เดียว — รูปแบบสเกลเดียวกับที่ Tailwind CSS ใช้


React Context สำหรับ Theme State

รูปแบบ ThemeContext

สำหรับแอปที่มี theme toggle ที่ผู้ใช้ควบคุม React Context มอบชั้นการจัดการ state Context เก็บชื่อธีมที่ใช้งานอยู่และเปิดเผยฟังก์ชัน 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 resolvedTheme: 'light' | 'dark' =
    theme === 'system'
      ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
      : theme;

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

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

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

การป้องกัน Flash of Wrong Theme (FOTWT)

แอปที่ render ฝั่งเซิร์ฟเวอร์เผชิญกับปัญหา flash ของธีมที่ผิด วิธีแก้คือ blocking inline script ใน <head> ที่อ่าน localStorage และใช้แอตทริบิวต์ธีมก่อนที่หน้าจะ render:

<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: Class-Based Dark Mode

ใน Tailwind v3 dark mode ต้องตั้ง darkMode: 'class' ใน tailwind.config.js ซึ่งทำให้ Tailwind ใช้ utility dark: เฉพาะเมื่อ class .dark อยู่บนองค์ประกอบ <html>:

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

Tailwind v4: CSS-First Configuration

Tailwind v4 ย้ายการกำหนดค่าไปยัง CSS ทั้งหมด โดยใช้ CSS custom properties แบบ native:

@import "tailwindcss";

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

กลยุทธ์ Multi-Brand Theming

ความท้าทาย

แพลตฟอร์ม SaaS ที่บริการลูกค้าหลายราย ผลิตภัณฑ์ white-label หรือระบบดีไซน์ที่ใช้ร่วมกันในหลายแบรนด์ผลิตภัณฑ์จำเป็นต้องจัดการไม่เพียงแค่ตัวแปรมืด/สว่างแต่ยังรวมถึงเอกลักษณ์สีที่แตกต่างกันอย่างสิ้นเชิง

Brand Tokens เป็น CSS Variable Overrides

แนวทางที่ดูแลได้ง่ายที่สุดกำหนด semantic tokens ที่ไม่ขึ้นกับแบรนด์ใน base stylesheet จากนั้น override การกำหนดค่า primitive ต่อแบรนด์:

:root {
  --brand-primary-raw: 37 99 235;
  --brand-primary: rgb(var(--brand-primary-raw));
}

[data-brand="acme"] {
  --brand-primary-raw: 37 99 235;   /* #2563EB */
}

[data-brand="globex"] {
  --brand-primary-raw: 124 58 237;  /* #7C3AED */
}

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

ใช้ Shade Generator เพื่อสร้างสเกลสีสมบูรณ์สำหรับแต่ละแบรนด์


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

  • CSS custom properties คือรากฐาน: กำหนด semantic tokens (--text-primary, --brand-primary) และเปลี่ยนค่าของพวกมันต่อธีม แทนที่จะใช้ class แบบ conditional ในทุกคอมโพเนนต์
  • Data-attribute theme switching (data-theme="dark") เป็นรูปแบบที่ยืดหยุ่นที่สุด — แทนที่ได้ด้วย JavaScript เข้ากันได้กับ SSR และรวมกับ media query prefers-color-scheme ได้
  • ระบบ token สองชั้น (สเกลสี primitive + semantic role tokens) ให้ความยืดหยุ่นธีมอย่างสมบูรณ์พร้อมทั้งรักษาคอมโพเนนต์ให้สะอาด
  • ใช้ Shade Generator เพื่อสร้างสเกลสีสมบูรณ์ 50–950 จากสีหลักของแบรนด์
  • React Context จัดการ state ธีม (ชื่อธีมที่ใช้งาน) และ side effects (ตั้งค่า data attribute, บันทึกลง localStorage)
  • ป้องกัน flash ของธีมที่ผิด ด้วย blocking inline script ใน <head> ที่อ่าน localStorage และใช้แอตทริบิวต์ธีมก่อนที่เบราว์เซอร์จะ render พิกเซลใดๆ
  • การรวม Tailwind CSS: v3 ใช้ darkMode: 'class' พร้อม prefix dark:; v4 ใช้การกำหนดค่า @theme แบบ CSS-first
  • Multi-brand theming ซ้อน data attribute อื่น (data-brand) บนระบบธีม override ค่า primitive token ต่อแบรนด์ในขณะที่ semantic tokens และคอมโพเนนต์ไม่เปลี่ยนแปลง

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

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

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