A plan-management card where picking a plan and adjusting seats computes a live proration preview, with the new rate and prorated charge rolling on a ticker.
npx shadcn@latest add @paragon/subscription-managerAlso installs: number-ticker
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, Check, Minus, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
export interface SubscriptionPlan {
id: string;
name: string;
/** Price per seat per period, in dollars. */
pricePerSeat: number;
features: string[];
}
export interface SubscriptionManagerProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
plans: SubscriptionPlan[];
currentPlanId: string;
/** Seats currently active on the account. */
seats?: number;
minSeats?: number;
period?: "month" | "year";
/** Fraction of the billing period already elapsed, 0–1, for proration. */
periodElapsed?: number;
onChange?: (state: { planId: string; seats: number }) => void;
}
/**
* A plan-management surface: pick a plan, adjust the seat count with a
* stepper, and watch a live proration preview compute what you'll be charged
* today for the remainder of the current billing period. The new monthly rate
* and the prorated charge both roll on a number ticker as inputs change.
* Upgrade and cancel actions sit below. Proration = (new − old) × remaining
* fraction, shown transparently.
*/
export function SubscriptionManager({
plans,
currentPlanId,
seats: initialSeats = 5,
minSeats = 1,
period = "month",
periodElapsed = 0.4,
onChange,
className,
...props
}: SubscriptionManagerProps) {
const reduced = useReducedMotion();
const current = plans.find((p) => p.id === currentPlanId) ?? plans[0];
const [planId, setPlanId] = React.useState(current.id);
const [seats, setSeats] = React.useState(initialSeats);
const money = React.useMemo(
() => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }),
[],
);
const selected = plans.find((p) => p.id === planId) ?? current;
const currentTotal = current.pricePerSeat * initialSeats;
const newTotal = selected.pricePerSeat * seats;
const remaining = Math.max(0, 1 - periodElapsed);
const proration = Math.max(0, (newTotal - currentTotal) * remaining);
const changed = planId !== current.id || seats !== initialSeats;
const changeRef = React.useRef(onChange);
changeRef.current = onChange;
React.useEffect(() => {
changeRef.current?.({ planId, seats });
}, [planId, seats]);
return (
<div
className={cn(
"flex w-full max-w-md flex-col rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
<div className="flex items-baseline justify-between">
<h3 className="text-sm font-medium">Manage subscription</h3>
<span className="text-[13px] text-muted-foreground">
Current: {current.name}
</span>
</div>
<div
role="radiogroup"
aria-label="Plan"
className="mt-4 grid gap-2 sm:grid-cols-2"
>
{plans.map((plan) => {
const active = plan.id === planId;
return (
<button
key={plan.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setPlanId(plan.id)}
className={cn(
"pressable relative flex flex-col rounded-lg border p-3 text-left transition-[border-color,background-color] duration-150 ease-out",
active
? "border-primary bg-accent"
: "hover:bg-accent/50",
)}
>
<span className="flex items-center justify-between">
<span className="text-sm font-medium">{plan.name}</span>
{active && (
<span className="flex size-4 items-center justify-center rounded-full bg-primary">
<Check
className="size-3 text-primary-foreground"
aria-hidden
/>
</span>
)}
</span>
<span className="mt-1 text-[13px] tabular-nums text-muted-foreground">
{money.format(plan.pricePerSeat)}/seat/{period}
</span>
</button>
);
})}
</div>
<div className="mt-4 flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Seats</p>
<p className="text-[13px] text-muted-foreground">
{seats} × {money.format(selected.pricePerSeat)}
</p>
</div>
<div className="flex items-center gap-1">
<button
type="button"
aria-label="Remove a seat"
disabled={seats <= minSeats}
onClick={() => setSeats((s) => Math.max(minSeats, s - 1))}
className="pressable flex size-8 items-center justify-center rounded-md shadow-border transition-[box-shadow] duration-150 ease-out hover:shadow-border-hover disabled:pointer-events-none disabled:opacity-40"
>
<Minus className="size-4" aria-hidden />
</button>
<span className="w-8 text-center text-sm font-medium tabular-nums">
{seats}
</span>
<button
type="button"
aria-label="Add a seat"
onClick={() => setSeats((s) => s + 1)}
className="pressable flex size-8 items-center justify-center rounded-md shadow-border transition-[box-shadow] duration-150 ease-out hover:shadow-border-hover"
>
<Plus className="size-4" aria-hidden />
</button>
</div>
</div>
<div className="mt-4 flex flex-col gap-2 border-t pt-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">New recurring total</span>
<span className="font-medium">
<NumberTicker
value={newTotal}
static={!!reduced}
duration={0.35}
formatOptions={{ style: "currency", currency: "USD" }}
/>
<span className="text-muted-foreground">/{period}</span>
</span>
</div>
<AnimatePresence initial={false}>
{changed && proration > 0 && (
<motion.div
initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden"
>
<div className="flex items-center justify-between pt-2">
<span className="text-muted-foreground">
Prorated charge today
</span>
<span className="font-medium tabular-nums text-warning">
<NumberTicker
value={proration}
static={!!reduced}
duration={0.35}
formatOptions={{ style: "currency", currency: "USD" }}
/>
</span>
</div>
<p className="pt-1 text-[12px] text-muted-foreground">
{Math.round(remaining * 100)}% of the period remains.
</p>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="mt-4 flex gap-2">
<button
type="button"
disabled={!changed}
className="pressable flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary text-sm font-medium text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"
>
{changed ? "Confirm changes" : "Up to date"}
{changed && <ArrowUpRight className="size-4" aria-hidden />}
</button>
<button
type="button"
className="pressable h-9 rounded-lg px-4 text-sm font-medium text-muted-foreground transition-colors duration-150 ease-out hover:text-destructive"
>
Cancel plan
</button>
</div>
</div>
);
}