教程

React 动态颜色主题:超越 CSS 变量

阅读约 5 分钟

大多数主题教程止步于深色模式切换:在 <html> 上切换一个类,翻转几个 CSS 变量,完成。这涵盖了一个常见场景,但忽略了一个更有趣的问题:如果用户可以选择自己的品牌颜色呢?如果你的 SaaS 产品服务于多个客户,每个客户都有自己的品牌标识呢?如果主题需要在运行时从单个输入十六进制值生成整套和谐调色板呢?

这就是动态颜色主题的起点——而仅凭 CSS 变量是不够的。你需要运行时颜色算法、在导航过程中能保持状态的 React 状态、持久化策略,以及在滑块移动时不会重新渲染整个组件树的架构。

本指南深入讲解上述每一个层次。


面向 React 主题的 CSS 变量方案

基础:语义令牌架构

每个动态主题系统都从同一个基础出发:CSS 自定义属性按语义角色命名,而非按颜色值命名。不要将变量命名为 --blue-500,而应命名为 --brand-primary。值可以改变,角色不变:

/* globals.css */
:root {
  /* 语义令牌——组件使用这些 */
  --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;
}

组件引用令牌,而非原始颜色:

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

.btn-primary:hover {
  background: var(--brand-hover);
}

当你在 JavaScript 中更新 --brand-primary 时,所有引用它的组件立即更新——无需 React 重新渲染,无需 props 层层传递,无需订阅 context。CSS 处理级联。

从 JavaScript 写入令牌

设置自定义属性的 DOM API 非常简单:

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

读取回来:

const brand = getComputedStyle(document.documentElement)
  .getPropertyValue('--brand-primary')
  .trim();

这是机械层面的基础。困难之处在于从单个品牌十六进制值生成完整的令牌集合。


使用颜色算法进行运行时主题生成

手动令牌集的问题

如果用户可以选择任意颜色——包括像 #FF5733#0D9488 这样不寻常的颜色——你就无法硬编码深色变体、悬停状态或品牌上的文字颜色。你需要从输入值计算它们。

生成完整的令牌集

以下是一个完整函数,它接受品牌十六进制值并生成主题所需的所有令牌:

import chroma from 'chroma-js';

function generateThemeTokens(brandHex) {
  const brand = chroma(brandHex);

  // 根据 WCAG 相对亮度判断品牌上的文字应为黑色还是白色
  const brandLuminance = brand.luminance();
  const onBrand = brandLuminance > 0.179 ? '#000000' : '#FFFFFF';

  // 在 OKLCH 中生成从浅色调到深色调的刻度,以获得感知均匀的步进
  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-200': scale[2],
    '--brand-300': scale[3],
    '--brand-400': scale[4],
    '--brand-500': scale[5],
    '--brand-600': scale[6],
    '--brand-700': scale[7],
    '--brand-800': scale[8],
    '--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);
  });
}

无障碍颜色对

从任意用户输入生成主题时,不能假定无障碍性。用户选择的颜色可能与白色之间对比度不足。你需要检查并调整:

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'); // 黄色调整至通过 AA

在为出现在白色或其他固定表面上的文字元素生成令牌时应用此方法。

互补色和点缀色

调色板生成器展示了单一基础颜色如何生成完整的和声家族。你可以通过色调旋转在代码中复现这一逻辑:

function generateComplementaryAccent(brandHex) {
  const hsl = chroma(brandHex).hsl();
  // 旋转色调180°获得互补色,调整亮度维持平衡
  const complementHue = (hsl[0] + 180) % 360;
  return chroma.hsl(complementHue, hsl[1] * 0.9, hsl[2]).hex();
}

function generateAnalogousAccent(brandHex, degrees = 30) {
  const hsl = chroma(brandHex).hsl();
  const analogousHue = (hsl[0] + degrees) % 360;
  return chroma.hsl(analogousHue, hsl[1], hsl[2]).hex();
}

用于主题状态管理的 React Context

ThemeContext 架构

主题 context 是用户交互(选择颜色、切换深色模式)与 DOM 级别令牌应用之间的桥梁。它应该保持精简:存储状态、将其应用到 DOM,并暴露 setter。颜色计算在 context 之外进行:

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

  // 状态变化时应用 CSS 令牌
  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]);

  // 响应系统偏好变化
  useEffect(() => {
    if (state.mode !== 'system') return;
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e: MediaQueryListEvent) => {
      document.documentElement.setAttribute(
        'data-theme',
        e.matches ? 'dark' : 'light',
      );
    };
    media.addEventListener('change', handler);
    return () => media.removeEventListener('change', handler);
  }, [state.mode]);

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

颜色选择器组件

有了 context,颜色选择器就成为一个精简的 UI 层:

// 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">
        品牌颜色
      </label>
      <div className="relative">
        <input
          id="brand-picker"
          type="color"
          value={localHex}
          onChange={handleChange}
          className="sr-only"
        />
        <label
          htmlFor="brand-picker"
          className="block w-8 h-8 rounded-md cursor-pointer ring-2 ring-offset-2"
          style={{
            backgroundColor: localHex,
            '--tw-ring-color': 'var(--brand-primary)',
          } as React.CSSProperties}
          aria-label={`当前品牌颜色:${localHex}`}
        />
      </div>
      <input
        type="text"
        value={localHex}
        onChange={handleChange}
        className="w-24 px-2 py-1 text-sm font-mono border rounded"
        placeholder="#2563EB"
        aria-label="品牌颜色十六进制值"
      />
    </div>
  );
}

