Option matrix
Dependent color x size selectors. Combos that are not made or sold out are disabled with the reason in a tooltip.
componentmatrixapparelbeautyvariantsinventory
$
npx shadcn@latest add @storekit/pdp-option-matrixpdp-option-matrix
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-option-matrix$
pnpm dlx shadcn@latest add @storekit/pdp-option-matrix$
yarn dlx shadcn@latest add @storekit/pdp-option-matrix$
bunx --bun shadcn@latest add @storekit/pdp-option-matrixSource
components/storekit/pdp/pdp-option-matrix.tsx
226 lines
"use client"
import { useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Dependent color x size selectors. Each axis gates the other: a size that
does not exist or is sold out in the chosen color is disabled with the
reason in a tooltip, and vice versa. Picking a color that kills the current
size clears the size and asks for one, instead of silently swapping it.
`stock` maps "colorId:sizeId" to units. A missing key means not made. */
type Color = { id: string; name: string; color: string }
type Size = { id: string; label: string; price: number }
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 TIP =
"pointer-events-none absolute -top-9 left-1/2 z-10 -translate-x-1/2 origin-bottom scale-95 whitespace-nowrap rounded-sk-chip bg-sk-ink px-2 py-1 text-[11px] font-medium text-sk-paper opacity-0 transition-[opacity,transform] duration-150 ease-sk-out group-hover/opt:scale-100 group-hover/opt:opacity-100 group-focus-within/opt:scale-100 group-focus-within/opt:opacity-100 motion-reduce:transition-none"
const DEFAULT_COLORS: Color[] = [
{ id: "sand", name: "Sand", color: "rgb(226 196 166)" },
{ id: "clay", name: "Clay", color: "rgb(184 120 92)" },
{ id: "cocoa", name: "Cocoa", color: "rgb(112 74 52)" },
]
const DEFAULT_SIZES: Size[] = [
{ id: "15", label: "15ml", price: 3200 },
{ id: "30", label: "30ml", price: 4900 },
{ id: "50", label: "50ml", price: 6900 },
]
const DEFAULT_STOCK: Record<string, number> = {
"sand:15": 40, "sand:30": 22, "sand:50": 9,
"clay:15": 12, "clay:30": 0, "clay:50": 6,
"cocoa:15": 18, "cocoa:30": 14,
}
function roving(e: React.KeyboardEvent, n: number, current: number, ok: (i: number) => boolean, pick: (i: number) => void) {
const fwd = e.key === "ArrowRight" || e.key === "ArrowDown"
const back = e.key === "ArrowLeft" || e.key === "ArrowUp"
if (!fwd && !back) return
e.preventDefault()
for (let s = 1; s <= n; s++) {
const i = (current + (fwd ? s : -s) + n * 2) % n
if (ok(i)) return pick(i)
}
}
export function PdpOptionMatrix({
colors = DEFAULT_COLORS,
sizes = DEFAULT_SIZES,
stock = DEFAULT_STOCK,
defaultColor,
defaultSize,
onChange,
className,
}: {
colors?: Color[]
sizes?: Size[]
stock?: Record<string, number>
defaultColor?: string
defaultSize?: string
onChange?: (sel: { colorId: string; sizeId: string | null; available: boolean }) => void
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const sk = useSkBlock("pdp", "pdp-option-matrix")
const [colorId, setColorId] = useState(defaultColor ?? colors[0]?.id)
const [sizeId, setSizeId] = useState<string | null>(defaultSize ?? null)
const colorRefs = useRef<(HTMLButtonElement | null)[]>([])
const sizeRefs = useRef<(HTMLButtonElement | null)[]>([])
const units = (c: string, s: string) => stock[`${c}:${s}`]
const sizeReason = (s: Size) => {
const u = units(colorId, s.id)
const cName = colors.find((c) => c.id === colorId)?.name ?? ""
if (u === undefined) return `Not made in ${cName}`
if (u <= 0) return `Sold out in ${cName}`
return null
}
const colorReason = (c: Color) => {
if (!sizeId) return sizes.some((s) => (units(c.id, s.id) ?? 0) > 0) ? null : "Sold out in every size"
const u = units(c.id, sizeId)
const sLabel = sizes.find((s) => s.id === sizeId)?.label ?? ""
if (u === undefined) return `Not made in ${sLabel}`
if (u <= 0) return `Sold out in ${sLabel}`
return null
}
const color = colors.find((c) => c.id === colorId) ?? colors[0]
const size = sizes.find((s) => s.id === sizeId) ?? null
const ci = Math.max(0, colors.findIndex((c) => c.id === colorId))
const si = sizes.findIndex((s) => s.id === sizeId)
const pickColor = (i: number, focus = false) => {
const c = colors[i]
if (!c) return
const keep = sizeId && (units(c.id, sizeId) ?? 0) > 0 ? sizeId : null
if (c.id !== colorId) sk.emit("interact", "color", undefined)
setColorId(c.id)
setSizeId(keep)
onChange?.({ colorId: c.id, sizeId: keep, available: !!keep })
if (focus) colorRefs.current[i]?.focus()
}
const pickSize = (i: number, focus = false) => {
const s = sizes[i]
if (!s || sizeReason(s)) return
if (s.id !== sizeId) sk.emit("interact", "size", undefined)
setSizeId(s.id)
onChange?.({ colorId, sizeId: s.id, available: true })
if (focus) sizeRefs.current[i]?.focus()
}
const summary = size ? `${color.name}, ${size.label}` : `${color.name}, choose a size`
const left = size ? units(colorId, size.id) ?? 0 : 0
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-5", className)}>
<fieldset className="flex flex-col gap-2.5">
<legend className="mb-2.5 flex w-full items-baseline gap-2 text-sm">
<span className="font-medium text-sk-ink">Shade</span>
<span className="text-sk-muted">{color.name}</span>
</legend>
<div role="radiogroup" aria-label="Shade" className="flex flex-wrap gap-1.5">
{colors.map((c, i) => {
const isSel = c.id === colorId
const reason = colorReason(c)
return (
<span key={c.id} className="group/opt relative">
<button
ref={(el) => {
colorRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={isSel}
aria-label={reason ? `${c.name}, ${reason.toLowerCase()}` : c.name}
aria-describedby={reason ? `${uid}-c-${i}` : undefined}
tabIndex={isSel ? 0 : -1}
onClick={() => pickColor(i)}
onKeyDown={(e) => roving(e, colors.length, ci, () => true, (j) => pickColor(j, true))}
className={cn("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}-cring`} aria-hidden transition={{ duration: reduce ? 0 : 0.35, ease: EASE }} className="absolute inset-0 rounded-sk-chip border-2 border-sk-ink" />
) : null}
<span aria-hidden className="relative h-8 w-8 overflow-hidden rounded-sk-chip ring-1 ring-inset ring-sk-ink/15" style={{ background: c.color }}>
{reason ? <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>
{reason ? (
<span id={`${uid}-c-${i}`} role="tooltip" className={TIP}>
{reason}
</span>
) : null}
</span>
)
})}
</div>
</fieldset>
<fieldset className="flex flex-col gap-2.5">
<legend className="mb-2.5 flex w-full items-baseline gap-2 text-sm">
<span className="font-medium text-sk-ink">Size</span>
<span className={cn(size ? "text-sk-muted" : "text-sk-ink")}>{size ? size.label : "Choose one"}</span>
</legend>
<div role="radiogroup" aria-label="Size" className="flex flex-wrap gap-2">
{sizes.map((s, i) => {
const isSel = s.id === sizeId
const reason = sizeReason(s)
const focusable = si >= 0 ? isSel : i === sizes.findIndex((x) => !sizeReason(x))
return (
<span key={s.id} className="group/opt relative">
<button
ref={(el) => {
sizeRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={isSel}
aria-disabled={reason ? true : undefined}
aria-describedby={reason ? `${uid}-s-${i}` : undefined}
tabIndex={focusable ? 0 : -1}
onClick={() => pickSize(i)}
onKeyDown={(e) => roving(e, sizes.length, si >= 0 ? si : i, (j) => !sizeReason(sizes[j]), (j) => pickSize(j, true))}
className={cn(
"h-11 min-w-[72px] rounded-sk-btn border px-4 text-sm font-medium tabular-nums transition-[border-color,background-color,color,transform] duration-200 ease-sk-out active:scale-[0.97]",
isSel ? "border-sk-accent bg-sk-accent/[0.08] text-sk-ink" : "border-sk-line text-sk-ink hover:border-sk-ink/40",
reason && "cursor-not-allowed border-dashed text-sk-muted line-through decoration-sk-muted/70 hover:border-sk-line active:scale-100",
RING
)}
>
{s.label}
</button>
{reason ? (
<span id={`${uid}-s-${i}`} role="tooltip" className={TIP}>
{reason}
</span>
) : null}
</span>
)
})}
</div>
</fieldset>
<div className="flex min-h-[48px] items-center justify-between gap-4 rounded-sk-card bg-sk-surface px-4 py-3" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={summary}
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="flex min-w-0 flex-col"
>
<span className="truncate text-sm font-medium text-sk-ink">{summary}</span>
<span className="text-[13px] tabular-nums text-sk-muted">{size ? `${left} in stock` : "Pick a size to see stock"}</span>
</motion.span>
</AnimatePresence>
<span className="shrink-0 text-sm font-semibold tabular-nums text-sk-price">{size ? money(size.price) : `From ${money(Math.min(...sizes.map((s) => s.price)))}`}</span>
</div>
</div>
)
}
Usage
`stock` maps "colorId:sizeId" to units. A missing key means the combo is not made, 0 means sold out.
