# Theme editor

> Build a live theme editor in React: one color picker per design token, writing every change straight to a CSS custom property on the document.

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

A theme editor lets someone recolor your interface while they are looking at it.
Build one when your product ships themes, when you white-label a customer's
brand, or when designers need to try token values without a deploy. Each token
gets its own picker, and each picker writes to a CSS custom property on
`document.documentElement`, so the page repaints without React re-rendering
anything.

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

Anything in your stylesheet that reads `var(--brand)` follows along as the
picker moves.

## Paint on change, save on change complete

The two change events do different jobs here.

`onChange` fires continuously while a drag is in progress, roughly once a frame.
That is the right rate for `setProperty`, which is a cheap DOM write, and it is
what makes the preview feel live.

`onChangeComplete` fires once, when the drag ends. Put the expensive work there:
writing to `localStorage`, a `PATCH` to your API, an entry on an undo stack.
Saving on `onChange` instead means one write per frame for a result nobody asked
for yet.

> **Do not save on onChange**
>
> A drag across the wheel produces hundreds of `onChange` calls. Each one that
> reaches storage or the network is work you throw away a frame later.

## Which field to write

The value handed to both callbacks carries the same color in several forms:
`hex`, `hexa`, `rgba`, `hsva` and `css`. Write `hex` when the token is an opaque
brand color. Write `hexa` when the token can be translucent, since `hex` drops
the alpha channel. `css` gives you the string in whichever `format` you set on
the picker, so `format="hsl"` plus `color.css` writes `hsl(...)` into the
property.

## Restoring a saved theme

The picker reads `defaultValue` once, when it mounts. A theme you load in an
effect therefore arrives too late for it, so apply the saved values to the
document in the effect and mount the editor only once they are in hand.

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

Reading storage in an effect rather than during render keeps the server and the
first client render identical. If you would rather hold each token in state and
feed it back through `value`, that is the controlled version — see
[controlled and uncontrolled](https://chroma-panel.jscrate.dev/react/handbook/controlled) for what you take on
by doing it.

## Theming the picker as well

The tokens above are yours. The picker has its own set, all prefixed `--cp-`,
which is how you make the editor match the interface it edits — see
[theming](https://chroma-panel.jscrate.dev/react/handbook/theming) for the full list. The rest of the trigger's
props, including `name` for submitting the theme as a form, are on the
[ColorInput reference](https://chroma-panel.jscrate.dev/react/components/color-input).
