# Comparison table (themes.dev UI block)

Your product against its alternatives, one column each with ticks; your column drawn out, the head held while the rows scroll, in a scroll box of its own. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

5.4% of the 2,073 sites in The UI Index have a table (112 sites).

## Install

- React: `npx shadcn@latest add https://www.themes.dev/ui/r/comparison-table.json`
- Astro: `npx shadcn@latest add https://www.themes.dev/ui/r/astro/comparison-table.json`
- HTML: https://www.themes.dev/ui/r/html/comparison-table.html (with https://www.themes.dev/ui/r/html/caseroom.css)

Page: https://www.themes.dev/ui/blocks/comparison-table/ · Rules for agents: https://www.themes.dev/ui/AGENT.md

## Code

### comparison-table.astro

```astro
---
import Button from '@/components/ui/button.astro';
import Icon from '@/components/ui/icon.astro';
import Link from '@/components/ui/link.astro';
import SectionHeader from '@/components/ui/section-header.astro';
import { cn } from '@/lib/utils';
import { COMPARISON_COPY, comparisonKind, comparisonTable as c, type ComparisonTableContent } from './comparison-table.classes';

type Props = ComparisonTableContent & { class?: string };
const { kicker, title, description, columns, rows, caption, note, id, class: className } = Astro.props;
---

<section aria-labelledby={`${id}-title`} class={cn(c.root, className)}>
  <div class={c.inner}>
    <SectionHeader id={`${id}-title`} kicker={kicker} title={title} description={description} />
    <div class={c.body}>
      <div class={c.region} role="region" aria-labelledby={`${id}-caption`} tabindex="0">
        <table class={c.table}>
          <caption id={`${id}-caption`} class={c.caption}>{caption ?? title}</caption>
          <thead>
            <tr>
              <td class={c.corner}></td>
              {
                columns.map((col) => (
                  <th scope="col" class={c.colHead(col.featured)}>
                    <span class={c.colStack}>
                      <span class={c.colName}>{col.name}</span>
                      {col.note && <span class={c.colNote}>{col.note}</span>}
                      {col.action && <Button href={col.action.href} class={c.colAction}>{col.action.label}</Button>}
                      {col.link && <Link href={col.link.href} variant="standalone" class={c.colLink}>{col.link.label}</Link>}
                    </span>
                  </th>
                ))
              }
            </tr>
          </thead>
          <tbody>
            {
              rows.map((row) => (
                <tr>
                  <th scope="row" class={c.rowHead}>{row.label}</th>
                  {columns.map((col, i) => {
                    const value = row.values[i] ?? 'no';
                    const kind = comparisonKind(value);
                    return (
                      <td class={c.cell(col.featured)}>
                        {kind === 'yes' ? <Icon name="check" label={COMPARISON_COPY.yes} class={c.yes} /> : kind === 'no' ? <Icon name="minus" label={COMPARISON_COPY.no} class={c.no} /> : value}
                      </td>
                    );
                  })}
                </tr>
              ))
            }
          </tbody>
        </table>
      </div>
      {note && <p class={c.note}>{note}</p>}
    </div>
  </div>
</section>
```

### comparison-table.classes.ts

