Theme Customizer
A live design-token editor: a radius slider plus hue and saturation per colour role, previewed light and dark side by side on real controls, and exported as the :root / .dark CSS you paste.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/theme-customizer.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ThemeCustomizer" component: a live editor
for a shadcn-style token set. Dependencies: lucide-react (Check, Copy, CopyX,
RotateCcw, TriangleAlert) and a cn() class merger. No colour library and no state
library — the OKLCH maths is about sixty lines and must be PURE (no DOM read, no
clock, no randomness), so the server and the browser produce byte-identical CSS.
Contract
- export const ThemeCustomizer = React.forwardRef<HTMLDivElement,
ThemeCustomizerProps>, displayName set, also the default export. Remaining
native div props spread on the root, className merged with cn().
- ThemeRoleSpec = {
key: string // keys into ThemeSettings.roles, and the React key
label?: string // falls back to key
variable: string // custom property the FILL is written to; leading -- optional
foregroundVariable?: string // the auto-paired INK; omit it and no ink is written at all
lightness: number // 0…1, the fill's lightness in the :root block
darkLightness: number // 0…1, the same fill in the .dark block
defaultHue: number // degrees this role resets to
defaultSaturation: number // 0…100, percent of the gamut ceiling it resets to
}
The split is the design: the user owns hue and saturation, the author owns
lightness. That is what keeps a role legible at every hue instead of letting a
yellow "primary" turn into an unreadable button.
- ThemeRoleValue = { hue: number; saturation: number } — degrees, and PERCENT OF
THE IN-GAMUT CEILING, not an absolute chroma.
- ThemeSettings = { radius: number /* rem, written to --radius */;
roles: Record<string, ThemeRoleValue> }
- RadiusRange = { min: number; max: number; step: number } — rem.
- ThemeCustomizerProps extends
Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue">:
roles = DEFAULT_THEME_ROLES // render order is array order
value?: ThemeSettings // controlled; pair with onValueChange
defaultValue?: ThemeSettings // uncontrolled seed AND the reset target;
// read ONCE on mount, controlled or not
onValueChange?: (settings: ThemeSettings) => void // every commit, normalised
onCopyCss?: (css: string) => void // only after a real write
radiusRange = { min: 0, max: 1.5, step: 0.025 } // must divide the default
// radius, or the native
// thumb snaps off it
readOnly = false
label = "Theme customizer" // accessible name of the whole editor
- export DEFAULT_THEME_ROLES: primary (--primary / --primary-foreground, light
lightness .52, dark .72, hue 265, saturation 62), accent (--accent /
--accent-foreground, .955 / .27, 265, 45), destructive (--destructive /
--destructive-foreground, .52 / .7, 27, 78). Default radius 0.625rem.
- Nothing is hard-wired to those names: a role is a variable name plus the two
lightnesses it should hold, so --brand or --chart-1 works the same way.
Behavior
- Normalise on every read, never on write: hue wraps (a hue is an angle, 400° is
40°, -20° is 340°), saturation clamps to 0…100, radius clamps into radiusRange,
all rounded (hue and saturation 1dp, radius 3dp). A role the settings never
mention falls back to its own spec defaults; a settings key with no matching
role is ignored. So a hostile or stale `value` is a clamp, never a broken
swatch, and every object handed to onValueChange is already clean.
- Colour, in four steps, all pure:
1. maxChroma(lightness, hue): the largest chroma sRGB still holds there, by
BISECTION from 0.4 down (18 halvings, in-gamut chroma is monotonic so
halving is exact). Gamut test = OKLCH → linear sRGB with Ottosson's
matrices, channels left UNCLAMPED — a channel outside 0…1 is exactly how
a colour announces it is undisplayable — with a 1e-4 float slack.
2. fill = quantize(lightness, (saturation / 100) * ceiling, hue), where
quantize rounds lightness to 3dp and hue to 1dp but FLOORS chroma to 3dp:
rounding up could push the value back out of the gamut the search just
proved it was inside.
3. ink = whichever of near-white (l .985) and near-black (l .145), both
neutral, has the higher WCAG contrast on the fill. Report the ratio; never
silently move the lightness to make the number look good.
4. Everything downstream — panes, ratios, export — is computed from the
QUANTIZED value, which is what makes "what you see is what you copy"
structural rather than a promise.
- One builder, two outputs. buildTheme(roles, settings, scheme) returns a flat
{name,value}[] that is BOTH the inline style a preview pane wears and the CSS
text the copy button writes, so preview and clipboard cannot drift:
- neutrals first, as lightness-only oklch(l 0 0): --background, --foreground,
--card, --card-foreground, --muted, --muted-foreground, --secondary,
--secondary-foreground, --border, --input, --ring. Light: 1, .145, 1, .145,
.97, .545, .97, .205, .922, .922, .708. Dark: .145, .985, .205, .985, .269,
.708, .269, .985, .275, .325, .556. They keep each pane a self-contained
island (a light pane stays light inside a dark page) and they are what turns
the export into a paste-and-done theme instead of three orphan variables.
- then each role's fill, and its ink when foregroundVariable is set. A role
whose variable collides with a neutral simply comes later, and later wins —
in the pane and in the exported block alike, same rule as CSS.
- --radius rides in the :root block only; it does not change between schemes.
css = `:root {…}\n\n.dark {…}`, two-space indent, one declaration per line.
- It edits a SCOPE, never the page. The variables are written as an inline style
on the two preview wrappers; nothing touches documentElement, so the host theme
is untouched while editing and there is no global state to unwind on unmount.
One asymmetry to keep: Tailwind resolves --radius-sm…--radius-xl against
--radius once, at the root, so a nested override never reaches a rounded-lg
inside it. The PANE therefore also writes --radius-sm/md/lg/xl at factors
.6/.8/1/1.4 — and those stay OUT of the export on purpose, because pasted at
:root the derivation works by itself and freezing it would break the consumer's
own scale.
- Controlled and uncontrolled: settings = normalize(value ?? internal). commit()
writes internal state only when value === undefined, and always calls
onValueChange. baseline = normalize(defaultValue) captured once with a lazy
useState initialiser — the reset target must not move when a parent re-renders
with a fresh object literal.
- Three reset scopes, each inert until its own part is dirty: per-role (hue or
saturation differs from baseline), radius, and Reset all (radius OR any role).
Dirty is compared against the baseline, not against the spec defaults. A role
added after mount has no captured baseline, so it resets to its own spec
defaults. Reset buttons use aria-disabled + a guard in the handler, NEVER the
disabled attribute: they go inert under the pointer the moment they do their
job, and the browser blurs a node it disables.
- readOnly is a refusal, not a removal. Sliders stay focusable and keep their
arrow keys (aria-readonly, dimmed) so a screen reader can still read every
value out; the COMMIT is what refuses, and the controlled value snaps the thumb
back. Both resets go aria-disabled. Copy CSS still works — reading is not
editing.
- Copy: writeClipboard never throws and never rejects — an insecure context, a
denied permission or a browser with no async Clipboard API all resolve to
false. A ref (not state) guards re-entry, read and written synchronously inside
the handler, because a state guard would still let the second click of a
double-click start a second write. On resolve: bail out if a `mounted` ref says
the component is gone (arming a timer there would arm it past the cleanup meant
to clear it), otherwise set "copied" or "failed", clear any previous timer and
arm a 2000ms return to idle, and call onCopyCss only on success. The mounted
ref is re-armed inside the effect, not just at init, or StrictMode's
mount/unmount/remount leaves the feedback dead for the rest of a dev session.
Failure is a visible state: a CopyX icon, a "Copy failed" label in
text-destructive, a line telling the user to select the block and press Ctrl or
Cmd + C, and a polite announcement. Cleanup on unmount clears the timer.
- Keyboard is the platform's, deliberately: three native <input type="range">
rows (radius; hue 0…359 step 1; saturation 0…100 step 1), so arrows step, Page
Up / Page Down jump, Home / End go to the ends, everywhere, for free. The hue
rail stops at 359 rather than 360 because the two are the same angle and the
wrap folds 360 back to 0 — with a max of 360, End would throw the thumb to the
opposite edge, which reads as a control that refused. Each slider carries
aria-valuetext, because a bare number tells a screen reader nothing:
"0.625 rem", "265 degrees", "62 percent of the in-gamut maximum". Every range
the editor ships must also have a step that divides its own default, or the
browser snaps the thumb to the nearest legal stop while the readout and the
export keep saying the real value.
- ARIA: root is role="group" with the label; the radius card and every role card
are role="group" aria-labelledby their own title; each slider is tied to its
<label> by id. The two-half swatch beside a role name (light fill | dark fill)
is aria-hidden. Each preview pane is aria-hidden AND inert, and its controls
are tabIndex={-1}: duplicating three buttons and an input into the tab order
would buy the keyboard nothing but dead stops, and every number they encode is
already spoken by the controls above. The <pre> holding the CSS is scrollable,
so it takes a tab stop of its own (tabIndex={0}, role="group", labelled by the
CSS export heading) — a keyboard user has to be able to reach a box before they
can scroll it. One sr-only role="status" aria-live="polite" region carries the
copy verdict.
- Readouts under each role: contrast for light and dark ("Light 4.83:1"), each
flagging itself below 4.5:1 with a warning triangle, the word "below AA" and
text-destructive — the miss is reported and STILL exported, because refusing to
write it would hide the problem. A role with no foregroundVariable says so
instead of faking a pair. Plus "chroma <fill> of <ceiling>", which is what
makes the percent slider legible as a real number.
- Preview panes are made of REAL controls, not a screenshot: a badge, a card with
a heading, muted copy, a read-only input, one button per role and an outline
button, every colour a var() the pane itself declares. A role without an ink
variable renders its label in the inherited colour — exactly what the consuming
app would do.
Rendering & styling
- Semantic tokens for all chrome: border, bg-card, bg-muted, bg-background,
text-foreground, text-muted-foreground, text-destructive, hover:bg-accent /
hover:text-accent-foreground, ring-ring. No hex / rgb / oklch literal ever
appears in a className. The generated colours are the artefact, so they travel
as computed oklch() strings through inline styles and custom properties only.
- Slider chrome is one class constant with vendor pseudo-elements
(::-webkit-slider-runnable-track / ::-moz-range-track for an 8px rounded rail
with a border, ::-webkit-slider-thumb / ::-moz-range-thumb for a 16px circle
bordered in --foreground and filled with --background so it stays visible on
any hue; WebKit needs -mt-1 to centre it, Firefox centres by itself). The rail
paints bg-[image:var(--tc-ramp)], and the gradient is handed in as that one
inline custom property, so the class list stays free of colour literals.
- The ramps are honest previews of what the slider selects: the hue rail is 13
stops every 30° at the role's light lightness and current saturation, the
saturation rail is 5 stops (0/25/50/75/100) at the current hue, both routed
through the same ceiling maths as the fill. The radius rail has no colour to
sample, so it uses a flat --secondary gradient.
- Focus: focus-visible ring-2 ring-ring (sliders add ring-offset-2 against
--background). aria-disabled actions drop to opacity-40, lose their hover and
take cursor-not-allowed.
- Motion is decorative only: colour AND border-radius transition over 150ms
ease-out so the two panes morph instead of blinking, with
motion-reduce:transition-none everywhere. With motion off the editor behaves
identically.
- Layout: a single column of cards (radius, then one per role), then the two
panes in a grid of repeat(auto-fit, minmax(min(11rem, 100%), 1fr)) so they sit
side by side when there is room and stack when there is not, then the CSS
export with its two actions. min-w-0 and truncate on names and code, so a long
variable name cannot widen the editor.
Customization levers
- Roles are the main dial: pass your own array to edit --chart-1…5, --sidebar,
or a single --brand. Each role costs one card and two sliders; three to five is
comfortable, beyond that give the list its own scroll container.
- Lightness pairs are the taste in the component. Raise darkLightness for a
brighter dark theme, drop lightness for a heavier light one; keep the two at
least ~0.15 apart or the theme stops reading as two themes.
- radiusRange widens or freezes the corner dial (pass { min: 0, max: 0, step: 1 }
to drop radius from the product entirely, or 2rem for pill-shaped UI).
- Sub-blocks worth cutting: the preview panes (keep the CSS box for a headless
editor), the CSS box (keep the panes and put Copy in a toolbar), the chroma
readout, the contrast notes. What must stay is buildTheme: it is what keeps
preview and clipboard identical.
- The neutral ramp is a constant, not a law — swap in your own greys, or add a
tint by giving them a chroma and hue, and they flow into both the panes and
the export.
- Export format: toCssBlock takes a selector, so :root / .dark can become
@theme inline, a [data-theme=…] attribute, a JSON token file or a Tailwind
config — one function, and the preview does not care.
- Thresholds and resolution: AA_TEXT 4.5 → 7 for AAA; HUE_STEP 30 → 15 for a
smoother rail at twice the gamut searches; INK_LIGHTNESS can hold more than two
candidates (add a mid grey and the pairing search finds it automatically).
- Persistence is the consumer's: onValueChange gives you normalised settings to
write to localStorage or an API, and feeding them back through value + a fixed
defaultValue gives you a published theme with a working Reset.Concepts
- One builder, two outputs —
buildThemeproduces the inline style the preview panes wear and the CSS text the copy button writes, from the same quantized values, so the preview and the clipboard cannot drift apart. That equality is the product; the sliders are chrome. - Saturation is a percentage of the gamut, not a number — 100% means the most chroma sRGB holds at that lightness and hue, found by bisecting an unclamped OKLCH→sRGB conversion, and the printed chroma is floored rather than rounded, so no setting can produce a colour the screen has to clip or a CSS string that lands back outside the gamut.
- It edits a scope, never the page — variables land on the two preview wrappers only;
documentElementis never touched, so the host theme survives editing and there is no global state to unwind on unmount. The one thing the pane adds, the derived--radius-sm…xlsteps, is deliberately left out of the export, because at:rootTailwind derives them itself. - Paired ink, reported not repaired — each fill gets whichever of near-white and near-black reads better on it, and the ratio is printed for both schemes; a pair under 4.5:1 is flagged and still exported, because nudging the lightness until the number looks good would hide a decision the author has to make.
- Baseline resets, inert but not disabled —
defaultValueis captured once and is what all three reset scopes (role, radius, everything) return to; each stays inert until its own part is dirty, and refuses viaaria-disabledplus a guard rather than thedisabledattribute, so a control going quiet under the cursor never blurs the person who just used it.readOnlyworks the same way: the sliders keep focus and arrow keys, only the commit refuses. - Copy is one-shot, self-clearing and honest about failing — a ref guards re-entry synchronously (a state guard would let a double-click start two writes), the resolve path bails out when the component has already unmounted, the verdict clears itself after two seconds, and a missing or denied clipboard becomes a visible failed state with a select-and-copy fallback instead of a rejected promise.
Regex Tester
A regex workbench: flag toggles, matches tinted in place, a group table, the engine's own error text, and a worker-backed budget that survives catastrophic backtracking.
Streak Tracker
A habit-streak card — the live run with a tiered flame, this week as seven day cells, a spendable freeze wallet and an at-risk countdown, all derived from an injected instant.