Fintech & Payments

Subscription Manager

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.

Install

npx shadcn@latest add @paragon/subscription-manager

Also installs: number-ticker

subscription-manager.tsx

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