Theming Màu Động Trong React: Vượt Ra Ngoài CSS Variables
Embed This Widget
Add the script tag and a data attribute to embed this widget.
Embed via iframe for maximum compatibility.
<iframe src="https://colorfyi.com/iframe/entity//" width="420" height="400" frameborder="0" style="border:0;border-radius:10px;max-width:100%" loading="lazy"></iframe>
Paste this URL in WordPress, Medium, or any oEmbed-compatible platform.
https://colorfyi.com/entity//
Add a dynamic SVG badge to your README or docs.
[](https://colorfyi.com/entity//)
Use the native HTML custom element.
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-brand và hover: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 íchbg-brandphả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.