Skip to content
All examples

Pricing

A billing-period switch, three plans, a feature comparison table and frequently asked questions.

Add this example to a project that has prfct installed. The command writes the files below and installs the components they use.

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/pricing.json
components/pricing/pricing.tsx
"use client"

import * as React from "react"
import { CheckIcon, MinusIcon } from "lucide-react"

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { cn } from "@/lib/utils"

type Period = "monthly" | "yearly"
type PlanId = "hobby" | "pro" | "enterprise"

type Plan = {
  id: PlanId
  name: string
  description: string
  /** Euros per user and month when billed monthly; null is quoted by sales. */
  monthly: number | null
  cta: string
  includes: string
  features: string[]
}

const plans: Plan[] = [
  {
    id: "hobby",
    name: "Hobby",
    description: "For personal projects and experiments.",
    monthly: 0,
    cta: "Start for free",
    includes: "Includes",
    features: [
      "3 projects",
      "100 build minutes a month",
      "100 GB bandwidth",
      "A preview deployment for every push",
      "Automatic HTTPS and custom domains",
      "Community support",
    ],
  },
  {
    id: "pro",
    name: "Pro",
    description: "For teams that ship to production every week.",
    monthly: 24,
    cta: "Start a trial",
    includes: "Everything in Hobby, plus",
    features: [
      "Unlimited projects",
      "6,000 build minutes a month",
      "1 TB bandwidth",
      "4 concurrent builds",
      "Roles and shared environment variables",
      "Email support within one business day",
    ],
  },
  {
    id: "enterprise",
    name: "Enterprise",
    description: "For organizations with security and compliance needs.",
    monthly: null,
    cta: "Contact sales",
    includes: "Everything in Pro, plus",
    features: [
      "SAML single sign-on and SCIM",
      "Custom build capacity and bandwidth",
      "Deployment approvals and audit log export",
      "Data residency in the EU or US",
      "99.99% uptime SLA",
      "A dedicated support engineer",
    ],
  },
]

/** One value per plan, in the order of `plans`: a count, text, or included / not included. */
type Value = number | string | boolean

const comparison: {
  group: string
  rows: { feature: string; values: [Value, Value, Value] }[]
}[] = [
  {
    group: "Usage",
    rows: [
      { feature: "Projects", values: [3, "Unlimited", "Unlimited"] },
      { feature: "Build minutes per month", values: [100, 6000, "Custom"] },
      { feature: "Bandwidth per month", values: ["100 GB", "1 TB", "Custom"] },
      { feature: "Concurrent builds", values: [1, 4, "Custom"] },
      { feature: "Preview deployments", values: [true, true, true] },
      { feature: "Log retention", values: ["1 day", "30 days", "1 year"] },
    ],
  },
  {
    group: "Collaboration",
    rows: [
      { feature: "Team members", values: [1, "Unlimited", "Unlimited"] },
      { feature: "Comments on previews", values: [true, true, true] },
      {
        feature: "Roles and permissions",
        values: [false, true, "Custom roles"],
      },
      { feature: "Shared environment variables", values: [false, true, true] },
      { feature: "Deployment approvals", values: [false, false, true] },
    ],
  },
  {
    group: "Security",
    rows: [
      { feature: "Two-factor authentication", values: [true, true, true] },
      {
        feature: "Audit log",
        values: [false, "90 days", "1 year, with export"],
      },
      { feature: "SAML single sign-on", values: [false, false, true] },
      { feature: "SCIM provisioning", values: [false, false, true] },
      { feature: "Data residency", values: [false, false, "EU or US"] },
      { feature: "Uptime SLA", values: [false, false, "99.99%"] },
    ],
  },
  {
    group: "Support",
    rows: [
      { feature: "Community forum", values: [true, true, true] },
      { feature: "Email support", values: [false, true, true] },
      {
        feature: "First response",
        values: [false, "1 business day", "1 hour"],
      },
      { feature: "Dedicated support engineer", values: [false, false, true] },
      { feature: "Migration help", values: [false, false, true] },
    ],
  },
]

