Inputs
Swatch Picker
An accessible grid of preset color swatches — radiogroup semantics, roving tabindex, and a contrast-safe selected check mark.
Preview in your theme
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/swatch-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SwatchPicker" component (lucide-react Check).
Contract
- Export a forwardRef div extending HTMLAttributes (omit native onChange).
- Props: swatches ({ value: string; label: string }[]), value (string | null,
controlled), onChange ((value: string) => void), size ("sm" | "md", default
"md"), disabled (default false), label (aria name, default "Color").
- swatch.value is any valid CSS color and is consumer data — it is painted
verbatim via inline style and is not subject to the component's own
semantic-token rule.
Behavior
- Container is role="radiogroup"; each swatch is a role="radio" button with
aria-checked and aria-label = swatch.label.
- Roving tabindex: only one swatch is ever tabbable — the selected one, or
the first swatch when nothing is selected (value has no match).
- Keyboard: ArrowRight/ArrowDown move to the next swatch, ArrowLeft/ArrowUp
to the previous, both circular (wrap past the last/first); Home/End jump
to the first/last. Moving both focuses and selects (calls onChange) —
same semantics as a native radio group. Arrows step from the tab stop, not
from the selection, so with nothing selected ArrowRight on the first
swatch selects the second.
- Click selects a swatch directly and moves focus to it.
- Selected swatch: ring-2 ring-offset-2 ring-ring, plus a centered Check icon.
- disabled disables every button (no focus, no click) and dims the group.
Rendering & styling
- Semantic tokens only: border-border/50 on each swatch, ring-ring +
ring-offset-background on the selected one, focus-visible ring tokens.
The check mark sits on an arbitrary consumer-supplied background, so no
single token can guarantee contrast. Render it as two stacked Check icons
at the same position: a text-foreground one with strokeWidth 5 as a halo,
and a text-background one with strokeWidth 3 on top. Foreground and
background always contrast with each other, so the mark reads on a white
and on a near-black swatch in either theme.
- Hover scale (hover:scale-110) is disabled under prefers-reduced-motion
(transition and the scale itself). Merge className via cn().
Customization levers
- Shape: swap rounded-full for rounded-md if the product wants square chips.
- Selection: add a multi-select variant (array value + toggle) for tagging
use cases — out of scope here, this component is single-select.
- Validation: if swatches come from user input rather than a fixed design
set, validate value as a real CSS color before rendering.
- Sizes: extend the SIZES/CHECK_SIZES maps for an "lg" tier.Concepts
- Roving tabindex — the radiogroup exposes exactly one tab stop; arrow keys move it instead of Tab, matching native radio-group behavior.
- Consumer-supplied color data —
swatch.valueis any CSS color the caller provides (hex, a named color,var(--chart-1)); it paints the swatch directly and sits outside this component's own semantic-token rule, which still governs every other pixel (border, ring, focus outline). - Contrast-safe overlay — the selected check mark can't rely on one theme token because it must stay legible against whatever color the caller passes in, including a swatch that's already white. It pairs two tokens that always contrast with each other: a
background-coloured check drawn over a widerforeground-coloured halo. - Preset palette selection — this is for choosing among a small, designer-defined set of colors, not for free-form color entry.