Size and color swatches with a sliding selection ring; out-of-stock options draw a strikethrough and can't be picked.
npx shadcn@latest add @paragon/variant-picker"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface VariantOption {
value: string;
label: string;
/** CSS color for a color swatch; omit for a text/size chip. */
color?: string;
/** Out of stock: rendered with a drawn strikethrough and non-selectable. */
soldOut?: boolean;
}
export interface VariantPickerProps
extends Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange" | "children"
> {
options: VariantOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Accessible group name, e.g. "Size" or "Color". */
label: string;
/** color chips render as swatches; text chips render as labelled pills. */
variant?: "color" | "text";
/** Disables the sliding selection ring; selection snaps. */
static?: boolean;
}
/**
* A single-select swatch group for product variants. Color swatches show the
* hue; text swatches show a size/label. A shared ring slides between the
* selected chip (motion layoutId), out-of-stock chips draw a diagonal
* strikethrough on mount and can't be picked. Roving-tabindex keyboard nav.
*/
export function VariantPicker({
options,
value: valueProp,
defaultValue,
onValueChange,
label,
variant = "text",
static: isStatic = false,
className,
...props
}: VariantPickerProps) {
const layoutId = React.useId();
const reduced = useReducedMotion();
const firstAvailable = options.find((o) => !o.soldOut)?.value;
const [uncontrolled, setUncontrolled] = React.useState(
defaultValue ?? firstAvailable,
);
const value = valueProp ?? uncontrolled;
const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
const selectable = options.filter((o) => !o.soldOut);
const select = (next: string) => {
if (valueProp === undefined) setUncontrolled(next);
onValueChange?.(next);
};
const move = (dir: 1 | -1, fromIndex: number) => {
if (selectable.length === 0) return;
const currentPos = selectable.findIndex(
(o) => o.value === options[fromIndex]?.value,
);
const base = currentPos === -1 ? 0 : currentPos;
const nextPos =
(base + dir + selectable.length) % selectable.length;
const nextValue = selectable[nextPos].value;
select(nextValue);
const domIndex = options.findIndex((o) => o.value === nextValue);
refs.current[domIndex]?.focus();
};
const isColor = variant === "color";
return (
<>
<style href="paragon-variant-picker" precedence="paragon">{`
@keyframes pg-variant-strike {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.pg-variant-strike { animation: none !important; transform: scaleX(1); }
}
`}</style>
<div
role="radiogroup"
aria-label={label}
className={cn("flex flex-wrap gap-2", className)}
{...props}
>
{options.map((option, i) => {
const selected = option.value === value;
const disabled = option.soldOut;
return (
<button
key={option.value}
ref={(node) => {
refs.current[i] = node;
}}
type="button"
role="radio"
aria-checked={selected}
aria-label={
option.soldOut ? `${option.label}, sold out` : option.label
}
aria-disabled={disabled || undefined}
tabIndex={selected ? 0 : -1}
onClick={() => !disabled && select(option.value)}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
move(1, i);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
move(-1, i);
}
}}
className={cn(
"relative inline-flex select-none items-center justify-center rounded-lg outline-none transition-[color,background-color,box-shadow] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
isColor ? "size-8" : "h-9 min-w-9 px-3 text-sm font-medium",
!isColor &&
!disabled &&
"shadow-border hover:shadow-border-hover",
!isColor && disabled && "text-muted-foreground/50",
!isColor && selected && "text-foreground",
disabled && "cursor-not-allowed",
)}
>
{selected && (
<motion.span
layoutId={layoutId}
aria-hidden
initial={false}
transition={
isStatic || reduced
? { duration: 0 }
: { type: "spring", duration: 0.3, bounce: 0 }
}
className={cn(
"pointer-events-none absolute rounded-lg ring-2 ring-primary ring-offset-2 ring-offset-background",
isColor ? "-inset-0" : "inset-0",
)}
/>
)}
{isColor ? (
<span
aria-hidden
className="size-full rounded-md shadow-border"
style={{ backgroundColor: option.color }}
/>
) : (
<span className="relative z-10">{option.label}</span>
)}
{disabled && (
<span
aria-hidden
className="pg-variant-strike pointer-events-none absolute left-1/2 top-1/2 h-px w-[130%] origin-center -translate-x-1/2 -translate-y-1/2 -rotate-45 bg-muted-foreground/70"
style={{
animation:
"pg-variant-strike 300ms var(--ease-out) both",
}}
/>
)}
</button>
);
})}
</div>
</>
);
}