Skip to content

Competitive comparison

A three-way comparison against approaches rather than named competitors.

SaaSintermediatecomparisoncompetitivetablepositioning

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

import { Check, Minus, X } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Table } from '@/components/ui/table'

/**
 * Competitive comparison table
 *
 * A three-way comparison against approaches rather than named competitors —
 * which keeps the section honest and stops it ageing badly. Every cell is
 * icon + hidden text, so the answer is never conveyed by a glyph alone.
 */
interface Row {
  id: string
  criterion: string
  foundry: 'yes' | 'no' | 'partial'
  library: 'yes' | 'no' | 'partial'
  scratch: 'yes' | 'no' | 'partial'
}

const rows: Row[] = [
  {
    id: 'r1',
    criterion: 'You own and can edit the source',
    foundry: 'yes',
    library: 'no',
    scratch: 'yes',
  },
  {
    id: 'r2',
    criterion: 'Accessible patterns out of the box',
    foundry: 'yes',
    library: 'yes',
    scratch: 'no',
  },
  {
    id: 'r3',
    criterion: 'No runtime styling dependency',
    foundry: 'yes',
    library: 'partial',
    scratch: 'yes',
  },
  {
    id: 'r4',
    criterion: 'Complete page patterns included',
    foundry: 'yes',
    library: 'no',
    scratch: 'no',
  },
  {
    id: 'r5',
    criterion: 'Upgrades arrive automatically',
    foundry: 'no',
    library: 'yes',
    scratch: 'no',
  },
  { id: 'r6', criterion: 'Time to first screen', foundry: 'yes', library: 'yes', scratch: 'no' },
]

function Answer({ value }: { value: Row['foundry'] }) {
  if (value === 'yes') {
    return (
      <>
        <Check className="mx-auto size-4 text-success" aria-hidden="true" />
        <span className="sr-only">Yes</span>
      </>
    )
  }
  if (value === 'partial') {
    return (
      <>
        <Minus className="mx-auto size-4 text-warning" aria-hidden="true" />
        <span className="sr-only">Partly</span>
      </>
    )
  }
  return (
    <>
      <X className="mx-auto size-4 text-ink-subtle" aria-hidden="true" />
      <span className="sr-only">No</span>
    </>
  )
}

export default function VersusTableComparison() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Three ways to get a component set.
          </h2>
          <p className="mt-3 text-md text-ink-muted">
            Compared against approaches rather than named products, so the table stays true as the
            market changes.
          </p>
        </div>

        <div className="mt-10">
          <Table
            caption="Foundry compared with an installed component library and building from scratch"
            rows={rows}
            rowKey={(row) => row.id}
            columns={[
              {
                key: 'criterion',
                header: 'Criterion',
                width: '46%',
                cell: (row) => <span className="text-ink">{row.criterion}</span>,
              },
              {
                key: 'foundry',
                header: (
                  <span className="flex items-center justify-center gap-2">
                    Foundry{' '}
                    <Badge size="sm" tone="accent">
                      This
                    </Badge>
                  </span>
                ),
                align: 'center',
                cell: (row) => <Answer value={row.foundry} />,
              },
              {
                key: 'library',
                header: 'Installed library',
                align: 'center',
                cell: (row) => <Answer value={row.library} />,
              },
              {
                key: 'scratch',
                header: 'From scratch',
                align: 'center',
                cell: (row) => <Answer value={row.scratch} />,
              },
            ]}
          />
        </div>

        <p className="mt-4 text-xs text-ink-subtle">
          Foundry deliberately does not upgrade automatically. Owning the source means owning the
          changes.
        </p>
      </Container>
    </section>
  )
}

components/blocks/sections/comparison/versus-table.tsx

import { Check, Minus, X } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Table } from '@/components/ui/table'

/**
 * Competitive comparison table
 *
 * A three-way comparison against approaches rather than named competitors —
 * which keeps the section honest and stops it ageing badly. Every cell is
 * icon + hidden text, so the answer is never conveyed by a glyph alone.
 */
