Skip to content
    Style: Wireframe

    Blocks · Marketing

    Comparison table

    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.

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

    Compare

    Northwind against the alternatives

    What you get with each, feature by feature.

    Northwind compared with Ledgerly, Sheetwork and Paperbook, feature by feature
    NorthwindFrom $12 a monthStart freeLedgerlyAccounting suiteMoving from LedgerlySheetworkSpreadsheetsMoving from SheetworkPaperbookBy handMoving from paper
    Invoices in your language
    Bank feeds
    Tax return exportAdd-onManualManual
    Receipts by photo
    Unlimited clients
    Works offline
    Shared with your accountantManual
    Recurring invoices
    Multi-currencyAdd-onManual
    Audit trail
    Roles and permissions
    Export everything, any time

    Based on each product's public plans, October 2026.

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    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

    Or copy the files below into your project. They need clsx, tailwind-merge and the components they import, and the themes.dev UI theme. The HTML needs only the compiled CSS.

    comparison-table.classes.ts
    /** 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
    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>
      );
    }