Skip to contentVibraUI
Data display

Comparison table

A feature matrix: rows against plans, one column recommended, the labels pinned while the rest scrolls.

A real table, so a reader moves through it a cell at a time and hears which feature and which plan each answer belongs to: the plans are column headers, the features are row headers, and a group is a <tbody> named by its own heading row. Three rules keep the answers legible. Every check and dash carries the word for what it means, because an icon read out on its own says "image" and nothing else — true is "Included", false is "Not included". A value of null, and a column a row never mentions, both read as "Not applicable", which is not the same answer as no and is not printed as one: a feature that does not exist on a plan and a feature that plan refuses are different facts. And the highlighted column is tinted with bg-brand-muted *and* set in the brand ink with a data-highlighted attribute on every cell in it, so it is never colour alone. The label column is pinned with the card's own plane behind it and the plans scroll past it inside the table's container, so a phone scrolls the comparison rather than the page — which is why the item draws its own panel: a sticky cell needs a surface to be opaque against. A group naming a row that is not in rows is skipped rather than leaving a hole, and a row no group claims still renders, ahead of the groups, so adding a heading later cannot silently drop a feature.

Install

npx shadcn@latest add @vibra/comparison-table

Needs the @vibra registry in your components.json — set it up once.

Examples

Grouped, with a recommended plan

Rows sectioned under headings, and the Team column held up as the one to take.

Props

PropTypeDefaultDescription
columns{ id: string; label: string; caption?: React.ReactNode; highlighted?: boolean }[]—The plans, in order. caption is the second line in the head — the price, the audience. highlighted tints the column and sets its label in the brand.
rows{ id: string; label: string; help?: string; values: Record<string, boolean | string | number | null> }[]—The features. help is a second line under the label; values is keyed by column id, and a key that is missing or null reads as not applicable.
groups{ label: string; rows: string[] }[]—Sections the rows into headed groups by id. Rows no group names render first, ungrouped.
classNamestring—Merged onto the <table>; the rest of the table props are spread onto it too.

Dependencies

Source

