E-commerce

Subscribe and Save

A delivery-frequency picker with a sliding segment pill and a subscription price that tweens as the discount changes.

Install

npx shadcn@latest add @paragon/subscribe-and-save

subscribe-and-save.tsx

"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { Repeat, Check } from "lucide-react";
import { cn } from "@/lib/utils";

export interface SubscribeFrequency {
  id: string;
  label: string;
  /** Discount fraction, 0–1 (e.g. 0.15 for 15% off). */
  discount: number;
}

export interface SubscribeAndSaveProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  /** One-time (full) price. */
  price: number;
  frequencies: SubscribeFrequency[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (id: string) => void;
  /** null id = "one-time purchase" (no discount). */
  allowOneTime?: boolean;
  currency?: string;
  locale?: Intl.LocalesArgument;
  /** Disables the sliding highlight; selection snaps. */
  static?: boolean;
}

const ONE_TIME = "__one_time__";

/**
 * A subscribe-and-save picker. A segmented control of delivery frequencies
 * (plus an optional one-time option) drives a subscription price that tweens
 * as the discount changes; the shared selection pill slides between segments.
 * Prices tabular. Reduced motion snaps the pill and the price.
 */
export function SubscribeAndSave({
  price,
  frequencies,
  value: valueProp,
  defaultValue,
  onValueChange,
  allowOneTime = true,
  currency = "USD",
  locale = "en-US",
  static: isStatic = false,
  className,
  ...props
}: SubscribeAndSaveProps) {
  const reduced = useReducedMotion();
  const layoutId = React.useId();
  const [uncontrolled, setUncontrolled] = React.useState(
    defaultValue ?? frequencies[0]?.id,
  );
  const value = valueProp ?? uncontrolled;

  const format = React.useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency }),
    [locale, currency],
  );

  const options = React.useMemo(
    () =>
      allowOneTime
        ? [{ id: ONE_TIME, label: "One-time", discount: 0 }, ...frequencies]
        : frequencies,
    [allowOneTime, frequencies],
  );

  const select = (id: string) => {
    if (valueProp === undefined) setUncontrolled(id);
    onValueChange?.(id);
  };

  const current = options.find((o) => o.id === value) ?? options[0];
  const subPrice = price * (1 - current.discount);
  const saved = price - subPrice;

  const move = (dir: 1 | -1) => {
    const i = options.findIndex((o) => o.id === value);
    select(options[(i + dir + options.length) % options.length].id);
  };

  return (
    <div
      className={cn(
        "w-full max-w-sm rounded-xl bg-card p-4 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="mb-3 flex items-center gap-2">
        <Repeat className="size-4 text-muted-foreground" aria-hidden />
        <h3 className="text-sm font-medium text-card-foreground">
          Delivery frequency
        </h3>
      </div>

      <div
        role="radiogroup"
        aria-label="Delivery frequency"
        className="flex gap-1 rounded-lg bg-secondary p-1"
      >
        {options.map((opt) => {
          const selected = opt.id === value;
          return (
            <button
              key={opt.id}
              type="button"
              role="radio"
              aria-checked={selected}
              tabIndex={selected ? 0 : -1}
              onClick={() => select(opt.id)}
              onKeyDown={(e) => {
                if (e.key === "ArrowRight" || e.key === "ArrowDown") {
                  e.preventDefault();
                  move(1);
                } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
                  e.preventDefault();
                  move(-1);
                }
              }}
              className={cn(
                "relative flex-1 rounded-md px-2 py-1.5 text-xs font-medium outline-none transition-colors duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring",
                selected
                  ? "text-foreground"
                  : "text-muted-foreground hover:text-foreground",
              )}
            >
              {selected && (
                <motion.span
                  layoutId={
                    isStatic || reduced ? undefined : `sas-pill-${layoutId}`
                  }
                  transition={{ type: "spring", duration: 0.3, bounce: 0 }}
                  aria-hidden
                  className="absolute inset-0 rounded-md bg-card shadow-border"
                />
              )}
              <span className="relative">{opt.label}</span>
            </button>
          );
        })}
      </div>

      <div className="mt-4 flex items-baseline justify-between">
        <span className="text-sm text-muted-foreground">Your price</span>
        <span className="flex items-baseline gap-2">
          {current.discount > 0 && (
            <span className="text-sm text-muted-foreground line-through tabular-nums">
              {format.format(price)}
            </span>
          )}
          <PriceTween value={subPrice} format={format} reduced={!!reduced} />
        </span>
      </div>

      {current.discount > 0 && (
        <p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-success">
          <Check className="size-3.5" aria-hidden />
          Save {format.format(saved)} ({Math.round(current.discount * 100)}%)
          every delivery
        </p>
      )}
    </div>
  );
}

function PriceTween({
  value,
  format,
  reduced,
}: {
  value: number;
  format: Intl.NumberFormat;
  reduced: boolean;
}) {
  const [display, setDisplay] = React.useState(value);
  const from = React.useRef(value);
  React.useEffect(() => {
    if (reduced) {
      setDisplay(value);
      from.current = value;
      return;
    }
    const start = performance.now();
    const a = from.current;
    const b = value;
    let raf = 0;
    const tick = (t: number) => {
      const k = Math.min(1, (t - start) / 300);
      const eased = 1 - Math.pow(1 - k, 3);
      setDisplay(a + (b - a) * eased);
      if (k < 1) raf = requestAnimationFrame(tick);
      else from.current = b;
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [value, reduced]);

  return (
    <span className="text-lg font-semibold text-card-foreground tabular-nums">
      {format.format(display)}
    </span>
  );
}