```tsx
/** A comparison table: the product against its alternatives, one column each, with ticks. The product's column is
 *  drawn out on an inverted ground. It is a real <table> in a scroll box of its own (focusable, named by its caption):
 *  the head stays in view while the rows scroll, the feature names stay at the left edge on a phone, and the last
 *  row has room below it so it is never cut off. */
export type ComparisonColumn = {
  /** The product or alternative: at most 32 characters. */
  name: string;
  /** A line under the name (a price or a kind of tool), at most 40 characters. */
  note?: string;
  /** The first column is drawn out. At most one. */
  featured?: boolean;
  /** A button under the featured column's name. */
  action?: { label: string; href: string };
  /** A text link under an alternative's name. */
  link?: { label: string; href: string };
};
export type ComparisonRow = {
  /** The feature: at most 60 characters. */
  label: string;
  /** One per column, in column order: yes, no, or a short text (Manual). */
  values: string[];
};
export type ComparisonTableContent = {
  kicker?: string;
  title: string;
  description?: string;
  columns: ComparisonColumn[];
  rows: ComparisonRow[];
  /** Names the table and the scroll box. Default: the title. */
  caption?: string;
  /** Small print under the table. */
  note?: string;
  /** Unique on the page; ids the heading and the caption. */
  id: string;
};

/** A value: yes and no draw a mark with a text alternative, anything else is text. */
export const comparisonKind = (value: string) => {
  const v = value.trim().toLowerCase();
  return v === 'yes' ? 'yes' : v === 'no' ? 'no' : 'text';
};
export const COMPARISON_COPY = { yes: 'Included', no: 'Not included' };

const featured = 'inverted bg-background text-foreground';

export const comparisonTable = {
  root: '@container bg-background px-6 py-14 text-foreground',
  inner: 'mx-auto flex max-w-6xl flex-col gap-10',
  body: 'flex min-w-0 flex-col gap-4',
  region: 'relative overflow-x-auto rounded-lg border [background:linear-gradient(to_right,var(--background)_50%,transparent)_left/2rem_100%_no-repeat_local,linear-gradient(to_left,var(--background)_50%,transparent)_right/2rem_100%_no-repeat_local,radial-gradient(farthest-side_at_0_50%,color-mix(in_oklab,var(--foreground)_18%,transparent),transparent)_left/0.75rem_100%_no-repeat_scroll,radial-gradient(farthest-side_at_100%_50%,color-mix(in_oklab,var(--foreground)_18%,transparent),transparent)_right/0.75rem_100%_no-repeat_scroll,var(--background)]',
  table: 'w-full table-fixed border-separate border-spacing-0 text-start text-sm max-md:w-max max-md:table-auto md:min-w-[40rem] [&>tbody>tr:last-child>*]:pb-6 [&>tbody>tr:last-child>.inverted]:rounded-b-lg',
  caption: 'sr-only',
  // The corner is sticky both ways; the feature names are sticky at the start edge (a rule beside them on a phone).
  corner: 'sticky start-0 top-0 z-30 bg-background shadow-[inset_-1px_-1px_0_var(--border)] max-md:w-28 max-md:min-w-28 md:w-1/4 md:shadow-[inset_0_-1px_0_var(--border)]',
  colHead: (isFeatured?: boolean) =>
    `sticky top-0 z-20 px-4 pt-5 pb-4 text-start align-top font-normal shadow-[inset_0_-1px_0_var(--border)] max-md:w-24 max-md:min-w-24 max-sm:px-2 lg:px-6 ${isFeatured ? `${featured} rounded-t-lg` : 'bg-background'}`,
  colStack: 'flex flex-col items-start gap-2',
  colName: 'text-base font-semibold text-foreground',
  colNote: 'text-sm font-normal text-muted-foreground',
  colAction: 'mt-1 w-full max-sm:px-1.5 max-sm:text-[13px]',
  colLink: 'mt-1',
  rowHead: 'sticky start-0 z-10 border-t bg-background px-4 py-3 text-start align-top font-normal text-foreground shadow-[inset_-1px_0_0_var(--border)] max-sm:px-2 md:shadow-none lg:px-6',
  cell: (isFeatured?: boolean) => `border-t px-4 py-3 align-top text-muted-foreground max-sm:px-2 lg:px-6 ${isFeatured ? featured : ''}`,
  yes: 'size-5 text-foreground',
  no: 'size-5 text-muted-foreground',
  note: 'text-sm text-pretty text-muted-foreground',
};
```

### comparison-table.tsx

```tsx
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Link } from '@/components/ui/link';
import { SectionHeader } from '@/components/ui/section-header';
import { cn } from '@/lib/utils';
import { COMPARISON_COPY, comparisonKind, comparisonTable as c, type ComparisonTableContent } from './comparison-table.classes';

export type ComparisonTableProps = ComparisonTableContent & { className?: string };

/** The product against its alternatives in a table with ticks: its column drawn out, the head held while the rows scroll. */
export function ComparisonTable({ kicker, title, description, columns, rows, caption, note, id, className }: ComparisonTableProps) {
  return (
    <section aria-labelledby={`${id}-title`} className={cn(c.root, className)}>
      <div className={c.inner}>
        <SectionHeader id={`${id}-title`} kicker={kicker} title={title} description={description} />
        <div className={c.body}>
          <div className={c.region} role="region" aria-labelledby={`${id}-caption`} tabIndex={0}>
            <table className={c.table}>
              <caption id={`${id}-caption`} className={c.caption}>{caption ?? title}</caption>
              <thead>
                <tr>
                  <td className={c.corner} />
                  {columns.map((col) => (
                    <th key={col.name} scope="col" className={c.colHead(col.featured)}>
                      <span className={c.colStack}>
                        <span className={c.colName}>{col.name}</span>
                        {col.note && <span className={c.colNote}>{col.note}</span>}
                        {col.action && <Button href={col.action.href} className={c.colAction}>{col.action.label}</Button>}
                        {col.link && <Link href={col.link.href} variant="standalone" className={c.colLink}>{col.link.label}</Link>}
                      </span>
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {rows.map((row) => (
                  <tr key={row.label}>
                    <th scope="row" className={c.rowHead}>{row.label}</th>
                    {columns.map((col, i) => {
                      const value = row.values[i] ?? 'no';
                      const kind = comparisonKind(value);
                      return (
                        <td key={col.name} className={c.cell(col.featured)}>
                          {kind === 'yes' ? <Icon name="check" label={COMPARISON_COPY.yes} className={c.yes} /> : kind === 'no' ? <Icon name="minus" label={COMPARISON_COPY.no} className={c.no} /> : value}
                        </td>
                      );
                    })}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          {note && <p className={c.note}>{note}</p>}
        </div>
      </div>
    </section>
  );
}
```
