# Two-Plan Table With Billing Toggle

> Plan comparison table pitting Starter against Pro across grouped feature rows, with a monthly and annual toggle that changes the price and cell values.

A shadcn/ui block from the [@7ovr](https://7ovr.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/7ovr/comparison-1).

- Page, with a live preview: https://shadcn.exchange/r/7ovr/comparison-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/7ovr/comparison-1-light-91068bf1cedbc4a9.png
- Registry item: https://7ovr.com/r/comparison-1.json
- Categories: comparison
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://7ovr.com/r/comparison-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@base-ui/react`
- Registry items: `badge`, `button`, `table`

## Notes from the registry

Requires a `base-*` style in components.json (the `shadcn init` default). Legacy `new-york`/`radix-*` styles install Radix primitives, which reject the `render` prop this block uses.

## Files

### `registry/blocks/comparison/1/comparison-block.tsx`

```tsx
"use client"

import * as React from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { IconPlaceholder } from "@/components/icons/icon-placeholder"

type Billing = "monthly" | "annual"

type Cell = boolean | string | { monthly: string; annual: string }

const resolveCell = (value: Cell, billing: Billing): boolean | string =>
  typeof value === "object" ? value[billing] : value

const FEATURES: {
  category: string
  rows: { label: string; starter: Cell; pro: Cell }[]
}[] = [
  {
    category: "Core",
    rows: [
      { label: "Projects", starter: "Up to 3", pro: "Unlimited" },
      {
        label: "Storage",
        starter: "1 GB",
        pro: { monthly: "100 GB", annual: "200 GB" },
      },
      { label: "API access", starter: true, pro: true },
      { label: "Custom domains", starter: false, pro: true },
    ],
  },
  {
    category: "Collaboration",
    rows: [
      { label: "Team members", starter: "1", pro: "Unlimited" },
      { label: "Role-based permissions", starter: false, pro: true },
      { label: "Shared workspaces", starter: false, pro: true },
      { label: "Activity log", starter: false, pro: true },
    ],
  },
  {
    category: "Security & Compliance",
    rows: [
      { label: "Two-factor authentication", starter: true, pro: true },
      { label: "SSO / SAML", starter: false, pro: true },
      { label: "Audit logs", starter: false, pro: true },
      { label: "Data residency controls", starter: false, pro: true },
    ],
  },
  {
    category: "Support",
    rows: [
      { label: "Community forum", starter: true, pro: true },
      { label: "Email support", starter: false, pro: true },
      {
        label: "Priority response SLA",
        starter: false,
        pro: { monthly: "< 4 h", annual: "< 2 h" },
      },
      { label: "Dedicated account manager", starter: false, pro: true },
    ],
  },
]

function FeatureValue({
  value,
  highlight,
}: {
  value: boolean | string
  highlight?: boolean
}) {
  if (typeof value === "string") {
    return (
      <span
        className={cn(
          "text-sm font-medium",
          highlight ? "text-foreground" : "text-muted-foreground"
        )}
      >
        {value}
      </span>
    )
  }
  if (value) {
    return (
      <IconPlaceholder
        lucide="Check"
        tabler="IconCheck"
        hugeicons="Tick02Icon"
        phosphor="Check"
        remixicon="RiCheckLine"
        className={cn(
          "mx-auto size-4",
          highlight ? "text-primary" : "text-muted-foreground"
        )}
        aria-label="Included"
      />
    )
  }
  return (
    <IconPlaceholder
      lucide="X"
      tabler="IconX"
      hugeicons="Cancel01Icon"
      phosphor="X"
      remixicon="RiCloseLine"
      className="mx-auto size-4 text-muted-foreground/40"
      aria-label="Not included"
    />
  )
}

export default function ComparisonBlock() {
  const [billing, setBilling] = React.useState<Billing>("monthly")
  const proPrice = billing === "annual" ? 23 : 29

  return (
    <section className="flex w-full items-center justify-center bg-background px-4 py-16 text-foreground sm:px-6">
      <div className="mx-auto w-full max-w-3xl">
        <div className="mx-auto max-w-xl text-center">
          <span className="text-sm font-medium tracking-widest text-muted-foreground uppercase">
            Plans
          </span>
          <h2 className="mt-3 font-heading text-3xl font-bold tracking-tight sm:text-4xl">
            Compare plans
          </h2>
          <p className="mt-3 text-muted-foreground">
            Every feature, side by side, so you can pick the right plan with
            confidence.
          </p>
        </div>

        <div className="mt-8 flex justify-center">
          <div
            role="group"
            aria-label="Billing period"
            className="inline-flex items-center gap-1 rounded-lg border border-border bg-muted/50 p-1"
          >
            <button
              type="button"
              onClick={() => setBilling("monthly")}
              aria-pressed={billing === "monthly"}
              className={cn(
                "rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
                billing === "monthly"
                  ? "bg-background text-foreground shadow-sm"
                  : "text-muted-foreground hover:text-foreground"
              )}
            >
              Monthly
            </button>
            <button
              type="button"
              onClick={() => setBilling("annual")}
              aria-pressed={billing === "annual"}
              className={cn(
                "flex items-center gap-2 rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
                billing === "annual"
                  ? "bg-background text-foreground shadow-sm"
                  : "text-muted-foreground hover:text-foreground"
              )}
            >
              Annual
              <Badge className="px-1.5 text-[10px] font-semibold">-20%</Badge>
            </button>
          </div>
        </div>

        <div className="relative mt-10 rounded-lg border border-border bg-card">
          <Badge className="absolute bottom-full left-[87.5%] z-20 mb-2 -translate-x-1/2 px-2 text-[10px] font-semibold">
            Popular
          </Badge>
          <Table>
            <TableHeader>
              <TableRow className="hover:bg-transparent">
                <TableHead className="w-1/2 py-4 text-sm font-semibold text-foreground">
                  Feature
                </TableHead>
                <TableHead className="w-1/4 py-4 text-center text-sm font-semibold text-foreground">
                  <div className="flex flex-col items-center gap-0.5">
                    <span>Starter</span>
                    <span className="text-xs font-normal text-muted-foreground">
                      Free
                    </span>
                  </div>
                </TableHead>
                <TableHead className="w-1/4 rounded-tr-lg bg-primary/10 py-4 text-center text-sm font-semibold text-foreground">
                  <div className="flex flex-col items-center gap-0.5">
                    <span>Pro</span>
                    <span className="text-xs font-normal text-muted-foreground">
                      ${proPrice} / Month
                    </span>
                  </div>
                </TableHead>
              </TableRow>
            </TableHeader>

            <TableBody>
              {FEATURES.map(({ category, rows }) => (
                <React.Fragment key={category}>
                  <TableRow className="bg-muted/50 hover:bg-muted/50">
                    <TableCell
                      colSpan={3}
                      className="py-2 text-xs font-semibold tracking-wider text-muted-foreground uppercase"
                    >
                      {category}
                    </TableCell>
                  </TableRow>

                  {rows.map((row) => (
                    <TableRow key={row.label}>
                      <TableCell className="text-sm text-foreground">
                        {row.label}
                      </TableCell>
                      <TableCell className="text-center">
                        <FeatureValue
                          value={resolveCell(row.starter, billing)}
                        />
                      </TableCell>
                      <TableCell className="bg-primary/5 text-center">
                        <FeatureValue
                          value={resolveCell(row.pro, billing)}
                          highlight
                        />
                      </TableCell>
                    </TableRow>
                  ))}
                </React.Fragment>
              ))}

              <TableRow className="hover:bg-transparent">
                <TableCell />
                <TableCell className="py-5 text-center">
                  <Button
                    nativeButton={false}
                    variant="outline"
                    size="sm"
                    className="w-full max-w-[120px]"
                    render={<a href="#" />}
                  >
                    Get Started
                  </Button>
                </TableCell>
                <TableCell className="rounded-br-lg bg-primary/5 py-5 text-center">
                  <Button
                    nativeButton={false}
                    size="sm"
                    className="w-full max-w-[120px]"
                    render={<a href="#" />}
                  >
                    Start Free Trial
                  </Button>
                </TableCell>
              </TableRow>
            </TableBody>
          </Table>
        </div>
      </div>
    </section>
  )
}
```
