Developerui
Pricing table
Plans with monthly/yearly billing and a featured plan.
Hobby
For side projects and prototypes.
$0/mo
- 1 team member
- 3 projects
- 100 GB bandwidth
- Community support
Pro
PopularFor production apps and small teams.
$25/mo
- Unlimited projects
- 1 TB bandwidth
- Preview environments
- Email support
Enterprise
For organizations with compliance needs.
Custom
- SSO and SCIM
- 99.99% SLA
- Dedicated support
- Audit log streaming
Installation
npx shadcn@latest add @particular/pricing-tablenpm dependencies
- cn@^0.4.0
- lucide-react@^1.51.0
Installs these registry items
Source
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { cn } from "cn"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
type BillingInterval = "monthly" | "yearly"
type PricingPlan = {
id: string
name: string
description: string
/** Price per month for each interval; `yearly` is the monthly equivalent when billed yearly. A string renders as-is (e.g. "Custom"). */
price: { monthly: number; yearly: number } | string
features: string[]
cta: string
featured?: boolean
badge?: string
/** Marks the plan the customer is on; its button is disabled. */
current?: boolean
}
function PricingTable({
plans,
onSelect,
currency = "USD",
interval: intervalProp,
defaultInterval = "monthly",
onIntervalChange,
yearlyNote = "Save 20%",
pendingPlanId,
className,
...props
}: Omit<React.ComponentProps<"div">, "onSelect"> & {
plans: PricingPlan[]
onSelect: (planId: string, interval: BillingInterval) => void
currency?: string
interval?: BillingInterval
defaultInterval?: BillingInterval
onIntervalChange?: (interval: BillingInterval) => void
yearlyNote?: React.ReactNode
/** Plan whose action is in flight; its button shows a pending state. */
pendingPlanId?: string
}) {
const [uncontrolled, setUncontrolled] = React.useState(defaultInterval)
const interval = intervalProp ?? uncontrolled
const money = new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: 0 })
return (
<div data-slot="pricing-table" className={cn("flex flex-col items-center gap-8", className)} {...props}>
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
value={[interval]}
onValueChange={(value) => {
const next = value[0]
if (next !== "monthly" && next !== "yearly") return
if (intervalProp === undefined) setUncontrolled(next)
onIntervalChange?.(next)
}}
aria-label="Billing interval"
>
<ToggleGroupItem value="monthly" className="px-3">
Monthly
</ToggleGroupItem>
<ToggleGroupItem value="yearly" className="gap-2 px-3">
Yearly
{yearlyNote && <span className="text-xs text-success">{yearlyNote}</span>}
</ToggleGroupItem>
</ToggleGroup>
<div className="grid w-full gap-4 md:grid-cols-[repeat(auto-fit,minmax(15rem,1fr))]">
{plans.map((plan) => {
const price = typeof plan.price === "string" ? plan.price : money.format(plan.price[interval])
return (
<section
key={plan.id}
aria-labelledby={`plan-${plan.id}`}
data-featured={plan.featured || undefined}
className="relative flex flex-col gap-6 rounded-xl border bg-card p-6 text-card-foreground data-featured:border-brand/60 data-featured:shadow-[0_0_0_1px_var(--brand),0_12px_40px_-12px_color-mix(in_oklch,var(--brand),transparent_60%)]"
>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h3 id={`plan-${plan.id}`} className="font-heading text-base font-semibold">
{plan.name}
</h3>
{plan.badge && <Badge variant={plan.featured ? "brand" : "secondary"}>{plan.badge}</Badge>}
</div>
<p className="text-sm text-muted-foreground">{plan.description}</p>
</div>
<div className="flex items-baseline gap-1">
<span className="font-heading text-4xl font-semibold tracking-tight tabular-nums">{price}</span>
{typeof plan.price !== "string" && (
<span className="text-sm text-muted-foreground">
/mo{interval === "yearly" ? ", billed yearly" : ""}
</span>
)}
</div>
<Button
variant={plan.featured ? "brand" : "outline"}
size="lg"
disabled={plan.current || pendingPlanId !== undefined}
aria-busy={pendingPlanId === plan.id || undefined}
onClick={() => onSelect(plan.id, interval)}
>
{plan.current ? "Current plan" : pendingPlanId === plan.id ? "Working…" : plan.cta}
</Button>
<ul className="flex flex-col gap-2.5 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex gap-2">
<CheckIcon aria-hidden className="mt-0.5 size-4 shrink-0 text-brand" />
<span>{feature}</span>
</li>
))}
</ul>
</section>
)
})}
</div>
</div>
)
}
export { PricingTable, type PricingPlan, type BillingInterval }