Swatches plus more
Featured swatches with a +N more panel grid grouped by family. A pick from the long tail is promoted into view.
componentswatchbeautyapparelvariants
$
npx shadcn@latest add @storekit/pdp-swatch-morepdp-swatch-more
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-more$
pnpm dlx shadcn@latest add @storekit/pdp-swatch-more$
yarn dlx shadcn@latest add @storekit/pdp-swatch-more$
bunx --bun shadcn@latest add @storekit/pdp-swatch-moreSource
components/storekit/pdp/pdp-swatch-more.tsx
257 lines
"use client"
import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { X } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Featured swatches plus "+N more". The first few shades stay on the page;
the long tail opens in a panel grid, grouped by family, directly under
the row (in flow, so no parent overflow can clip it). Picking a shade
from the tail promotes it into the visible row so the choice stays in view.
Escape closes, Tab is trapped inside the panel, focus returns to the
trigger. `color` is any CSS color string carried on inline style. */
type Shade = { id: string; name: string; color: string; family?: 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_SHADES: Shade[] = [
["Porcelain", "248 230 216", "Fair"], ["Ivory", "242 222 200", "Fair"], ["Shell", "238 214 192", "Fair"], ["Linen", "232 206 180", "Fair"],
["Sand", "226 196 166", "Light"], ["Beige", "218 186 152", "Light"], ["Wheat", "212 178 140", "Light"], ["Buff", "204 168 130", "Light"],
["Honey", "196 154 112", "Medium"], ["Caramel", "184 140 98", "Medium"], ["Amber", "172 126 86", "Medium"], ["Toffee", "160 114 78", "Medium"],
["Chestnut", "138 96 66", "Deep"], ["Cocoa", "118 80 56", "Deep"], ["Mocha", "98 66 46", "Deep"], ["Espresso", "76 50 36", "Deep"],
["Ebony", "58 38 28", "Deep"], ["Onyx", "44 30 24", "Deep"],
].map(([name, rgb, family], i) => ({ id: name.toLowerCase(), name, color: `rgb(${rgb})`, family, soldOut: i === 10 }))
function Dot({ s, selected, onPick, size = "md", refCb, tabIndex, onKeyDown, layoutGroup }: {
s: Shade
selected: boolean
onPick: () => void
size?: "md" | "sm"
refCb?: (el: HTMLButtonElement | null) => void
tabIndex?: number
onKeyDown?: (e: React.KeyboardEvent) => void
layoutGroup?: string
}) {
const reduce = useReducedMotion()
return (
<button
ref={refCb}
type="button"
role="radio"
aria-checked={selected}
aria-label={s.soldOut ? `${s.name}, sold out` : s.name}
title={s.name}
tabIndex={tabIndex}
onClick={onPick}
onKeyDown={onKeyDown}
className={cn("group/sw relative grid h-11 w-11 shrink-0 place-items-center rounded-sk-chip transition-transform duration-200 ease-sk-out active:scale-[0.97]", RING)}
>
{selected ? (
layoutGroup ? (
<motion.span layoutId={layoutGroup} 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-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={cn("relative overflow-hidden rounded-sk-chip ring-1 ring-inset ring-sk-ink/15", size === "md" ? "h-8 w-8" : "h-7 w-7")} 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>
)
}
export function PdpSwatchMore({
shades = DEFAULT_SHADES,
featured = 6,
label = "Shade",
defaultValue,
onChange,
className,
}: {
shades?: Shade[]
/** How many shades stay visible before the "+N more" trigger. */
featured?: number
label?: string
defaultValue?: string
onChange?: (id: string) => void
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const sk = useSkBlock("pdp", "pdp-swatch-more")
const [value, setValue] = useState(defaultValue ?? shades.find((s) => !s.soldOut)?.id ?? shades[0]?.id)
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const rowRefs = useRef<(HTMLButtonElement | null)[]>([])
const selected = shades.find((s) => s.id === value) ?? shades[0]
const head = shades.slice(0, featured)
const visible = head.some((s) => s.id === value) || !selected ? head : [...head.slice(0, featured - 1), selected]
const rest = shades.length - visible.length
const vi = Math.max(0, visible.findIndex((s) => s.id === value))
const pick = (id: string) => {
if (id !== value) sk.emit("interact", "swatch", undefined)
setValue(id)
onChange?.(id)
}
const close = () => {
setOpen(false)
triggerRef.current?.focus()
}
useEffect(() => {
if (!open) return
const panel = panelRef.current
const first = panel?.querySelector<HTMLElement>('[aria-checked="true"]') ?? panel?.querySelector<HTMLElement>("button")
first?.focus()
const onDown = (e: MouseEvent) => {
if (panel && !panel.contains(e.target as Node) && !triggerRef.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener("mousedown", onDown)
return () => document.removeEventListener("mousedown", onDown)
}, [open])
const onPanelKeys = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
return close()
}
if (e.key !== "Tab" || !panelRef.current) return
const items = Array.from(panelRef.current.querySelectorAll<HTMLElement>("button"))
const i = items.indexOf(document.activeElement as HTMLElement)
if (e.shiftKey && i <= 0) {
e.preventDefault()
items[items.length - 1]?.focus()
} else if (!e.shiftKey && i === items.length - 1) {
e.preventDefault()
items[0]?.focus()
}
}
const onRowKeys = (e: React.KeyboardEvent) => {
const n = visible.length
let next = -1
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (vi + 1) % n
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (vi - 1 + n) % n
if (next < 0) return
e.preventDefault()
pick(visible[next].id)
rowRefs.current[next]?.focus()
}
const families = Array.from(new Set(shades.map((s) => s.family ?? ""))).map((f) => ({ family: f, items: shades.filter((s) => (s.family ?? "") === f) }))
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("relative 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="truncate text-sk-muted" aria-live="polite">
{selected?.name}
{selected?.soldOut ? ", sold out" : ""}
</span>
<span className="ml-auto shrink-0 font-sk-mono text-[11px] tabular-nums text-sk-muted">{shades.length} shades</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<div role="radiogroup" aria-labelledby={`${uid}-label`} className="flex flex-wrap gap-1.5">
{visible.map((s, i) => (
<Dot
key={s.id}
s={s}
selected={s.id === value}
onPick={() => pick(s.id)}
tabIndex={i === vi ? 0 : -1}
onKeyDown={onRowKeys}
refCb={(el) => {
rowRefs.current[i] = el
}}
layoutGroup={`${uid}-ring`}
/>
))}
</div>
{rest > 0 ? (
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={`${uid}-panel`}
onClick={() => {
if (!open) sk.emit("interact", "more_open", undefined)
setOpen((o) => !o)
}}
className={cn(
"h-11 rounded-sk-chip border border-sk-line px-3.5 text-[13px] font-medium tabular-nums text-sk-ink transition-[border-color,background-color,transform] duration-200 ease-sk-out hover:border-sk-ink/40 active:scale-[0.97]",
open && "border-sk-ink/40 bg-sk-ink/[0.04]",
RING
)}
>
+{rest} more
</button>
) : null}
</div>
<AnimatePresence initial={false}>
{open ? (
<motion.div
ref={panelRef}
id={`${uid}-panel`}
role="dialog"
aria-modal="false"
aria-label={`All ${label.toLowerCase()}s`}
onKeyDown={onPanelKeys}
initial={{ opacity: 0, y: reduce ? 0 : 8, scale: reduce ? 1 : 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduce ? 0 : 4, scale: reduce ? 1 : 0.98 }}
transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}
className="origin-top rounded-sk-card border border-sk-line bg-sk-card p-4 shadow-[0_24px_60px_-24px_rgb(var(--sk-ink)/0.25)] sm:p-5"
>
<div className="mb-3 flex items-center justify-between gap-3">
<span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">All {shades.length} shades</span>
<button
type="button"
aria-label="Close"
onClick={close}
className={cn("-mr-2 grid h-11 w-11 place-items-center rounded-sk-btn text-sk-muted transition-[color,transform] duration-200 hover:text-sk-ink active:scale-[0.97]", RING)}
>
<X size={16} />
</button>
</div>
<div className="flex max-h-[260px] flex-col gap-3 overflow-y-auto">
{families.map((g) => (
<div key={g.family} role="radiogroup" aria-label={g.family || label} className="flex flex-col gap-1.5">
{g.family ? <span className="text-[13px] text-sk-muted">{g.family}</span> : null}
<div className="flex flex-wrap gap-1">
{g.items.map((s) => (
<Dot
key={s.id}
s={s}
size="sm"
selected={s.id === value}
onPick={() => {
pick(s.id)
close()
}}
/>
))}
</div>
</div>
))}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
Usage
Import from "@/components/storekit/pdp/pdp-swatch-more" and pass your product data. The demo above exercises the full prop surface with sample values.
