Billing Plan Selector

PreviousNext

Select subscription plans with monthly/annual billing toggle.

Docs
hextauiui

Preview

Loading preview…
registry/new-york/blocks/billing/billing-plan-selector.tsx
"use client";

import { Check, Loader2 } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/new-york/ui/card";
import { Switch } from "@/registry/new-york/ui/switch";

export interface PlanFeature {
  name: string;
  included?: boolean;
  limit?: number | string;
  tooltip?: string;
}

export interface SelectablePlan {
  id: string;
  name: string;
  description?: string;
  price: {
    monthly: number;
    annual: number;
  };
  currency?: string;
  features: PlanFeature[];
  isPopular?: boolean;
  isCurrent?: boolean;
  ctaLabel?: string;
  disabled?: boolean;
}

export interface BillingPlanSelectorProps {
  plans: SelectablePlan[];
  selectedPlanId?: string;
  billingPeriod?: "monthly" | "annual";
  onBillingPeriodChange?: (period: "monthly" | "annual") => void;
  onPlanSelect?: (planId: string) => void;
  className?: string;
  showAnnualSavings?: boolean;
  currency?: string;
  layout?: "grid" | "list";
}

function formatPrice(
  amount: number,
  currency = "USD",
  period: "monthly" | "annual" = "monthly"
): string {
  const formatter = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
    minimumFractionDigits: 0,
    maximumFractionDigits: 0,
  });

  const formatted = formatter.format(amount);
  return period === "monthly" ? `${formatted}/mo` : `${formatted}/yr`;
}

export default function BillingPlanSelector({
  plans,
  selectedPlanId,
  billingPeriod = "monthly",
  onBillingPeriodChange,
  onPlanSelect,
  className,
  showAnnualSavings = true,
  currency = "USD",
  layout = "grid",
}: BillingPlanSelectorProps) {
  const [isLoading, setIsLoading] = useState<string | null>(null);

  const handlePlanSelect = async (planId: string) => {
    setIsLoading(planId);
    try {
      await onPlanSelect?.(planId);
    } finally {
      setIsLoading(null);
    }
  };

  const calculateSavings = (plan: SelectablePlan): number | null => {
    if (!showAnnualSavings || billingPeriod === "annual") return null;
    const monthlyTotal = plan.price.monthly * 12;
    const annualTotal = plan.price.annual;
    const savings = monthlyTotal - annualTotal;
    return savings > 0 ? savings : null;
  };

  const defaultCurrency = currency || plans[0]?.currency || "USD";

  return (
    <div className={cn("flex w-full flex-col gap-6", className)}>
      {plans.length > 0 && (
        <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
          <div className="flex flex-col gap-1">
            <h2 className="font-semibold text-lg">Choose your plan</h2>
            <p className="text-muted-foreground text-sm">
              Select the plan that best fits your needs
            </p>
          </div>
          <div className="flex items-center gap-3">
            <span
              className={cn(
                "text-sm",
                billingPeriod === "monthly" && "font-medium"
              )}
            >
              Monthly
            </span>
            <Switch
              checked={billingPeriod === "annual"}
              onCheckedChange={(checked) =>
                onBillingPeriodChange?.(checked ? "annual" : "monthly")
              }
            />
            <div className="flex flex-col">
              <span
                className={cn(
                  "text-sm",
                  billingPeriod === "annual" && "font-medium"
                )}
              >
                Annual
              </span>
              {showAnnualSavings && billingPeriod === "annual" && (
                <span className="text-primary text-xs">Save up to 20%</span>
              )}
            </div>
          </div>
        </div>
      )}

      <div
        className={cn(
          "grid gap-4",
          layout === "grid"
            ? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
            : "grid-cols-1"
        )}
      >
        {plans.map((plan) => {
          const savings = calculateSavings(plan);
          const price =
            billingPeriod === "monthly"
              ? plan.price.monthly
              : plan.price.annual;
          const isSelected = selectedPlanId === plan.id;

          return (
            <Card
              className={cn(
                "relative flex flex-col shadow-xs transition-all",
                plan.isCurrent && "border-primary",
                plan.isPopular && "border-primary shadow-md",
                isSelected && "ring-2 ring-primary",
                plan.disabled && "opacity-50"
              )}
              key={plan.id}
            >
              {plan.isPopular && (
                <div className="absolute -top-3 left-1/2 -translate-x-1/2">
                  <Badge className="bg-primary text-primary-foreground">
                    Popular
                  </Badge>
                </div>
              )}
              <CardHeader>
                <div className="flex flex-col gap-2">
                  <div className="flex items-center justify-between">
                    <CardTitle className="wrap-break-word">
                      {plan.name}
                    </CardTitle>
                    {plan.isCurrent && (
                      <Badge variant="secondary">Current</Badge>
                    )}
                  </div>
                  {plan.description && (
                    <CardDescription className="wrap-break-word">
                      {plan.description}
                    </CardDescription>
                  )}
                  <div className="flex items-baseline gap-1">
                    <span className="font-semibold text-3xl">
                      {
                        formatPrice(
                          price,
                          plan.currency || defaultCurrency,
                          billingPeriod
                        ).split("/")[0]
                      }
                    </span>
                    <span className="text-muted-foreground text-sm">
                      /{billingPeriod === "monthly" ? "mo" : "yr"}
                    </span>
                  </div>
                  {savings && (
                    <p className="text-primary text-sm">
                      Save{" "}
                      {
                        formatPrice(
                          savings,
                          plan.currency || defaultCurrency,
                          "annual"
                        ).split("/")[0]
                      }{" "}
                      per year
                    </p>
                  )}
                </div>
              </CardHeader>
              <CardContent>
                <div className="flex flex-1 flex-col gap-6">
                  {plan.features.length > 0 && (
                    <div className="flex flex-col gap-2">
                      {plan.features.map((feature, idx) => (
                        <div className="flex items-start gap-2" key={idx}>
                          {feature.included !== false ? (
                            <Check className="size-4 shrink-0 text-primary" />
                          ) : (
                            <div className="size-4 shrink-0" />
                          )}
                          <div className="flex min-w-0 flex-1 flex-col gap-2">
                            <span className="wrap-break-word text-sm">
                              {feature.name}
                              {feature.limit !== undefined &&
                                feature.included !== false && (
                                  <span className="text-muted-foreground">
                                    {" "}
                                    ({feature.limit})
                                  </span>
                                )}
                            </span>
                          </div>
                        </div>
                      ))}
                    </div>
                  )}
                  <Button
                    aria-busy={isLoading === plan.id}
                    className="w-full"
                    data-loading={isLoading === plan.id}
                    disabled={
                      isLoading === plan.id || plan.disabled || plan.isCurrent
                    }
                    onClick={() => handlePlanSelect(plan.id)}
                    type="button"
                    variant={
                      plan.isCurrent
                        ? "outline"
                        : isSelected
                          ? "default"
                          : "default"
                    }
                  >
                    {isLoading === plan.id ? (
                      <>
                        <Loader2 className="size-4 animate-spin" />
                        Processing…
                      </>
                    ) : plan.isCurrent ? (
                      "Current plan"
                    ) : (
                      plan.ctaLabel || "Select plan"
                    )}
                  </Button>
                </div>
              </CardContent>
            </Card>
          );
        })}
      </div>
    </div>
  );
}

Installation

npx shadcn@latest add @hextaui/billing-plan-selector

Usage

import { BillingPlanSelector } from "@/components/ui/billing-plan-selector"
<BillingPlanSelector />