const questions = [
  {
    value: "change",
    question: "Can I change plans later?",
    answer:
      "Yes, at any time from Billing. Upgrades apply immediately and you pay the prorated difference for the rest of the period. Downgrades take effect when the period ends.",
  },
  {
    value: "users",
    question: "What counts as a user?",
    answer:
      "Anyone in your team who can deploy, change project settings or manage environment variables. People who only view and comment on preview deployments are free.",
  },
  {
    value: "usage",
    question: "What happens if we use more build minutes?",
    answer:
      "Builds keep running. Extra minutes cost €10 per 1,000 on Pro, team owners get an email at 80% and 100% of the allowance, and you can set a spending limit under Billing.",
  },
  {
    value: "vat",
    question: "Do invoices include VAT?",
    answer:
      "Prices exclude VAT. Add your company details and VAT ID under Billing and every invoice will include them; EU businesses with a valid VAT ID are invoiced under the reverse charge.",
  },
  {
    value: "cancel",
    question: "How do I cancel?",
    answer:
      "Downgrade to Hobby under Billing. Pro features stay on until the end of the period you paid for, and projects above the Hobby limits are paused, not deleted.",
  },
  {
    value: "education",
    question: "Do you offer education or open-source discounts?",
    answer:
      "Students and teachers get Pro free for a year with a school email address. Open-source projects with an OSI-approved license can apply for free Pro seats for their maintainers.",
  },
]

const euro = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "EUR",
  maximumFractionDigits: 0,
})
// Counts stay numbers so each language groups digits its own way.
const count = new Intl.NumberFormat("en-US")

// Yearly billing charges ten months for twelve, the "2 months free".
function pricing(plan: Plan, period: Period) {
  if (plan.monthly === null) {
    return {
      amount: "Custom",
      unit: null,
      terms: "Volume pricing on an annual contract",
    }
  }
  if (plan.monthly === 0) {
    return {
      amount: euro.format(0),
      unit: null,
      terms: "No credit card required",
    }
  }
  const yearly = plan.monthly * 10
  return period === "monthly"
    ? {
        amount: euro.format(plan.monthly),
        unit: "per user / month",
        terms: "Billed monthly",
      }
    : {
        amount: euro.format(yearly / 12),
        unit: "per user / month",
        terms: `${euro.format(yearly)} per user, billed yearly`,
      }
}

function PlanCard({ plan, period }: { plan: Plan; period: Period }) {
  const { amount, unit, terms } = pricing(plan, period)
  const recommended = plan.id === "pro"

  return (
    // Mid-width containers put the features beside the price. In three
    // columns the cards share four rows (subgrid), so prices, buttons and
    // feature lists line up whatever the copy length.
    <Card
      className={cn(
        "@2xl:grid @2xl:grid-cols-2",
        "@4xl:row-span-4 @4xl:grid-cols-1 @4xl:grid-rows-subgrid",
        recommended && "border-foreground"
      )}
    >
      {/* In three columns `contents` hands these rows to the subgrid. */}
      <div className="flex flex-col gap-(--card-gap) @4xl:contents">
        <CardHeader>
          <CardTitle className="flex items-center gap-2">
            <h3>{plan.name}</h3>
            {recommended && <Badge variant="outline">Recommended</Badge>}
          </CardTitle>
          <CardDescription className="text-pretty">
            {plan.description}
          </CardDescription>
        </CardHeader>
        <CardContent className="flex flex-col gap-1">
          <p className="flex flex-wrap items-baseline gap-x-1.5">
            <span className="text-display-sm numeric">{amount}</span>
            {unit && (
              <span className="text-sm text-muted-foreground">{unit}</span>
            )}
          </p>
          <p className="text-xs text-muted-foreground numeric">{terms}</p>
        </CardContent>
        <CardContent className="mt-auto">
          <Button
            variant={recommended ? "default" : "outline"}
            className="w-full"
          >
            {plan.cta}
          </Button>
        </CardContent>
      </div>
      <CardContent className="flex flex-col gap-3">
        <p className="text-sm font-medium">{plan.includes}</p>
        <ul className="flex flex-col gap-2.5">
          {plan.features.map((feature) => (
            <li key={feature} className="flex gap-2.5 text-sm">
              <CheckIcon
                className="mt-0.5 size-4 shrink-0 text-muted-foreground"
                aria-hidden="true"
              />
              {feature}
            </li>
          ))}
        </ul>
      </CardContent>
    </Card>
  )
}