150毫秒的防抖至关重要——没有它,在十六进制输入框中每次按键时都会运行 generateThemeTokens,这是不必要的性能开销。


Tailwind CSS 动态颜色类

Tailwind 与运行时颜色的挑战

Tailwind 在构建时生成实用类。只有当 Tailwind 的扫描器在源文件中找到 bg-brand-500 等类时,它们才会存在于样式表中。运行时确定的颜色——比如用户刚刚选择的颜色——不会在样式表中出现。

有两种解决方案,适用于不同需求:

方案1:CSS 变量桥接

最简洁的方法是定义一组指向 CSS 自定义属性的 Tailwind 主题令牌,然后在运行时控制这些属性的值:

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

现在 bg-brandtext-brandhover:bg-brand-hover 都是有效的 Tailwind 类,它们反映 --brand-primary 在运行时持有的任何值:

<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
  主要操作
</button>

通过 JavaScript 更改 --brand-primary 会立即更新所有带有 bg-brand 的元素——无需重新渲染。

方案2:内联样式用于一次性动态颜色

当一个组件需要真正唯一的运行时颜色(例如调色板查看器中的色块)时,使用内联样式。Tailwind 的 bg-[#FF5733] 任意值语法适用于构建时已知的静态值,但 JSX 模板字符串中的 bg-[${dynamicHex}] 无法工作——该类不会存在于样式表中。

// 对真正动态的颜色,正确做法:
<div
  style={{ backgroundColor: swatch.hex }}
  className="w-8 h-8 rounded"
  aria-label={swatch.name}
/>

// 这在运行时不起作用(类不在样式表中):
<div className={`bg-[${swatch.hex}]`} /> // 错误

持久化用户颜色偏好

跨会话持久化使用 localStorage

上面的 ThemeContext 已在 useEffect 中写入 localStorage。SSR 兼容性的关键模式是延迟初始化状态——向 useState 传递一个函数,该函数仅在客户端读取 localStorage,而非在服务端渲染时:

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

防止加载时默认主题闪烁

对于服务端渲染的应用(Next.js App Router),组件树在到达浏览器之前在服务端渲染。如果以默认主题初始化,保存了自定义主题的用户会看到闪烁——默认蓝色短暂出现后,保存的紫色才加载。

解决方法是在 <head> 中使用一个阻塞内联脚本,在浏览器渲染任何像素之前应用保存的主题:

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

内联脚本同步应用最关键的令牌(品牌颜色、主题模式属性)。完整的令牌集由 ThemeProvider 在水合后应用——但由于主要视觉令牌已正确设置,用户不会看到任何闪烁。

对于经过身份验证的应用,将主题存储在 Cookie 中(服务端可读)可以从第一个字节开始以正确主题进行 SSR:

// middleware.ts (Next.js)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const brandHex = request.cookies.get('brand-hex')?.value ?? '#2563EB';
  const mode = request.cookies.get('theme-mode')?.value ?? 'system';

  // 通过响应头或查询参数将主题传递给页面
  const response = NextResponse.next();
  response.headers.set('x-brand-hex', brandHex);
  response.headers.set('x-theme-mode', mode);
  return response;
}

在根 layout.tsx 服务端组件中读取这些头信息,并将初始主题作为 prop 传递给 ThemeProvider。Provider 以服务端已知的主题初始化,完全消除闪烁。


生成无障碍的深色模式变体

从品牌颜色生成浅色主题时,还需要深色模式版本。挑战在于:在浅色背景上效果很好的同一个品牌十六进制值——比如 #2563EB——在深色表面上往往不满足 WCAG 对比度要求。

一种稳健的方法从同一个品牌输入生成两套令牌——一套用于浅色,一套用于深色:

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

通过 data-theme="dark" 属性模式应用深色令牌。当 ThemeProvider 设置 document.documentElement.setAttribute('data-theme', 'dark') 时,CSS 级联会应用深色模式令牌值。

要验证生成的颜色对是否通过 WCAG,请使用调色板生成器可视化完整刻度并检查步骤间的对比度。


核心要点

  • CSS 自定义属性是基础——按角色而非颜色值命名的语义令牌允许在不修改组件的情况下完全更换主题。
  • 从单个品牌十六进制值进行运行时令牌生成需要:亮度/暗度刻度、品牌上文字的 WCAG 对比度检查,以及深色模式变体的独立计算。
  • React context 管理主题状态(活跃品牌十六进制、明/暗模式)并将令牌作为副作用应用到 DOM——组件无需订阅主题 context 来应用颜色。
  • 对连接到颜色选择器或滑块等连续输入的昂贵颜色计算进行防抖处理(150毫秒)。
  • 对于 Tailwind CSS:在 @theme 中定义 CSS 变量桥接,使 bg-brand 实用类反映运行时令牌值;仅对真正独特的每元素动态颜色使用内联样式。
  • 使用 <head> 中的同步阻塞脚本在渲染前应用最关键的令牌,防止默认主题闪烁。
  • 对于有每用户主题的 SSR 应用,将主题存储在 Cookie 中,以便服务端可以渲染正确的初始 HTML。
  • 使用色阶生成器检查并验证生成的颜色刻度对应完整的50–950范围,使用调色板生成器验证整个主题调色板的和谐性和对比度。

相关颜色

相关品牌

相关工具