E-commerce

Shipping Selector

A shipping-method radio group with price, ETA, and a selection highlight that slides between options.

Install

npx shadcn@latest add @paragon/shipping-selector

shipping-selector.tsx

"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>
  );
}