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

Source

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.

Option matrix, StoreKit