Particular.Tech
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

Popular

For 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-table
npm dependencies
  • cn@^0.4.0
  • lucide-react@^1.51.0
Installs these registry items

Source

ui/pricing-table.tsx
"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 }