Color Palette
A live specimen sheet for a theme's color tokens — chips grouped by role, values read back from the DOM, ink/fill pairing previews and one-click copy.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/color-palette.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ColorPalette" component: a specimen sheet
for a theme's CSS colour custom properties. lucide-react for icons, no other
runtime dependency, no colour-parsing library.
Contract
- Export a forwardRef <div> extending React.HTMLAttributes<HTMLDivElement>.
- groups: { label, description?, tokens: ColorToken[] }[] where
ColorToken = { name, label?, on? }.
- name: the custom property. Accept it with or without the leading dashes and
normalise once ("primary" and "--primary" are the same token).
- label: human title. Without it the swatch prints the property name itself,
which is what a chart series wants.
- on: the ink token meant to sit on this fill. It is the pairing preview, not
a second specimen.
- copyAs?: "value" | "var" (default "value") — what the clipboard receives: the
resolved colour, or the literal var(--primary).
- size?: "sm" | "md" (default "md") — drives chip height AND the minimum column
width of the swatch grid; the two must move together or short chips end up in
wide cells.
- label?: string (default "Color tokens") for the group's accessible name.
- onCopyToken?: (token, text) => void — fires only after the write succeeded.
- The component owns no colour data. Every value on screen came from the
document; nothing is hardcoded, so it can never drift from the theme.
Behavior
- Reading values: in an effect, take getComputedStyle(rootElement) and call
getPropertyValue(name).trim() for every token. Read off the component's own
root, not documentElement, so a scoped theme (a .dark or [data-theme] wrapper
around the sheet) resolves to what is actually painted here.
- Three states per token, not two: null-map = "not read yet" (server render and
the first paint) renders a pulse placeholder; "" = the theme does not define
it; anything else is the value. Collapsing the first two makes the whole sheet
flash "not defined" for one frame on every mount.
- Following the theme: one MutationObserver watching { attributes: true,
attributeFilter: ["class","style","data-theme"] } on BOTH documentElement
(where next-themes writes) and the component root; plus a change listener on
matchMedia("(prefers-color-scheme: dark)") for themes that never touch an
attribute. Re-read on every signal. Compare the new map against the old one
key by key and return the previous object when nothing moved — a theme flip
fires several mutations and only one of them should cost a render.
- Effect identity: key the effect on the token names joined into a string, not
on the groups array — an inline groups literal is a new array on every parent
render and would tear the observers down and rebuild them each time.
- Copying: read the value, refuse when it is "" (a token this theme does not
define has nothing honest to put on the clipboard — do not write an empty
string and do not flash a tick). Then guard re-entry with a ref that is read
AND written synchronously inside the handler; a state flag lets the second
click of a double-click through, because both clicks read the same pre-update
state. Clear the ref when the write settles.
- The clipboard write never throws: an insecure context, a denied permission or
a browser without navigator.clipboard all resolve to false, and false paints a
visible failure (cross icon + "select the value and press Ctrl/Cmd+C"), never
a silent success.
- Copy result: tick or cross on that swatch for 2000ms, then back to the copy
icon. Re-arming the timer clears the previous one. An async write can land
after unmount, so check a mounted ref before touching state — otherwise you
arm a timer past the cleanup that was supposed to clear it.
- Keyboard: roving tabindex over the flattened token list — indices run across
group boundaries in reading order, exactly one swatch is tabbable, so Tab
enters and leaves the whole sheet in one stop. onFocus sets the active index,
which keeps mouse and keyboard in sync.
- ArrowRight / ArrowLeft: next / previous swatch in reading order, clamped at
the ends (this is a document, not a carousel — no wrap-around).
- ArrowDown / ArrowUp: nearest swatch one visual row down / up. Measure with
getBoundingClientRect in the same tick, then pick, among boxes whose top is
strictly beyond the current top by more than a 1px epsilon, first the
nearest row and then within that row the smallest |left - currentLeft|. Use
rects, not offsetTop: viewport coordinates do not care which ancestor
happens to be positioned. When no such row exists, land on the last / first
swatch instead of doing nothing.
- Home / End: first / last swatch of the whole sheet.
- Enter / Space: native button activation, i.e. copy.
Chain the consumer's onKeyDown first and bail out if it called preventDefault.
- ARIA: root is role="group" with the aria-label; each family is a <section>
with an <h3> and a <ul aria-labelledby={headingId}> (useId), so a screen
reader announces "Surfaces, list, 4 items"; every swatch is a type="button"
whose accessible name comes from its own text — a visually hidden "Copy" or
"Cannot copy" prefix, then title, property name and value. The chip is
aria-hidden (it is a picture of the value that is already spoken). The copy
result goes to one polite role="status" region that is cleared after the
reset, so the NEXT identical copy is announced again.
- Undefined tokens get aria-disabled, never the native disabled attribute: a
token can vanish when the theme flips and the browser blurs a node the instant
it becomes disabled, dropping focus on <body>. No pointer-events:none either —
the refusal lives in the handler, so the swatch stays hoverable, focusable and
announced. It stays in the arrow-key traversal too: it is documentation.
- Degenerate cases: a group with zero tokens prints "No tokens in this group."
instead of an empty grid; groups: [] renders nothing but the live region; a
token whose `on` is undefined shows a plain chip; a token whose `on` does not
resolve falls back through var(--ink, currentColor) so the sample degrades to
the inherited colour instead of disappearing.
- Cleanup: disconnect the observer, remove the media-query listener and clear
the reset timer on unmount and whenever the token list changes.
Rendering & styling
- Semantic tokens only: swatch card bg-card + border + hover:bg-accent, titles
text-foreground, property names and values text-muted-foreground, the
"not defined in this theme" line text-destructive, the copy tick text-primary,
focus-visible:ring-2 ring-ring with ring-offset-2 ring-offset-background.
Merge the consumer className with cn().
- The chip is painted with an inline style backgroundColor:
var(--token, var(--muted)) — inline because the token name is data, and with
a fallback so an unknown token degrades to the muted surface instead of
collapsing to transparent. This is also why the chips are already correct in
server-rendered HTML: only the value TEXT waits for the first read.
- The pairing sample is the string "Aa" inside the chip with
style={{ color: "var(--ink, currentColor)" }}, plus a mono line naming the ink
token underneath.
- Grid: grid with
[grid-template-columns:repeat(auto-fill,minmax(min(11rem,100%),1fr))]
(8.5rem for "sm"). auto-fill rather than auto-fit keeps swatch width identical
across families, so a five-token row and a two-token row still line up.
min(...,100%) is what stops a narrow card from overflowing.
- Values wrap with break-all — a colour value is long, and truncating the one
string the reader came for is worse than two lines. Titles truncate.
- Motion is decorative only: transition-colors on hover and animate-pulse on the
pre-read placeholder, both with motion-reduce variants. With motion off the
component reads, copies and navigates identically.
- Never let a <li> or <ul> become position:relative if you switch the row maths
back to offsetTop — each item would become its own offsetParent and every
swatch would report the same top.
Customization levers
- Density: size is the whole axis — chip height plus minimum column width. Add
an "lg" entry to SIZE_STYLES only; nothing else reads the size.
- Which sub-blocks: drop the group description for a tighter sheet, drop the
mono property line when every token is labelled, or drop the value line
entirely for a purely visual palette (keep copy: it still writes the value).
- copyAs is a literal union — add "name" (the bare --primary) or "tailwind"
(bg-primary) by extending the union and the one ternary that builds the text.
- Pairing: pass `on` only for tokens that really have a canonical ink. A sheet
where every chip says "Aa" reads as decoration; a sheet where only the paired
ones do reads as a rule.
- Grouping is the buyer's taxonomy, not ours: surfaces / actions / chart series
/ status is a good default, but "by brand" or "by page" works with the same
contract.
- Tokens: bg-card swatches on a plain page, or bg-background swatches inside an
already-carded settings panel; the chip border is what keeps a
--background-coloured chip visible on a --background page — do not remove it.
- Copy target: onCopyToken is where you hang a toast, an analytics event, or a
"recently copied" list.Concepts
- Computed-value readback — the sheet asks the browser what the token resolved to instead of shipping a table of colours, so it documents the theme that is really loaded, including a consumer's overrides, and it can never drift out of date.
- Theme-flip re-read — one observer on
<html>and on the sheet's own root, plus theprefers-color-schememedia query, covers class-based themes, scoped themes and OS-driven ones; equality-checking the new map means several mutations cost at most one render. - Paint before you can read — chips are coloured through
var(--token)inline, so the palette is correct in server-rendered HTML and only the numeric text waits for hydration; the pre-read state is a third state, not "missing". - Refusal over silent success — a token the theme does not define stays listed and stays focusable because that absence is the useful information, but copying is refused rather than writing an empty string and flashing a tick.
- Row-aware roving tabindex — one Tab stop for the whole sheet; left/right walk reading order across group boundaries while up/down measure the wrapped grid at keypress time, so the same key does the visually right thing at four columns and at one.
- Pairing preview, not a verdict — the "Aa" sample shows the ink a fill is meant to carry so a wrong pairing is obvious at a glance; it deliberately computes no contrast ratio, which is a different component's job.
Type Scale
A living typography specimen: every step set in real type, its px, rem, leading and tracking read back off the DOM, with copy-the-token rows and a heading / paragraph / numerals switcher.
Contrast Checker
A WCAG contrast readout for one colour pair — resolved through the live cascade, composited on a canvas, with AA/AAA verdicts and the nearest passing lightness.