A delivery-frequency picker with a sliding segment pill and a subscription price that tweens as the discount changes.
npx shadcn@latest add @paragon/subscribe-and-save"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>
);
}