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-colors
pdp-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-colors

Source

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.

Color swatches, StoreKit