Chroma Panel

Search documentation

Find a page or section

Theme editor

View as Markdown

Write each picked color to a CSS custom property.

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.

"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.

"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 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 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.