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…

"use client"

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

export interface SwatchPickerSwatch {
  /** Any valid CSS color (hex, oklch, named, `var(--chart-1)`…) — consumer
   *  data painted verbatim via inline style, not subject to the token rule
   *  that governs this component's own chrome. */
  value: string
  /** Accessible name announced by the radio and used as the tooltip-free label. */
  label: string
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/swatch-picker.json

Prompt

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.
- 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 for the component's own chrome: border-border/50 on
  each swatch, ring-ring + ring-offset-background on the selected one,
  focus-visible ring tokens. The only intentional exception is the check
  mark: since it sits on an arbitrary consumer-supplied background, no
  token can guarantee contrast, so it renders as literal white with a dark
  drop-shadow for definition — documented, not accidental.
- 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 dataswatch.value is 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 a theme token because it must stay legible against whatever color the caller passes in, including a swatch that's already white. It uses literal white plus a drop-shadow instead.
  • Preset palette selection — this is for choosing among a small, designer-defined set of colors, not for free-form color entry.

On This Page