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.