function ComparisonValue({ value }: { value: Value }) {
  if (value === true) {
    return (
      <>
        <CheckIcon className="size-4" aria-hidden="true" />
        <span className="sr-only">Included</span>
      </>
    )
  }
  if (value === false) {
    return (
      <>
        <MinusIcon
          className="size-4 text-muted-foreground"
          aria-hidden="true"
        />
        <span className="sr-only">Not included</span>
      </>
    )
  }
  return typeof value === "number" ? count.format(value) : value
}

// Header cells stick below the page header: prfct.dev sets
// --page-header-height to its header's height, and elsewhere it is 0. They
// draw their own bottom line, because a row border would stay behind when
// they stick.
const stickyHead =
  "sticky top-(--page-header-height,0px) z-raised h-auto bg-background py-3 align-top after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border"

function ComparisonTable({
  period,
  shown,
  labelledBy,
}: {
  period: Period
  /** The one plan narrow containers show. */
  shown: PlanId
  labelledBy: string
}) {
  // Edge columns sit flush with the section heading.
  const planColumn = (plan: Plan) =>
    cn("last:pr-0", plan.id !== shown && "@max-2xl:hidden")

  return (
    // Table scrolls sideways by default, and a scroll container would trap
    // the sticky header. This table never overflows, so it doesn't scroll.
    <Table aria-labelledby={labelledBy} containerClassName="overflow-visible">
      <TableHeader className="[&_tr]:border-b-0">
        <TableRow>
          <TableHead scope="col" className={cn(stickyHead, "first:pl-0")}>
            <span className="sr-only">Feature</span>
          </TableHead>
          {plans.map((plan) => {
            const { amount, unit } = pricing(plan, period)
            return (
              <TableHead
                key={plan.id}
                scope="col"
                className={cn(
                  stickyHead,
                  "w-2/5 whitespace-normal @2xl:w-1/5",
                  planColumn(plan)
                )}
              >
                <span className="block text-sm font-medium text-foreground">
                  {plan.name}
                </span>
                <span className="block font-normal numeric">
                  {amount} {unit}
                </span>
              </TableHead>
            )
          })}
        </TableRow>
      </TableHeader>
      {comparison.map(({ group, rows }) => (
        <TableBody key={group}>
          <TableRow className="border-b-0 hover:bg-transparent">
            <TableHead
              scope="rowgroup"
              className="h-auto pt-8 pb-2 text-sm text-foreground first:pl-0"
            >
              {group}
            </TableHead>
            {plans.map((plan) => (
              <TableCell
                key={plan.id}
                className={cn("h-auto", planColumn(plan))}
              />
            ))}
          </TableRow>
          {rows.map(({ feature, values }) => (
            <TableRow key={feature} className="hover:bg-transparent">
              <TableHead
                scope="row"
                className="h-auto py-3 text-sm font-normal whitespace-normal text-foreground first:pl-0"
              >
                {feature}
              </TableHead>
              {plans.map((plan, index) => (
                <TableCell
                  key={plan.id}
                  className={cn(
                    "h-auto py-3 whitespace-normal",
                    planColumn(plan)
                  )}
                >
                  <ComparisonValue value={values[index]} />
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      ))}
    </Table>
  )
}

export default function PricingExample() {
  const [period, setPeriod] = React.useState<Period>("monthly")
  // Narrow containers compare one plan at a time instead of scrolling sideways.
  const [shown, setShown] = React.useState<PlanId>("pro")
  const id = React.useId()

  return (
    <div className="@container">
      <div className="overflow-clip rounded-xl border bg-background">
        <div className="mx-auto flex max-w-6xl flex-col gap-16 px-5 py-10 @3xl:gap-24 @3xl:px-10 @3xl:py-14">
          <div className="flex flex-col gap-10">
            <div className="flex flex-col gap-6 @3xl:flex-row @3xl:items-end @3xl:justify-between">
              <div className="flex max-w-xl flex-col gap-3">
                <h2 className="text-heading-xl text-balance @3xl:text-display-sm">
                  Plans and pricing
                </h2>
                <p className="text-body-md text-pretty text-muted-foreground">
                  Deploy personal projects on Northwind for free, and pay per
                  member once your team ships to production.
                </p>
              </div>
              <div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2">
                <ToggleGroup
                  variant="outline"
                  spacing={0}
                  value={[period]}
                  // Pressing the active period again would leave none chosen.
                  onValueChange={([next]) => next && setPeriod(next as Period)}
                  aria-label="Billing period"
                >
                  <ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
                  <ToggleGroupItem
                    value="yearly"
                    aria-describedby={`${id}-saving`}
                  >
                    Yearly
                  </ToggleGroupItem>
                </ToggleGroup>
                <span
                  id={`${id}-saving`}
                  className="text-sm text-muted-foreground"
                >
                  2 months free
                </span>
              </div>
            </div>

            <div className="flex flex-col gap-4">
              <div className="grid gap-4 @4xl:grid-cols-3">
                {plans.map((plan) => (
                  <PlanCard key={plan.id} plan={plan} period={period} />
                ))}
              </div>
              <p className="text-xs text-muted-foreground">
                Prices in euros, excluding VAT.
              </p>
            </div>
          </div>

          <section
            aria-labelledby={`${id}-compare`}
            className="flex flex-col gap-6"
          >
            <div className="flex flex-col gap-4">
              <h2 id={`${id}-compare`} className="text-heading-lg">
                Compare plans
              </h2>
              <ToggleGroup
                variant="outline"
                spacing={0}
                value={[shown]}
                onValueChange={([next]) => next && setShown(next as PlanId)}
                aria-label="Plan to compare"
                className="w-full @2xl:hidden"
              >
                {plans.map((plan) => (
                  <ToggleGroupItem
                    key={plan.id}
                    value={plan.id}
                    className="flex-1"
                  >
                    {plan.name}
                  </ToggleGroupItem>
                ))}
              </ToggleGroup>
            </div>

            <ComparisonTable
              period={period}
              shown={shown}
              labelledBy={`${id}-compare`}
            />
          </section>

          <section
            aria-labelledby={`${id}-faq`}
            className="grid gap-6 @4xl:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] @4xl:gap-16"
          >
            <div className="flex flex-col gap-2">
              <h2 id={`${id}-faq`} className="text-heading-lg">
                Frequently asked questions
              </h2>
              <p className="text-sm text-pretty text-muted-foreground">
                Billing, seats and invoices. Anything else goes to our support
                team, who reply within one business day.
              </p>
            </div>
            <Accordion>
              {questions.map((item) => (
                <AccordionItem key={item.value} value={item.value}>
                  <AccordionTrigger>{item.question}</AccordionTrigger>
                  <AccordionContent>{item.answer}</AccordionContent>
                </AccordionItem>
              ))}
            </Accordion>
          </section>
        </div>

        <div className="border-t bg-surface">
          <div className="mx-auto flex max-w-6xl flex-col items-start gap-4 px-5 py-8 @3xl:flex-row @3xl:items-center @3xl:justify-between @3xl:px-10">
            <p className="text-sm text-pretty">
              For custom contracts, security reviews or invoicing by purchase
              order, talk to our team.
            </p>
            <Button variant="outline">Contact sales</Button>
          </div>
        </div>
      </div>
    </div>
  )
}
app/pricing/page.tsx
import type { Metadata } from "next"

import Pricing from "@/components/pricing/pricing"

export const metadata: Metadata = {
  title: "Pricing",
  description: "Three plans with a monthly and yearly switch, a feature comparison and answers to common questions.",
}

export default function PricingPage() {
  return (
    <main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
      <Pricing />
    </main>
  )
}