"use client";
import * as React from "react";
import { motion } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface VariantOptionValue {
/** Stable value stored in the selection record and in `ProductVariant.options`. */
value: string;
/** Display label (defaults to `value`). */
label?: string;
/** CSS colour for swatch options. */
color?: string;
}
export interface VariantOption {
/** Key used in the selection record, e.g. "colour". */
name: string;
/** Visible group label, e.g. "Colour" (defaults to `name`). */
label?: string;
/** Round/square colour swatches or text buttons. */
kind?: "swatch" | "button";
values: VariantOptionValue[];
}
export interface ProductVariant {
id: string;
/** One value per option name, e.g. { colour: "sage", size: "m" }. */
options: Record<string, string>;
price: number;
stock: number;
}
/** The chosen value per option name. */
export type VariantSelection = Record<string, string>;
type Spring = { stiffness: number; damping: number; mass: number };
export interface VariantPickerProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue" | "children"> {
/** Option groups in display order. */
options: VariantOption[];
/** Every variant that exists; combinations that are missing are treated as "not made". */
variants: ProductVariant[];
/** Controlled selection. */
value?: VariantSelection;
/** Initial selection when uncontrolled (defaults to the first in-stock variant). */
defaultValue?: VariantSelection;
/** Called with the new selection and the variant it resolves to (if any). */
onChange?: (value: VariantSelection, variant: ProductVariant | undefined) => void;
/** ISO 4217 currency code for prices. */
currency?: string;
/** Locale used to format prices (fixed by default so server and client agree). */
locale?: string;
/** Show "+$4" style deltas on values that cost more than the cheapest value in their group. */
showPriceDelta?: boolean;
/** Show the stock hint ("Only 2 left", "Sold out") in the summary row. */
showStock?: boolean;
/** Stock at or below this number reads "Only N left". */
lowStock?: number;
/** Swatch shape. */
swatchShape?: "circle" | "square";
/** Swatch diameter in px. */
swatchSize?: number;
/** Spring for the sliding selection ring. */
spring?: Spring;
/** Selection ring, focus ring and low-stock colour. */
accent?: string;
/** When set, a hidden input submits the resolved variant id. */
name?: string;
/** Accessible name for the whole picker. */
label?: string;
}
type Availability = "available" | "soldout" | "missing";
interface ValueState {
availability: Availability;
variant: ProductVariant | undefined;
}
const DEFAULT_SPRING: Spring = { stiffness: 520, damping: 36, mass: 0.8 };
const labelOf = (option: VariantOption) => option.label ?? option.name;
const valueLabel = (option: VariantOption, value: string | undefined) =>
option.values.find((v) => v.value === value)?.label ?? value ?? "";
/**
* A product option selector. Each option is a radiogroup (arrow keys move and select, skipping
* unavailable values); availability is derived from `variants` for the current selection of the
* other options, so impossible or sold-out combinations are struck through with a reason.
*/
export function VariantPicker({
options,
variants,
value,
defaultValue,
onChange,
currency = "USD",
locale = "en-US",
showPriceDelta = true,
showStock = true,
lowStock = 3,
swatchShape = "circle",
swatchSize = 32,
spring = DEFAULT_SPRING,
accent = "#FF7448",
name,
label = "Product options",
className = "",
style,
...props
}: VariantPickerProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const [inner, setInner] = React.useState<VariantSelection>(() => {
if (defaultValue) return defaultValue;
const first = variants.find((v) => v.stock > 0) ?? variants[0];
if (first) return { ...first.options };
return Object.fromEntries(options.map((o) => [o.name, o.values[0]?.value ?? ""]));
});
const selection = value ?? inner;
const [announcement, setAnnouncement] = React.useState("");
const [preview, setPreview] = React.useState<{ option: string; value: string } | null>(null);
const money = new Intl.NumberFormat(locale, { style: "currency", currency });
const signed = new Intl.NumberFormat(locale, { style: "currency", currency, signDisplay: "exceptZero" });
const resolve = (sel: VariantSelection) =>
variants.find((v) => options.every((o) => v.options[o.name] === sel[o.name]));
const current = resolve(selection);
/** Availability of one value given the current choice of every other option. */
const stateOf = (option: VariantOption, v: string): ValueState => {
const variant = resolve({ ...selection, [option.name]: v });
if (!variant) return { availability: "missing", variant };
return { availability: variant.stock > 0 ? "available" : "soldout", variant };
};
/** "Sage" or "Sage / Linen": the other options' chosen labels, used in reason text. */
const othersLabel = (option: VariantOption) =>
options
.filter((o) => o.name !== option.name)
.map((o) => valueLabel(o, selection[o.name]))
.filter(Boolean)
.join(" / ");
const reasonFor = (option: VariantOption, state: ValueState) => {
const others = othersLabel(option);
if (state.availability === "missing") return others ? `Not made in ${others}` : "Not available";
if (state.availability === "soldout") return others ? `Sold out in ${others}` : "Sold out";
return "";
};
const stockText = (variant: ProductVariant | undefined) => {
if (!variant) return "This combination isn't made";
if (variant.stock <= 0) return "Sold out";
if (variant.stock <= lowStock) return `Only ${variant.stock} left`;
return "In stock";
};
const select = (option: VariantOption, v: string) => {
if (selection[option.name] === v) return;
const next = { ...selection, [option.name]: v };
const variant = resolve(next);
if (value === undefined) setInner(next);
onChange?.(next, variant);
const parts = [`${labelOf(option)}: ${valueLabel(option, v)}.`];
if (variant) parts.push(money.format(variant.price) + ".");
if (showStock) parts.push(stockText(variant) + ".");
setAnnouncement(parts.join(" "));
};
const ringTransition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const radius = swatchShape === "circle" ? "rounded-full" : "rounded-[6px]";
return (
<div
role="group"
aria-label={label}
className={`flex w-full flex-col gap-5 ${className}`}
style={{ ["--vp-accent" as string]: accent, ...style }}
{...props}
>
{options.map((option) => {
const groupId = `${uid}-${option.name}`;
const states = option.values.map((v) => stateOf(option, v.value));
// Deltas are relative to the cheapest value in this group (others held), so they stay put while you choose.
const prices = states.flatMap((st) => (st.variant ? [st.variant.price] : []));
const groupMin = prices.length ? Math.min(...prices) : 0;
const unavailable = option.values
.map((v, i) => ({ v, state: states[i] }))
.filter(({ state }) => state.availability !== "available");
const shown = preview?.option === option.name ? preview.value : selection[option.name];
const isPreview = preview?.option === option.name && preview.value !== selection[option.name];
return (
<div key={option.name} className="flex flex-col gap-2.5">
<div className="flex items-baseline gap-2">
<span id={`${groupId}-label`} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{labelOf(option)}:
</span>
<span
aria-hidden="true"
className={`text-[14px] font-medium tracking-tight transition-colors duration-150 ${
isPreview ? "text-zinc-400 dark:text-zinc-500" : "text-zinc-900 dark:text-zinc-50"
}`}
>
{valueLabel(option, shown) || "—"}
</span>
{/* The visible value above is a hover preview; screen readers get the real choice. */}
<span id={`${groupId}-value`} className="sr-only">
{valueLabel(option, selection[option.name])}
</span>
</div>
<OptionGroup
option={option}
groupId={groupId}
selected={selection[option.name]}
states={states}
onSelect={(v) => select(option, v)}
onUnavailable={(v, state) => setAnnouncement(`${valueLabel(option, v)}: ${reasonFor(option, state)}.`)}
onPreview={(v) => setPreview(v === null ? null : { option: option.name, value: v })}
reasonFor={(state) => reasonFor(option, state)}
deltaFor={(state) =>
showPriceDelta && state.variant && state.variant.price !== groupMin ? signed.format(state.variant.price - groupMin) : ""
}
radius={radius}
swatchSize={swatchSize}
ringTransition={ringTransition}
/>
{unavailable.length > 0 && (
<p aria-hidden="true" className="text-[12px] leading-snug text-zinc-500 dark:text-zinc-400">
{othersLabel(option) ? `In ${othersLabel(option)}: ` : ""}
{unavailable.map(({ v, state }, i) => (
<React.Fragment key={v.value}>
{i > 0 && ", "}
<span className="font-medium text-zinc-700 dark:text-zinc-300">{v.label ?? v.value}</span>{" "}
{state.availability === "missing" ? "not made" : "sold out"}
</React.Fragment>
))}
</p>
)}
</div>
);
})}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-zinc-900/10 pt-4 dark:border-white/10">
<div className="flex items-baseline gap-2">
<span className="text-[20px] font-semibold tabular-nums tracking-tight text-zinc-950 dark:text-white">
{current ? money.format(current.price) : "—"}
</span>
{current && variants.length > 0 && (
<PriceFromNote price={current.price} variants={variants} format={signed} />
)}
</div>
{showStock && (
<span className="inline-flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
<span
aria-hidden="true"
className={`size-1.5 rounded-full ${
!current || current.stock <= 0
? "bg-zinc-400 dark:bg-zinc-500"
: current.stock <= lowStock
? "bg-[var(--vp-accent)]"
: "bg-emerald-500"
}`}
/>
{stockText(current)}
</span>
)}
</div>
<span className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</span>
{name && <input type="hidden" name={name} value={current && current.stock > 0 ? current.id : ""} />}
</div>
);
}
/** "+$4.00 over base" when the chosen variant costs more than the cheapest one. */
function PriceFromNote({ price, variants, format }: { price: number; variants: ProductVariant[]; format: Intl.NumberFormat }) {
const base = Math.min(...variants.map((v) => v.price));
if (price === base) return null;
return (
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{format.format(price - base)} over base
</span>
);
}
interface OptionGroupProps {
option: VariantOption;
groupId: string;
selected: string | undefined;
states: ValueState[];
onSelect: (value: string) => void;
onUnavailable: (value: string, state: ValueState) => void;
onPreview: (value: string | null) => void;
reasonFor: (state: ValueState) => string;
deltaFor: (state: ValueState) => string;
radius: string;
swatchSize: number;
ringTransition: { duration: number } | ({ type: "spring" } & Spring);
}
/** One radiogroup with a roving tabindex; arrow keys select the next available value. */
function OptionGroup({
option,
groupId,
selected,
states,
onSelect,
onUnavailable,
onPreview,
reasonFor,
deltaFor,
radius,
swatchSize,
ringTransition,
}: OptionGroupProps) {
const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
const isSwatch = option.kind === "swatch";
const available = (i: number) => states[i]?.availability === "available";
const selectedIndex = option.values.findIndex((v) => v.value === selected);
// The tab stop is the checked radio, or the first available one when nothing valid is checked.
const tabStop = selectedIndex !== -1 ? selectedIndex : Math.max(0, states.findIndex((s) => s.availability === "available"));
const move = (from: number, step: 1 | -1) => {
const n = option.values.length;
for (let k = 1; k <= n; k++) {
const i = (from + step * k + n) % n;
if (available(i)) return i;
}
return -1;
};
const focusAndSelect = (i: number) => {
if (i < 0) return;
refs.current[i]?.focus();
onSelect(option.values[i].value);
};
const handleKeyDown = (i: number, event: React.KeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
focusAndSelect(move(i, 1));
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
focusAndSelect(move(i, -1));
break;
case "Home":
event.preventDefault();
focusAndSelect(move(-1, 1));
break;
case "End":
event.preventDefault();
focusAndSelect(move(option.values.length, -1));
break;
case " ":
case "Enter":
event.preventDefault();
if (available(i)) onSelect(option.values[i].value);
else onUnavailable(option.values[i].value, states[i]);
break;
}
};
return (
<div
role="radiogroup"
aria-labelledby={`${groupId}-label ${groupId}-value`}
className={`flex flex-wrap items-center ${isSwatch ? "gap-3 px-1 py-1" : "gap-2"}`}
onPointerLeave={() => onPreview(null)}
>
{option.values.map((v, i) => {
const state = states[i];
const checked = v.value === selected;
const ok = state.availability === "available";
const reason = reasonFor(state);
const delta = deltaFor(state);
const reasonId = `${groupId}-reason-${i}`;
const text = v.label ?? v.value;
return (
<button
key={v.value}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
aria-disabled={ok ? undefined : true}
aria-label={isSwatch ? (delta ? `${text}, ${delta}` : text) : undefined}
aria-describedby={reason ? reasonId : undefined}
tabIndex={i === tabStop ? 0 : -1}
title={isSwatch ? text : undefined}
data-state={state.availability}
onClick={() => (ok ? onSelect(v.value) : onUnavailable(v.value, state))}
onKeyDown={(e) => handleKeyDown(i, e)}
onPointerEnter={(e) => {
if (e.pointerType === "mouse") onPreview(v.value);
}}
onFocus={() => onPreview(null)}
className={
isSwatch
? `group/sw relative grid place-items-center ${radius} outline-hidden focus-visible:outline-2 focus-visible:outline-offset-[6px] focus-visible:outline-solid focus-visible:outline-[var(--vp-accent)] ${
ok ? "cursor-pointer" : "cursor-not-allowed"
}`
: `relative inline-flex h-10 min-w-12 items-center justify-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium tracking-tight outline-hidden transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--vp-accent)] motion-reduce:transition-none ${
ok
? checked
? "border-transparent text-zinc-950 dark:text-white"
: "cursor-pointer border-zinc-900/12 text-zinc-800 hover:border-zinc-900/35 dark:border-white/12 dark:text-zinc-200 dark:hover:border-white/35"
: `cursor-not-allowed border-dashed text-zinc-400 dark:text-zinc-500 ${
checked ? "border-[var(--vp-accent)]/60" : "border-zinc-900/15 dark:border-white/15"
}`
}`
}
style={isSwatch ? { width: swatchSize, height: swatchSize } : undefined}
>
{isSwatch ? (
<>
{checked && (
<motion.span
layoutId={`${groupId}-ring`}
transition={ringTransition}
aria-hidden="true"
className={`pointer-events-none absolute -inset-[5px] ${radius} border-[1.5px] ${
ok ? "border-[var(--vp-accent)]" : "border-dashed border-zinc-400 dark:border-zinc-500"
}`}
/>
)}
<span
aria-hidden="true"
className={`absolute inset-0 ${radius} shadow-[inset_0_0_0_1px_rgba(0,0,0,0.14)] transition-[scale,opacity] duration-200 dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)] motion-reduce:transition-none ${
ok ? "group-hover/sw:scale-[1.06]" : "opacity-45"
}`}
style={{ background: v.color ?? "#a1a1aa" }}
/>
{!ok && <StrikeLine />}
</>
) : (
<>
{checked && (
<motion.span
layoutId={`${groupId}-ring`}
transition={ringTransition}
aria-hidden="true"
className={`pointer-events-none absolute -inset-px rounded-[inherit] border-[1.5px] ${
ok
? "border-[var(--vp-accent)] bg-[color-mix(in_srgb,var(--vp-accent)_10%,transparent)]"
: "border-dashed border-zinc-400 dark:border-zinc-500"
}`}
/>
)}
<span className={`relative ${ok ? "" : "line-through decoration-1"}`}>{text}</span>
{delta && (
<span className="relative font-mono text-[10px] tabular-nums tracking-normal text-zinc-500 dark:text-zinc-400">
{delta}
</span>
)}
</>
)}
</button>
);
})}
{/* Reasons are referenced by aria-describedby; hidden nodes still provide descriptions. */}
{option.values.map((v, i) => {
const reason = reasonFor(states[i]);
return reason ? (
<span key={v.value} id={`${groupId}-reason-${i}`} hidden>
{reason}
</span>
) : null;
})}
</div>
);
}
/** Diagonal strike used on unavailable swatches. */
function StrikeLine() {
return (
<svg aria-hidden="true" viewBox="0 0 32 32" className="pointer-events-none absolute inset-0 size-full" preserveAspectRatio="none">
<line x1="5" y1="27" x2="27" y2="5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-zinc-700 dark:text-zinc-200" />
</svg>
);
}