interface Row {
  id: string
  criterion: string
  foundry: 'yes' | 'no' | 'partial'
  library: 'yes' | 'no' | 'partial'
  scratch: 'yes' | 'no' | 'partial'
}

const rows: Row[] = [
  {
    id: 'r1',
    criterion: 'You own and can edit the source',
    foundry: 'yes',
    library: 'no',
    scratch: 'yes',
  },
  {
    id: 'r2',
    criterion: 'Accessible patterns out of the box',
    foundry: 'yes',
    library: 'yes',
    scratch: 'no',
  },
  {
    id: 'r3',
    criterion: 'No runtime styling dependency',
    foundry: 'yes',
    library: 'partial',
    scratch: 'yes',
  },
  {
    id: 'r4',
    criterion: 'Complete page patterns included',
    foundry: 'yes',
    library: 'no',
    scratch: 'no',
  },
  {
    id: 'r5',
    criterion: 'Upgrades arrive automatically',
    foundry: 'no',
    library: 'yes',
    scratch: 'no',
  },
  { id: 'r6', criterion: 'Time to first screen', foundry: 'yes', library: 'yes', scratch: 'no' },
]

function Answer({ value }: { value: Row['foundry'] }) {
  if (value === 'yes') {
    return (
      <>
        <Check className="mx-auto size-4 text-success" aria-hidden="true" />
        <span className="sr-only">Yes</span>
      </>
    )
  }
  if (value === 'partial') {
    return (
      <>
        <Minus className="mx-auto size-4 text-warning" aria-hidden="true" />
        <span className="sr-only">Partly</span>
      </>
    )
  }
  return (
    <>
      <X className="mx-auto size-4 text-ink-subtle" aria-hidden="true" />
      <span className="sr-only">No</span>
    </>
  )
}

export default function VersusTableComparison() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Three ways to get a component set.
          </h2>
          <p className="mt-3 text-md text-ink-muted">
            Compared against approaches rather than named products, so the table stays true as the
            market changes.
          </p>
        </div>

        <div className="mt-10">
          <Table
            caption="Foundry compared with an installed component library and building from scratch"
            rows={rows}
            rowKey={(row) => row.id}
            columns={[
              {
                key: 'criterion',
                header: 'Criterion',
                width: '46%',
                cell: (row) => <span className="text-ink">{row.criterion}</span>,
              },
              {
                key: 'foundry',
                header: (
                  <span className="flex items-center justify-center gap-2">
                    Foundry{' '}
                    <Badge size="sm" tone="accent">
                      This
                    </Badge>
                  </span>
                ),
                align: 'center',
                cell: (row) => <Answer value={row.foundry} />,
              },
              {
                key: 'library',
                header: 'Installed library',
                align: 'center',
                cell: (row) => <Answer value={row.library} />,
              },
              {
                key: 'scratch',
                header: 'From scratch',
                align: 'center',
                cell: (row) => <Answer value={row.scratch} />,
              },
            ]}
          />
        </div>

        <p className="mt-4 text-xs text-ink-subtle">
          Foundry deliberately does not upgrade automatically. Owning the source means owning the
          changes.
        </p>
      </Container>
    </section>
  )
}

components/ui/table.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * Table
 *
 * A real `<table>` with `<caption>`, scoped headers and semantic rows — the
 * only markup screen readers can navigate cell by cell.
 *
 * Two responsive strategies are supported, because neither works everywhere:
 *
 *   `scroll` keeps the grid and puts it in a labelled, keyboard-focusable
 *           scroll region (a scrollable area must be reachable by keyboard).
 *   `stack` collapses each row into a labelled card below `md`, which reads
 *           better for short, wide records.
 */
