A shipping-method radio group with price, ETA, and a selection highlight that slides between options.
npx shadcn@latest add @paragon/shipping-selector"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ShippingMethod {
id: string;
label: string;
eta: string;
/** Price in the smallest label unit; 0 renders as "Free". */
price: number;
/** Optional strike-through original price (e.g. free-shipping promo). */
compareAt?: number;
note?: string;
}
export interface ShippingSelectorProps
extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
methods: ShippingMethod[];
value?: string;
defaultValue?: string;
onValueChange?: (id: string) => void;
currency?: string;
locale?: Intl.LocalesArgument;
label?: string;
/** Disables the sliding selection highlight; selection snaps. */
static?: boolean;
}
/**
* Shipping method options as an accessible radio group. The selected row is
* marked by a shared highlight that slides between options (motion layoutId)
* and a check that pops in. Roving tabindex + arrow keys. Prices are tabular
* so ETAs and amounts stay aligned. Reduced motion snaps instead of sliding.
*/
export function ShippingSelector({
methods,
value: valueProp,
defaultValue,
onValueChange,
currency = "USD",
locale = "en-US",
label = "Shipping method",
static: isStatic = false,
className,
...props
}: ShippingSelectorProps) {
const reduced = useReducedMotion();
const layoutId = React.useId();
const [uncontrolled, setUncontrolled] = React.useState(
defaultValue ?? methods[0]?.id,
);
const value = valueProp ?? uncontrolled;
const format = React.useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency }),
[locale, currency],
);
const select = (id: string) => {
if (valueProp === undefined) setUncontrolled(id);
onValueChange?.(id);
};
const move = (dir: 1 | -1) => {
const i = methods.findIndex((m) => m.id === value);
select(methods[(i + dir + methods.length) % methods.length].id);
};
return (
<div
role="radiogroup"
aria-label={label}
className={cn("flex w-full max-w-md flex-col gap-2", className)}
{...props}
>
{methods.map((m) => {
const selected = m.id === value;
return (
<button
key={m.id}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => select(m.id)}
onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowRight") {
e.preventDefault();
move(1);
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
e.preventDefault();
move(-1);
}
}}
className={cn(
"group relative flex items-center gap-3 rounded-xl bg-card px-4 py-3 text-left outline-none transition-[box-shadow] duration-150 ease-out",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
selected ? "shadow-border-hover" : "shadow-border",
)}
>
{selected && !isStatic && !reduced && (
<motion.span
layoutId={`ship-sel-${layoutId}`}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
aria-hidden
className="pointer-events-none absolute inset-0 rounded-xl shadow-[0_0_0_2px_var(--color-foreground)]"
/>
)}
{selected && (isStatic || reduced) && (
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-xl shadow-[0_0_0_2px_var(--color-foreground)]"
/>
)}
<span
aria-hidden
className={cn(
"relative flex size-5 shrink-0 items-center justify-center rounded-full transition-[background-color,box-shadow] duration-150 ease-out",
selected
? "bg-foreground text-background"
: "shadow-[inset_0_0_0_1.5px_var(--color-border)]",
)}
>
<Check
className={cn(
"size-3 transition-[scale,opacity] duration-150 ease-out",
selected ? "scale-100 opacity-100" : "scale-50 opacity-0",
)}
/>
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-card-foreground">
{m.label}
</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{m.eta}
{m.note ? ` · ${m.note}` : ""}
</span>
</span>
<span className="flex shrink-0 items-baseline gap-1.5 tabular-nums">
{m.compareAt !== undefined && m.compareAt > m.price && (
<span className="text-xs text-muted-foreground line-through">
{format.format(m.compareAt)}
</span>
)}
<span
className={cn(
"text-sm font-semibold",
m.price === 0 ? "text-success" : "text-card-foreground",
)}
>
{m.price === 0 ? "Free" : format.format(m.price)}
</span>
</span>
</button>
);
})}
</div>
);
}