Hướng dẫn Thực hành

Theming Màu Động Trong React: Vượt Ra Ngoài CSS Variables

Đọc 6 phút

Hầu hết các hướng dẫn theming dừng lại ở nút chuyển dark mode. Chuyển một class trên <html>, đảo một số CSS variables, xong. Điều đó bao gồm một trường hợp phổ biến nhưng bỏ lỡ vấn đề thú vị hơn: nếu người dùng có thể chọn màu thương hiệu của riêng họ thì sao? Nếu sản phẩm SaaS của bạn phục vụ nhiều khách hàng, mỗi người có bản sắc riêng thì sao? Nếu theme cần tạo ra toàn bộ bảng màu hài hòa từ một giá trị hex đầu vào duy nhất tại runtime thì sao?

Đó là nơi dynamic color theming bắt đầu — và CSS variables đơn thuần là không đủ. Bạn cần các thuật toán màu tại runtime, React state tồn tại qua điều hướng, chiến lược persistence và kiến trúc không re-render toàn bộ component tree mỗi khi thanh trượt di chuyển.

Hướng dẫn này bao gồm chi tiết từng lớp đó.


Cách Tiếp Cận CSS Variable Cho React Themes

Nền Tảng: Kiến Trúc Semantic Token

Mọi hệ thống dynamic theme đều bắt đầu với cùng nền tảng: CSS custom properties được đặt tên theo vai trò ngữ nghĩa, không phải giá trị màu. Đừng đặt tên biến là --blue-500. Đặt tên là --brand-primary. Giá trị có thể thay đổi; vai trò thì không:

/* globals.css */
:root {
  /* Semantic tokens — đây là những gì component sử dụng */
  --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;
}

Các component tham chiếu token, không bao giờ là màu thô:

.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);
}

Khi bạn cập nhật --brand-primary trong JavaScript, mọi component tham chiếu nó đều cập nhật ngay lập tức — không React re-render, không prop drilling.

Ghi Token Từ 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');

Tạo Theme Runtime Với Thuật Toán Màu

Tạo Toàn Bộ Bộ Token

Đây là hàm hoàn chỉnh nhận brand hex và tạo ra tất cả các token mà theme của bạn cần:

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-500': scale[5],
    '--brand-900': scale[9],
    '--brand-950': scale[10],
  };
}

function applyTheme(brandHex) {
  const tokens = generateThemeTokens(brandHex);
  const root = document.documentElement;
  Object.entries(tokens).forEach(([prop, value]) => {
    root.style.setProperty(prop, value);
  });
}

Cặp Màu Có Khả Năng Tiếp Cận

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 Để Quản Lý Trạng Thái Theme

Kiến Trúc 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';

function getStoredTheme(): ThemeState {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (stored) return JSON.parse(stored) as ThemeState;
  } catch {}
  return { brandHex: '#2563EB', mode: 'system' };
}

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<ThemeState>(getStoredTheme);

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

Component Chọn Màu

// components/BrandColorPicker.tsx
import { useTheme } from '../contexts/ThemeContext';
import { useState, useRef } from 'react';

export function BrandColorPicker() {
  const { brandHex, setBrand } = useTheme();
  const [localHex, setLocalHex] = useState(brandHex);
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setLocalHex(value);

    clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => {
      if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
        setBrand(value);
      }
    }, 150);
  };

  return (
    <div className="flex items-center gap-3">
      <label htmlFor="brand-picker" className="text-sm font-medium">
        Màu Thương Hiệu
      </label>
      <input
        id="brand-picker"
        type="color"
        value={localHex}
        onChange={handleChange}
      />
      <input
        type="text"
        value={localHex}
        onChange={handleChange}
        className="w-24 px-2 py-1 text-sm font-mono border rounded"
        placeholder="#2563EB"
      />
    </div>
  );
}

Delay 150ms rất quan trọng — không có nó, generateThemeTokens chạy mỗi lần gõ phím khi nhập hex, điều này không cần thiết tốn kém.


Tailwind CSS Dynamic Color Classes

Cầu Nối CSS Variable

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

Bây giờ bg-brand, text-brandhover:bg-brand-hover là các class Tailwind hợp lệ phản ánh bất kỳ giá trị nào --brand-primary giữ tại runtime:

<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
  Hành Động Chính
</button>

Inline Styles Cho Màu Động Thực Sự

// Đúng cho màu thực sự động:
<div
  style={{ backgroundColor: swatch.hex }}
  className="w-8 h-8 rounded"
/>

// Điều này sẽ KHÔNG hoạt động tại runtime:
<div className={`bg-[${swatch.hex}]`} /> // Sai

Lưu Trữ Tùy Chọn Màu Của Người Dùng

localStorage Để Duy Trì Qua Các Phiên

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' };
  }
});

Ngăn Flash Theme Mặc Định Khi Tải

// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="vi" 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>
  );
}

Tạo Biến Thể Dark Mode Có Khả Năng Tiếp Cận

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',
    },
  };
}

Những Điểm Mấu Chốt

  • CSS custom properties là nền tảng — semantic token được đặt tên theo vai trò, không theo giá trị màu, cho phép thay đổi theme hoàn toàn mà không cần sửa đổi component.
  • Tạo token runtime từ một brand hex duy nhất yêu cầu: thang sáng/tối, kiểm tra tương phản WCAG cho văn bản on-brand và tính toán riêng biệt cho biến thể dark mode.
  • React context quản lý trạng thái theme (brand hex đang hoạt động, chế độ sáng/tối) và áp dụng token vào DOM như một side effect.
  • Trì hoãn các phép tính màu tốn kém (150ms) khi kết nối với đầu vào liên tục như color picker hoặc thanh trượt.
  • Với Tailwind CSS: định nghĩa một cầu nối CSS variable trong @theme để các tiện ích bg-brand phản ánh giá trị token runtime.
  • Ngăn flash theme mặc định với script blocking đồng bộ trong <head> áp dụng các token quan trọng nhất trước khi render.
  • Với ứng dụng SSR có theme theo người dùng, lưu theme trong cookie để server có thể render HTML ban đầu đúng.
  • Sử dụng Công cụ tạo Tông màu để kiểm tra và xác thực các thang màu được tạo, và Công cụ tạo Bảng màu để xác minh sự hài hòa và tỷ lệ tương phản.

Màu sắc liên quan

Thương hiệu liên quan

Công cụ liên quan