Skip to content
goodtabs UI
BrowseBrowse components

Pricing compare

A feature by plan comparison in real table markup, with grouped rows.

npx shadcn@latest add https://ui.goodtabs.co/r/pricing-compare.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/pricing-compare

Compare plans
FeatureFreeProTeam
Core
Projects3UnlimitedUnlimited
History7 days1 yearForever
Private reposNot includedIncludedIncluded

One group, a tinted column for the middle plan.

Grouped rows

Everything in each plan
FeatureHobbyTeamStudio
Library
Components per day5UnlimitedUnlimited
Private collectionsNot includedIncludedIncluded
Skill packsNot includedIncludedIncluded
Collaboration
Seats1Per seatPer seat
Shared workspacesNot includedIncludedIncluded
Client workspacesNot includedNot includedIncluded
Support
Community forumIncludedIncludedIncluded
Priority repliesNot included1 day4 hours
Invoice billingNot includedNot includedIncluded

Three groups of rows with checks, dashes and short values.

Usage

import { PricingCompare } from "@/components/pricing-compare"

<PricingCompare caption="Compare plans" plans={["Free", "Pro"]} groups={[{ name: "Core", rows: [{ feature: "Projects", values: ["3", "Unlimited"] }] }]} />

Props

PropTypeDefaultDescription
plansstring[]Column names.
groupsGroup[]name plus rows of feature and one value per plan: true, false or a string.
captionstringVisible caption that also names the table.
emphasisednumberIndex of the plan column to tint.

Source

pricing-compare/pricing-compare.tsx
import * as React from "react";
import { CheckIcon, MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";

/** A cell value: true is included, false is not, a string is shown as text. */
type Cell = boolean | string;

type Group = {
  /** Group label, for example "Collaboration". */
  name: string;
  /** Rows in the group. `values` has one entry per plan, in plan order. */
  rows: { feature: string; values: Cell[] }[];
};

type PricingCompareProps = Omit<React.ComponentProps<"table">, "title"> & {
  /** Plan names, one per column. */
  plans: string[];
  /** Index of the plan to emphasise with a tinted column. */
  emphasised?: number;
  /** Feature groups, each a labelled block of rows. */
  groups: Group[];
  /** Visible caption above the table. Also names the table. */
  caption: string;
};

function CellValue({ value }: { value: Cell }) {
  if (value === true) {
    return (
      <>
        <CheckIcon className="mx-auto size-4 text-foreground" aria-hidden="true" />
        <span className="sr-only">Included</span>
      </>
    );
  }
  if (value === false) {
    return (
      <>
        <MinusIcon className="mx-auto size-4 text-muted-foreground/60" aria-hidden="true" />
        <span className="sr-only">Not included</span>
      </>
    );
  }
  return <span className="font-mono text-xs text-foreground/85">{value}</span>;
}

/**
 * A feature by plan comparison. Real table markup: a caption, column headers with scope="col",
 * one tbody per group and row headers with scope="row". Scrolls sideways on narrow screens.
 * Server component.
 */
export function PricingCompare({ plans, emphasised, groups, caption, className, ...props }: PricingCompareProps) {
  const tint = (i: number) => (i === emphasised ? "bg-muted/50" : undefined);
  return (
    <div data-slot="pricing-compare" className="relative w-full overflow-x-auto rounded-lg border bg-card">
      <table className={cn("w-full min-w-[32rem] border-collapse text-sm", className)} {...props}>
        <caption className="px-4 py-3 text-left text-sm font-medium text-foreground">{caption}</caption>
        <thead>
          <tr className="border-y">
            <th scope="col" className="w-2/5 px-4 py-2.5 text-left font-mono text-xs font-normal text-muted-foreground">
              Feature
            </th>
            {plans.map((p, i) => (
              <th
                key={i}
                scope="col"
                className={cn("px-4 py-2.5 text-center text-sm font-medium text-foreground", tint(i))}
              >
                {p}
              </th>
            ))}
          </tr>
        </thead>
        {groups.map((g, groupIndex) => (
          <tbody key={groupIndex} className="border-b last:border-b-0">
            <tr>
              <th
                scope="colgroup"
                colSpan={plans.length + 1}
                className="px-4 pt-4 pb-1.5 text-left font-mono text-xs font-normal tracking-wide text-muted-foreground uppercase"
              >
                {g.name}
              </th>
            </tr>
            {g.rows.map((r, rowIndex) => (
              <tr key={rowIndex} className="border-t border-border/60 first:border-t-0">
                <th scope="row" className="px-4 py-2.5 text-left font-normal text-foreground/85">
                  {r.feature}
                </th>
                {r.values.map((v, i) => (
                  <td key={i} className={cn("px-4 py-2.5 text-center", tint(i))}>
                    <CellValue value={v} />
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        ))}
      </table>
    </div>
  );
}

export type { Group, Cell };

For AI agents

Hand your agent this page, the markdown at /components/pricing-compare/prompt.md, or the Copy AI prompt button above.

When to use

  • Below pricing cards when buyers need the full list
  • When plans differ across many features
  • Any feature by plan matrix

When not to use

  • For three bullets per plan, cards are enough
  • For data tables with sorting, use a data table
  • For a single plan

Agent notes

  • Real table: caption, th scope col, tbody per group, th scope row; values are true, false or text
  • True and false render an icon plus sr-only text, so screen readers hear Included
  • Values array must match plans in length and order
  • Scrolls sideways under 32rem, keep plan names short