Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Copy, CopyX, RotateCcw, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long the copy verdict (tick or cross) stays on the button. */
const COPY_RESET_MS = 2000
/** No sRGB colour is this saturated at any lightness — the search only narrows down from here. */
const CHROMA_LIMIT = 0.4
/** Halvings of the chroma search: 18 lands well inside the 0.001 the printed value carries. */
const GAMUT_STEPS = 18
/** Slack of the gamut test, in linear-sRGB channel units. Floating point, not a design decision. */
const GAMUT_EPSILON = 1e-4

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/theme-customizer.json

Prompt

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 outputsbuildTheme produces 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; documentElement is 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…xl steps, is deliberately left out of the export, because at :root Tailwind 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 disableddefaultValue is 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 via aria-disabled plus a guard rather than the disabled attribute, so a control going quiet under the cursor never blurs the person who just used it. readOnly works 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.

On This Page