# 主题编辑器

> 用 React 构建一个实时主题编辑器：每个设计令牌对应一个颜色选择器，每次修改都直接写入文档上的 CSS 自定义属性。

Source: https://chroma-panel.jscrate.dev/zh/react/recipes/theme-editor
Last updated: 2026-09-21

在 React 中做主题颜色选择器，可以把每个设计令牌连接到一个 `ColorInput`。实时修改写入 CSS 自定义属性，交互结束时再保存最终值。

```tsx
"use client";

import { ColorInput } from "chroma-panel";
import "chroma-panel/style.css";

const TOKENS = [
  { id: "brand", property: "--brand", label: "Brand", initial: "#3366cc" },
  {
    id: "surface",
    property: "--surface",
    label: "Surface",
    initial: "#ffffff",
  },
  { id: "text", property: "--text", label: "Text", initial: "#1c1c1e" },
];

function saveToken(property: string, hex: string) {
  const saved = JSON.parse(
    window.localStorage.getItem("theme") ?? "{}"
  ) as Record<string, string>;
  window.localStorage.setItem(
    "theme",
    JSON.stringify({ ...saved, [property]: hex })
  );
}

export function ThemeEditor() {
  return (
    <div>
      {TOKENS.map((token) => (
        <p key={token.id}>
          <label htmlFor={token.id}>{token.label}</label>
          <ColorInput
            id={token.id}
            defaultValue={token.initial}
            onChange={(color) => {
              document.documentElement.style.setProperty(
                token.property,
                color.hex
              );
            }}
            onChangeComplete={(color) => saveToken(token.property, color.hex)}
          />
        </p>
      ))}
    </div>
  );
}
```

样式表中所有读取 `var(--brand)` 的地方，都会随着选择器的拖动实时变化。

## 修改时绘制，修改完成时保存

这里两个修改事件各有分工。

`onChange` 在拖动过程中持续触发，大约每帧一次。这个频率正适合 `setProperty`，它只是一次开销很小的 DOM 写入，预览也正是因此才有实时的感觉。

`onChangeComplete` 在拖动结束时只触发一次。开销大的工作放在这里：写入 `localStorage`、向你的 API 发送 `PATCH` 请求、往撤销栈里加一条记录。如果改在 `onChange` 里保存，每帧都会写一次，而这些结果还没有人需要。

> **不要在 onChange 中保存**
>
> 在色轮上拖动一次，会产生数百次 `onChange`
> 调用。每一次写到存储或发到网络的操作，在下一帧就作废了。

## 写入哪个字段

两个回调收到的值都以多种形式表示同一个颜色：`hex`、`hexa`、`rgba`、`hsva` 和 `css`。如果令牌是不透明的品牌色，写入 `hex`。如果令牌可能是半透明的，写入 `hexa`，因为 `hex` 会丢掉透明度 (alpha) 通道。`css` 给出的字符串采用你在选择器上设置的 `format`，所以 `format="hsl"` 加上 `color.css`，写入属性的就是 `hsl(...)`。

## 恢复已保存的主题

选择器在挂载时读取 `defaultValue`。所以要先加载已保存的值，把它们应用到文档上，然后再挂载编辑器。

```tsx
"use client";

import { useEffect, useState } from "react";

export function ThemeEditorLoader() {
  const [saved, setSaved] = useState<Record<string, string> | null>(null);

  useEffect(() => {
    const raw = window.localStorage.getItem("theme");
    const theme =
      raw === null ? {} : (JSON.parse(raw) as Record<string, string>);
    for (const [property, hex] of Object.entries(theme)) {
      document.documentElement.style.setProperty(property, hex);
    }
    setSaved(theme);
  }, []);

  if (saved === null) return null;
  return <ThemeEditor />;
}
```

在 effect 中读取存储，可以让服务端渲染和客户端首次渲染的结果保持一致。如果希望每个令牌都由 React state 管理，就用 `value` 代替 `defaultValue`。请参阅[受控与非受控](https://chroma-panel.jscrate.dev/zh/react/handbook/controlled)。

## 给选择器也设置主题

上面这些令牌是你自己的。选择器也有自己的一套令牌，都以 `--cp-` 为前缀，用它们可以让编辑器和它所编辑的界面风格一致。完整列表见[主题](https://chroma-panel.jscrate.dev/zh/react/handbook/theming)。触发器的其他 prop，包括用于把主题作为表单提交的 `name`，见 [ColorInput 参考文档](https://chroma-panel.jscrate.dev/zh/react/components/color-input)。
