Color swatches
Color-dot radiogroup with a gliding selected ring, live color label and a diagonal strike for sold out colors.
componentswatchbeautyapparelvariants
$
npx shadcn@latest add @storekit/pdp-swatch-colorspdp-swatch-colors
Live and interactive. Switch the brand in the frame header to see the same source under another token file.
Installation
$
npx shadcn@latest add @storekit/pdp-swatch-colors$
pnpm dlx shadcn@latest add @storekit/pdp-swatch-colors$
yarn dlx shadcn@latest add @storekit/pdp-swatch-colors$
bunx --bun shadcn@latest add @storekit/pdp-swatch-colorsSource
components/storekit/pdp/pdp-swatch-colors.tsx
140 lines
"use client"
import { useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Color-dot swatches. A real radiogroup with arrow keys and roving focus.
The selected ring glides between dots, the label names the selected color
(or the hovered one while pointing), and sold out colors keep their dot with
a diagonal strike so shoppers can still pick them and ask for a restock.
`color` is any CSS color string; it is data, so it rides on inline style. */
type Swatch = { id: string; name: string; color: string; soldOut?: boolean }
const RING = "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
const EASE = [0.16, 1, 0.3, 1] as const
const DEFAULT_SWATCHES: Swatch[] = [
{ id: "sand", name: "Sand", color: "rgb(222 205 180)" },
{ id: "clay", name: "Clay", color: "rgb(184 120 92)" },
{ id: "sage", name: "Sage", color: "rgb(150 166 136)" },
{ id: "slate", name: "Slate", color: "rgb(84 96 112)" },
{ id: "ink", name: "Ink", color: "rgb(28 30 36)", soldOut: true },
{ id: "bone", name: "Bone", color: "rgb(246 243 236)" },
]
export function PdpSwatchColors({
swatches = DEFAULT_SWATCHES,
label = "Color",
defaultValue,
onChange,
className,
}: {
swatches?: Swatch[]
label?: string
defaultValue?: string
onChange?: (id: string) => void
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const sk = useSkBlock("pdp", "pdp-swatch-colors")
const [value, setValue] = useState(defaultValue ?? swatches.find((s) => !s.soldOut)?.id ?? swatches[0]?.id)
const [hover, setHover] = useState<string | null>(null)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const current = Math.max(0, swatches.findIndex((s) => s.id === value))
const selected = swatches[current]
const shown = swatches.find((s) => s.id === hover) ?? selected
const select = (i: number, focus = false) => {
const s = swatches[i]
if (!s) return
if (s.id !== value) sk.emit("interact", "swatch", undefined)
setValue(s.id)
onChange?.(s.id)
if (focus) refs.current[i]?.focus()
}
const onKeyDown = (e: React.KeyboardEvent) => {
const n = swatches.length
let next = -1
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (current + 1) % n
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (current - 1 + n) % n
else if (e.key === "Home") next = 0
else if (e.key === "End") next = n - 1
if (next < 0) return
e.preventDefault()
select(next, true)
}
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-3", className)}>
<div className="flex items-baseline gap-2 text-sm">
<span id={`${uid}-label`} className="font-medium text-sk-ink">
{label}
</span>
<span className="relative inline-flex h-5 min-w-0 flex-1 overflow-hidden" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={shown?.id}
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -6 }}
transition={{ duration: reduce ? 0 : 0.2, ease: EASE }}
className="truncate text-sk-muted"
>
{shown?.name}
{shown?.soldOut ? <span className="text-sk-muted/80">, sold out</span> : null}
</motion.span>
</AnimatePresence>
</span>
</div>
<div role="radiogroup" aria-labelledby={`${uid}-label`} className="flex flex-wrap gap-1.5" onMouseLeave={() => setHover(null)}>
{swatches.map((s, i) => {
const isSel = s.id === value
return (
<button
key={s.id}
ref={(el) => {
refs.current[i] = el
}}
type="button"
role="radio"
aria-checked={isSel}
aria-label={s.soldOut ? `${s.name}, sold out` : s.name}
title={s.name}
tabIndex={isSel ? 0 : -1}
onClick={() => select(i)}
onKeyDown={onKeyDown}
onMouseEnter={() => setHover(s.id)}
className={cn("group/sw relative grid h-11 w-11 place-items-center rounded-sk-chip transition-transform duration-200 ease-sk-out active:scale-[0.97]", RING)}
>
{isSel ? (
<motion.span
layoutId={`${uid}-ring`}
aria-hidden
transition={{ duration: reduce ? 0 : 0.35, ease: EASE }}
className="absolute inset-0 rounded-sk-chip border-2 border-sk-ink"
/>
) : (
<span aria-hidden className="absolute inset-0 rounded-sk-chip border border-transparent transition-colors duration-200 group-hover/sw:border-sk-line" />
)}
<span
aria-hidden
className="relative h-8 w-8 overflow-hidden rounded-sk-chip ring-1 ring-inset ring-sk-ink/15"
style={{ background: s.color }}
>
{s.soldOut ? (
<span className="absolute left-1/2 top-1/2 h-[1.5px] w-[140%] -translate-x-1/2 -translate-y-1/2 -rotate-45 bg-sk-paper shadow-[0_0_0_0.5px_rgb(var(--sk-ink)/0.5)]" />
) : null}
</span>
</button>
)
})}
</div>
</div>
)
}
Usage
Swatch colors are data: pass any CSS color string and it renders on inline style.
