Skip to content

Product comparison

Three products across only the attributes that differ.

Commercestarterproductcomparisontableattributes

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 { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Table } from '@/components/ui/table'
import { formatCurrency } from '@/lib/format'
import { products } from '@/content/demo'

/**
 * Product comparison
 *
 * Three products across the attributes that actually differ. Uses the Table
 * primitive's scroll strategy, so the comparison grid survives a 390px screen
 * inside a labelled, focusable scroll region rather than collapsing into
 * unreadable stacks.
 */
interface Row {
  id: string
  attribute: string
  values: string[]
}

const compared = products.slice(0, 3)

const rows: Row[] = [
  { id: 'price', attribute: 'Price', values: compared.map((p) => formatCurrency(p.priceCents)) },
  { id: 'material', attribute: 'Material', values: compared.map((p) => p.material) },
  { id: 'category', attribute: 'Category', values: compared.map((p) => p.category) },
  {
    id: 'rating',
    attribute: 'Rating',
    values: compared.map((p) => `${p.rating} / 5 (${p.reviews})`),
  },
  {
    id: 'stock',
    attribute: 'Availability',
    values: compared.map((p) => (p.inStock ? 'In stock' : 'Out of stock')),
  },
]

export default function ProductComparisonRow() {
  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Compare the three layers.
          </h2>
          <p className="mt-3 text-md text-ink-muted">
            Only the attributes that differ — a comparison table where every row matches teaches
            nothing.
          </p>
        </div>

        <div className="mt-10">
          <Table
            caption="Comparison of three products across price, material, category, rating and availability"
            rows={rows}
            rowKey={(row) => row.id}
            columns={[
              {
                key: 'attribute',
                header: 'Attribute',
                width: '25%',
                cell: (row) => <span className="font-medium text-ink-strong">{row.attribute}</span>,
              },
              ...compared.map((product, index) => ({
                key: product.slug,
                header: (
                  <span className="flex flex-col items-start gap-1">
                    <span className="text-ink-strong normal-case">{product.name}</span>
                    {product.badge ? <Badge size="sm">{product.badge}</Badge> : null}
                  </span>
                ),
                cell: (row: Row) => <span className="text-ink">{row.values[index]}</span>,
              })),
            ]}
          />
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/product/comparison-row.tsx

import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Table } from '@/components/ui/table'
import { formatCurrency } from '@/lib/format'
import { products } from '@/content/demo'

/**
 * Product comparison
 *
 * Three products across the attributes that actually differ. Uses the Table
 * primitive's scroll strategy, so the comparison grid survives a 390px screen
 * inside a labelled, focusable scroll region rather than collapsing into
 * unreadable stacks.
 */
interface Row {
  id: string
  attribute: string
  values: string[]
}

const compared = products.slice(0, 3)

const rows: Row[] = [
  { id: 'price', attribute: 'Price', values: compared.map((p) => formatCurrency(p.priceCents)) },
  { id: 'material', attribute: 'Material', values: compared.map((p) => p.material) },
  { id: 'category', attribute: 'Category', values: compared.map((p) => p.category) },
  {
    id: 'rating',
    attribute: 'Rating',
    values: compared.map((p) => `${p.rating} / 5 (${p.reviews})`),
  },
  {
    id: 'stock',
    attribute: 'Availability',
    values: compared.map((p) => (p.inStock ? 'In stock' : 'Out of stock')),
  },
]

export default function ProductComparisonRow() {
  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Compare the three layers.
          </h2>
          <p className="mt-3 text-md text-ink-muted">
            Only the attributes that differ — a comparison table where every row matches teaches
            nothing.
          </p>
        </div>

        <div className="mt-10">
          <Table
            caption="Comparison of three products across price, material, category, rating and availability"
            rows={rows}
            rowKey={(row) => row.id}
            columns={[
              {
                key: 'attribute',
                header: 'Attribute',
                width: '25%',
                cell: (row) => <span className="font-medium text-ink-strong">{row.attribute}</span>,
              },
              ...compared.map((product, index) => ({
                key: product.slug,
                header: (
                  <span className="flex flex-col items-start gap-1">
                    <span className="text-ink-strong normal-case">{product.name}</span>
                    {product.badge ? <Badge size="sm">{product.badge}</Badge> : null}
                  </span>
                ),
                cell: (row: Row) => <span className="text-ink">{row.values[index]}</span>,
              })),
            ]}
          />
        </div>
      </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>
  )
}

lib/format.ts

/** Small formatting helpers shared across catalogue and starter surfaces. */

export function titleCase(value: string): string {
  return value
    .split(/[-_\s]+/)
    .filter(Boolean)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ')
}

export function pluralise(count: number, singular: string, plural = `${singular}s`): string {
  return `${count} ${count === 1 ? singular : plural}`
}

const currencyFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  minimumFractionDigits: 2,
})

export function formatCurrency(cents: number): string {
  return currencyFormatter.format(cents / 100)
}

const compactFormatter = new Intl.NumberFormat('en-US', {
  notation: 'compact',
  maximumFractionDigits: 1,
})

export function formatCompact(value: number): string {
  return compactFormatter.format(value)
}

/**
 * Dates in Foundry are authored as ISO date strings so that server and client
 * renders agree byte-for-byte. Formatting is pinned to `en-US` + UTC for the
 * same reason — no hydration drift from the visitor's locale or timezone.
 */
export function formatDate(iso: string): string {
  const date = new Date(`${iso}T00:00:00Z`)
  if (Number.isNaN(date.getTime())) return iso
  return new Intl.DateTimeFormat('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    timeZone: 'UTC',
  }).format(date)
}

export function formatShortDate(iso: string): string {
  const date = new Date(`${iso}T00:00:00Z`)
  if (Number.isNaN(date.getTime())) return iso
  return new Intl.DateTimeFormat('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
    timeZone: 'UTC',
  }).format(date)
}

export function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean)
  if (parts.length === 0) return '?'
  if (parts.length === 1) return (parts[0] ?? '?').slice(0, 2).toUpperCase()
  return `${(parts[0] ?? '')[0] ?? ''}${(parts[parts.length - 1] ?? '')[0] ?? ''}`.toUpperCase()
}

export function slugify(value: string): string {
  return value
    .toLowerCase()
    .normalize('NFKD')
    .replace(/[^\w\s-]/g, '')
    .trim()
    .replace(/[\s_]+/g, '-')
    .replace(/-+/g, '-')
}

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

Usage

A comparison table where every row matches teaches nothing, so only differing attributes are shown.

  • Use the scroll strategy; a comparison that collapses into stacked cards is no longer a comparison.
  • Put the recommended product first, and say why with a badge.

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.

  • Three products
  • Five attributes
  • Scroll strategy

Accessibility

Scoped headers
Product columns use scope, so cell navigation announces the product.
Focusable scroll
The overflow container is a labelled, 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.