components/ui/comparison-table.tsx
import * as React from "react"
import { CheckIcon, MinusIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

export type ComparisonColumn = {
  id: string
  label: string
  /** A second line under the label — the price, the audience, the caveat. */
  caption?: React.ReactNode
  /** Tints the whole column and marks it as the one being recommended. */
  highlighted?: boolean
}

/**
 * One feature across the columns. A value may be `true`/`false` (a check or a
 * dash), a written amount, or `null` for a feature that does not apply to that
 * column at all — which is not the same answer as "no", and is not printed as
 * one.
 */
export type ComparisonRow = {
  id: string
  label: string
  /** A second line under the label, for what the feature actually means. */
  help?: string
  values: Record<string, boolean | string | number | null>
}

export type ComparisonGroup = {
  label: string
  /** Row ids, in the order they should appear under the heading. */
  rows: string[]
}

// The words behind the marks. A comparison read out cell by cell is a run of
// icons otherwise, and "check" is not an answer to "does this plan have SSO".
const VALUE_LABELS = {
  true: "Included",
  false: "Not included",
  null: "Not applicable",
} as const

/** The pinned label column keeps the card's own plane, so rows scroll under it rather than through it. */
const STICKY_LABEL = "sticky start-0 z-10 bg-card"

function ComparisonValue({ value }: { value: boolean | string | number | null }) {
  if (value === true || value === false) {
    const Icon = value ? CheckIcon : MinusIcon
    return (
      <span data-slot="comparison-table-mark" data-value={String(value)}>
        <Icon
          aria-hidden="true"
          className={cn("mx-auto size-4", value ? "text-success" : "text-muted-foreground/60")}
        />
        <span className="sr-only">{value ? VALUE_LABELS.true : VALUE_LABELS.false}</span>
      </span>
    )
  }

  // The only call site normalises a missing key with `?? null`, so `value` is
  // never actually `undefined` here — the type does not even allow it.
  if (value === null) {
    return (
      <span data-slot="comparison-table-empty" className="text-muted-foreground/60">
        <span aria-hidden="true">&mdash;</span>
        <span className="sr-only">{VALUE_LABELS.null}</span>
      </span>
    )
  }

  return <span data-slot="comparison-table-value">{value}</span>
}

export type ComparisonTableProps = React.ComponentProps<"table"> & {
  columns: ComparisonColumn[]
  rows: ComparisonRow[]
  /**
   * Sections the rows into headed groups. A row no group names still renders,
   * ahead of the groups, so a heading added later never silently drops a
   * feature off the page.
   */
  groups?: ComparisonGroup[]
}

/**
 * A feature matrix: rows of features against columns of plans, with one column
 * held up as the recommended one.
 *
 * A real table, so a reader can move through it a cell at a time and hear which
 * feature and which plan each answer belongs to. Three rules make the answers
 * legible: every check and dash carries the word for what it means, a feature
 * that does not apply reads as "not applicable" rather than as a no, and the
 * highlighted column is tinted *and* marked, never colour alone.
 *
 * The label column is pinned and the plans scroll past it inside the table's own
 * container, so a phone scrolls the comparison rather than the page.
 */
function ComparisonTable({
  className,
  columns,
  rows,
  groups,
  ...props
}: ComparisonTableProps) {
  const groupId = React.useId()
  const byId = new Map(rows.map((row) => [row.id, row]))

  // Everything a group claims, so what is left over can be found in one pass and
  // kept in the order it was given.
  const claimed = new Set(groups?.flatMap((group) => group.rows) ?? [])
  const ungrouped = rows.filter((row) => !claimed.has(row.id))

  const sections = (groups ?? []).map((group, index) => ({
    key: `${groupId}-${index}`,
    label: group.label,
    // A group may name a row that is not in `rows` — a plan matrix built from
    // two sources will — and a heading with a hole under it is worse than a
    // heading with one row.
    rows: group.rows.map((id) => byId.get(id)).filter((row) => row !== undefined),
  }))

  const body = (row: ComparisonRow) => (
    <TableRow key={row.id} data-slot="comparison-table-row">
      <TableHead
        scope="row"
        data-pinned="true"
        className={cn(STICKY_LABEL, "h-auto min-w-40 py-2.5 text-foreground normal-case")}
      >
        <span className="block font-medium">{row.label}</span>
        {row.help ? (
          <span className="block max-w-56 text-xs font-normal text-wrap text-muted-foreground">
            {row.help}
          </span>
        ) : null}
      </TableHead>

      {columns.map((column) => (
        <TableCell
          key={column.id}
          data-highlighted={column.highlighted ? "true" : undefined}
          className={cn(
            "min-w-28 py-2.5 text-center tabular-nums",
            column.highlighted && "bg-brand-muted"
          )}
        >
          <ComparisonValue value={row.values[column.id] ?? null} />
        </TableCell>
      ))}
    </TableRow>
  )

  return (
    <div data-slot="comparison-table" className="overflow-hidden panel">
      {/* The rounded corners above clip; the Table primitive's own container
          (data-slot="table-container") is what scrolls, with the pinned
          label column sticky inside it. */}
      <Table className={cn("text-sm", className)} {...props}>
        <TableHeader>
          <TableRow className="hover:bg-transparent">
            <TableHead data-pinned="true" className={cn(STICKY_LABEL, "min-w-40")}>
              <span className="sr-only">Feature</span>
            </TableHead>

            {columns.map((column) => (
              <TableHead
                key={column.id}
                scope="col"
                data-highlighted={column.highlighted ? "true" : undefined}
                className={cn(
                  "h-auto min-w-28 py-3 text-center normal-case",
                  column.highlighted && "bg-brand-muted"
                )}
              >
                <span
                  className={cn(
                    "block text-sm font-medium",
                    column.highlighted ? "text-brand" : "text-foreground"
                  )}
                >
                  {column.label}
                </span>
                {column.caption ? (
                  <span className="block text-xs font-normal text-muted-foreground">
                    {column.caption}
                  </span>
                ) : null}
              </TableHead>
            ))}
          </TableRow>
        </TableHeader>

        {ungrouped.length > 0 ? <TableBody>{ungrouped.map(body)}</TableBody> : null}

        {sections.map((section) => (
          <TableBody key={section.key} aria-labelledby={section.key}>
            <TableRow className="hover:bg-transparent">
              <TableHead
                id={section.key}
                // It heads the rows below it in this tbody, not the columns
                // across it — the same direction "row" is, just for a group.
                scope="rowgroup"
                colSpan={columns.length + 1}
                className="h-auto bg-secondary py-1.5 text-foreground"
              >
                {section.label}
              </TableHead>
            </TableRow>
            {section.rows.map(body)}
          </TableBody>
        ))}
      </Table>
    </div>
  )
}

export { ComparisonTable }