export interface Column<Row> {
  key: string
  header: ReactNode
  /** Cell renderer. Receives the row and its index. */
  cell: (row: Row, index: number) => ReactNode
  align?: 'start' | 'center' | 'end'
  /** Hides the column below `md` in scroll mode. */
  hideOnMobile?: boolean
  width?: string
}

export interface TableProps<Row> {
  caption: string
  /** Hides the caption visually while leaving it for assistive tech. */
  hideCaption?: boolean
  columns: Array<Column<Row>>
  rows: Row[]
  rowKey: (row: Row, index: number) => string
  responsive?: 'scroll' | 'stack'
  empty?: ReactNode
  className?: string
  density?: 'compact' | 'default'
}

const alignClass = { start: 'text-left', center: 'text-center', end: 'text-right' } as const

export function Table<Row>({
  caption,
  hideCaption = true,
  columns,
  rows,
  rowKey,
  responsive = 'scroll',
  empty,
  className,
  density = 'default',
}: TableProps<Row>) {
  if (rows.length === 0 && empty) {
    return <div className={className}>{empty}</div>
  }

  const cellPadding = density === 'compact' ? 'px-3 py-1.5' : 'px-4 py-2.5'

  const table = (
    <table className="w-full border-collapse text-sm">
      <caption className={cn('text-left text-xs text-ink-muted', hideCaption ? 'sr-only' : 'pb-3')}>
        {caption}
      </caption>
      <thead>
        <tr className="border-b border-line bg-surface-sunken">
          {columns.map((column) => (
            <th
              key={column.key}
              scope="col"
              style={column.width ? { width: column.width } : undefined}
              className={cn(
                'label-caps text-ink-muted',
                cellPadding,
                alignClass[column.align ?? 'start'],
                responsive === 'scroll' && column.hideOnMobile && 'hidden md:table-cell',
              )}
            >
              {column.header}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((row, index) => (
          <tr
            key={rowKey(row, index)}
            className="border-b border-line-subtle last:border-0 hover:bg-surface-sunken/60"
          >
            {columns.map((column) => (
              <td
                key={column.key}
                className={cn(
                  'text-ink',
                  cellPadding,
                  alignClass[column.align ?? 'start'],
                  responsive === 'scroll' && column.hideOnMobile && 'hidden md:table-cell',
                )}
              >
                {column.cell(row, index)}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  )

  if (responsive === 'stack') {
    return (
      <div className={className}>
        {/* Below md: one labelled card per record. */}
        <ul className="flex flex-col gap-2 md:hidden">
          {rows.map((row, index) => (
            <li key={rowKey(row, index)} className="rounded-lg border border-line bg-surface p-3">
              <dl className="flex flex-col gap-1.5">
                {columns.map((column) => (
                  <div key={column.key} className="flex items-baseline justify-between gap-3">
                    <dt className="label-caps shrink-0 text-ink-subtle">{column.header}</dt>
                    <dd className="min-w-0 text-right text-sm text-ink">
                      {column.cell(row, index)}
                    </dd>
                  </div>
                ))}
              </dl>
            </li>
          ))}
        </ul>
        <div className="hidden overflow-hidden rounded-lg border border-line md:block">{table}</div>
      </div>
    )
  }

  return (
    <div className={cn('overflow-hidden rounded-lg border border-line', className)}>
      <div
        tabIndex={0}
        role="region"
        aria-label={caption}
        className="thin-scrollbar overflow-x-auto focus-visible:outline-2 focus-visible:-outline-offset-2"
      >
        {table}
      </div>
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Comparing against approaches keeps the section honest and stops it ageing badly. Including a row where the product loses is what makes the rest credible.

  • Include at least one row where you are not the answer.
  • Three states, not two — 'partly' is usually the truthful cell.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Six criteria
  • Three approaches
  • Yes, partly and no states

Accessibility

Triple state
Yes, partly and no each have a distinct icon and a hidden word.
Scroll region
The matrix keeps its grid on narrow screens inside a focusable region.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.