Display

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.

Preview in your theme

Loading preview…

"use client"

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

const SIZE_STYLES = {
  sm: {
    chip: "h-10",
    grid: "[grid-template-columns:repeat(auto-fill,minmax(min(8.5rem,100%),1fr))]",
    sample: "text-xs",
  },
  md: {
    chip: "h-16",

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/color-palette.json

Prompt

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 the prefers-color-scheme media 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.

On This Page