Billing Plan Selector
Billing Plan Selector is a React and Tailwind CSS form component from HextaUI, licensed MIT. Copy it and paste it into your project.
HextaUI
MIT
forms
What it is
Billing Plan Selector is a React and Tailwind CSS form component from HextaUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
No packages beyond the project baseline
Licence
This component comes from HextaUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Billing Plan Selector
"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>
);
}