Swatch Picker
A colour radio group whose selection ring springs between swatches, with a live name and hex preview.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/swatch-picker.json'use client'
import { useRef } from 'react'
import './swatch-picker.css'
export interface Swatch {
name: string
hex: string
}
export interface SwatchPickerProps {
swatches: Swatch[]
value: string
onValueChange: (hex: string) => void
label?: string
shape?: 'circle' | 'square'
/** Swatch size in px. */
size?: number
fill?: 'dark' | 'light'
className?: string
}
/** A colour radio group: the selection ring springs between swatches and a preview shows the name and hex. */
export function SwatchPicker({ swatches, value, onValueChange, label = 'Colour', shape = 'circle', size = 34, fill = 'dark', className }: SwatchPickerProps) {
const refs = useRef<(HTMLButtonElement | null)[]>([])
const idx = Math.max(0, swatches.findIndex((s) => s.hex === value))
const cur = swatches[idx]
const move = (d: number) => {
const n = (idx + d + swatches.length) % swatches.length
onValueChange(swatches[n].hex)
refs.current[n]?.focus()
}
return (
<div className={['sd-sw', className].filter(Boolean).join(' ')} data-fill={fill} data-shape={shape} style={{ '--sw-size': `${size}px`, '--sw-cur': cur.hex } as React.CSSProperties}>
<div className="sd-sw__preview" aria-hidden>
<span className="sd-sw__blob" />
<span>
<b>{cur.name}</b>
<code>{cur.hex.toUpperCase()}</code>
</span>
</div>
<div role="radiogroup" aria-label={label} className="sd-sw__row">
<span className="sd-sw__ring" style={{ transform: `translateX(calc(${idx} * (var(--sw-size) + 10px)))` }} aria-hidden />
{swatches.map((s, i) => (
<button
key={s.hex}
ref={(el) => {
refs.current[i] = el
}}
type="button"
role="radio"
aria-checked={i === idx}
aria-label={s.name}
tabIndex={i === idx ? 0 : -1}
className="sd-sw__dot"
style={{ background: s.hex }}
onClick={() => onValueChange(s.hex)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') (e.preventDefault(), move(1))
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') (e.preventDefault(), move(-1))
}}
/>
))}
</div>
</div>
)
}.sd-sw { --fg: #fafafa; --mute: #8e8e8e; --bg: #1d1d1d; --line: rgb(255 255 255 / 0.08); --r: 999px;
display: grid; gap: 16px; padding: 16px; border-radius: 22px; background: var(--bg); color: var(--fg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-sw[data-fill='light'] { --fg: #121212; --mute: #6f6f6f; --bg: #fff; --line: rgb(0 0 0 / 0.08); }
.sd-sw[data-shape='square'] { --r: 10px; }
.sd-sw__preview { display: flex; align-items: center; gap: 12px; }
.sd-sw__blob { width: 52px; height: 52px; border-radius: calc(var(--r) * 1.4); background: var(--sw-cur); box-shadow: 0 10px 26px -8px var(--sw-cur); transition: background-color 400ms, box-shadow 400ms; }
.sd-sw__preview b { display: block; font-size: 18px; font-weight: 600; letter-spacing: -0.02em; }
.sd-sw__preview code { font-size: 12.5px; color: var(--mute); font-family: ui-monospace, monospace; }
.sd-sw__row { position: relative; display: flex; gap: 10px; }
.sd-sw__dot { width: var(--sw-size); height: var(--sw-size); border-radius: var(--r); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.15); transition: transform 300ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-sw__dot:hover { transform: scale(1.08); }
.sd-sw__dot[aria-checked='true'] { transform: scale(0.72); }
.sd-sw__dot:focus-visible { outline: 2px solid var(--fg); outline-offset: 5px; }
.sd-sw__ring { position: absolute; left: 0; top: 0; width: var(--sw-size); height: var(--sw-size); border-radius: var(--r); box-shadow: inset 0 0 0 2.5px var(--sw-cur); transition: transform 480ms cubic-bezier(0.25, 1.4, 0.35, 1), box-shadow 400ms; pointer-events: none; }
@media (prefers-reduced-motion: reduce) { .sd-sw * { transition: none !important; } }Overview
One ring slides between swatches while the chosen dot shrinks inside it. A preview blob takes the colour and casts a matching glow.
Arrow keys move the selection, like any radio group.
When to use
- Product variants, theme accents, label colours.
When not to use
- Arbitrary colours: use a full colour picker.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/swatch-picker.json$
npx shadcn@latest add https://sundaeui.dev/r/swatch-picker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/swatch-picker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/swatch-picker.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy swatch-picker.tsx and swatch-picker.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { SwatchPicker } from '@/components/sundae/swatch-picker'
<SwatchPicker swatches={swatches} value={accent} onValueChange={setAccent} label="Accent colour" />Props
| Prop | Type | Default |
|---|---|---|
swatches*Options. | { name; hex }[] | — |
value*Selected hex. | string | — |
onValueChange*Called on change. | (hex: string) => void | — |
shapeSwatch shape. | 'circle' | 'square' | 'circle' |
sizeSwatch size, px. | number | 34 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A radiogroup with roving tabindex; each swatch is named ("Lagoon").
- Visible focus ring offset from the swatch.
- Reduced motion removes the ring slide.
Customization
- Every colour is a CSS custom property on the root class; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Previous / next colour
FAQ
- Is Swatch Picker free?
- Yes. Swatch Picker is free for personal and commercial projects, with full source on this page.
- How do I install Swatch Picker?
- Run npx shadcn@latest add https://sundaeui.dev/r/swatch-picker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Swatch Picker support keyboard and screen readers?
- A radiogroup with roving tabindex; each swatch is named ("Lagoon"). Visible focus ring offset from the swatch.
- Does Swatch Picker respect reduced motion?
- Reduced motion removes the ring slide.
More like this
All forms →Jelly Switch
Forms · PlayfulRolling Counter
Forms · Playful- Pro
Wheel Picker
Forms · Refined Rating Scale
Forms · RefinedRadio Cards
Forms · RefinedTime Field
Forms · Refined