Comparison Table

Usage

JamieOtter AIFireflies.aiKrispSonnetSuperpoweredTactiq
Bot-Free
YesYes
Both modes

Both modes (bot default; bot-free desktop on Mac/Win)

Both modes

Both modes (bot default; bot-free desktop on Mac/Win)

YesYes

Yes (no bot unless video)

YesYes

Yes (invisible recorder)

YesYes
YesYes (Chrome ext.)

Yes (Chrome extension)

Free Plan
10 meetings/mo

10 meetings/mo, 30-min cap, all features

300 min/mo

300 min/mo, 30-min cap per call

800-min lifetime

800-min storage lifetime cap

60 min/day + 2 notes/day

60 daily min noise cancel + unlimited transcripts + 2 mtg notes/day

5 recordings/mo

5 recordings/mo, 30-min cap

Calendar + 1-mo history

Calendar auto-join + 1-month notes history (AI Notes paid only)

10 transcripts + 5 AI

10 transcripts/mo + 5 AI summary credits

Languages
Yes100+
4

4 (English, Spanish, French, Japanese)

Yes100+
16 (95+ in beta)

16 on Core / 95+ in beta

~10

~10 (English, Spanish, French, German + 6)

50+
60+
Integrations
17 integrations

HubSpot, Salesforce, Attio, Notion, Asana, Make.com + 11 more (CRM + note-taking + task + calendar + AI/MCP + SSO + webhooks)

Slack, Salesforce, HubSpot…

Slack, Salesforce, HubSpot, Zoom, Teams, Meet + others (full count not publicly disclosed)

40+ integrations

HubSpot, Salesforce, Pipedrive, Zoho, Dynamics 365, Slack, Notion + others (40+ total)

800+ via audio layer

HubSpot, Salesforce, Pipedrive, Affinity (paid CRMs) + 800+ communication apps via audio layer

HubSpot, Salesforce

HubSpot, Salesforce + CRM-focused (full count not publicly disclosed)

Slack, Notion, Drive, CRMs

Slack, Notion, Google Drive, Salesforce, HubSpot, Zapier, email + others

Drive, CRMs, Slack, Notion

Google Drive, HubSpot, Salesforce, Pipedrive, Slack, Notion + others

Supported Devices
Mac, Windows, iOS
Web, iOS, Android
Web, iOS, Android
Mac, Windows
Mac (mobile soon)

Mac (mobile companion app launching)

Mac, Windows
Chrome only

Browser only (Chrome)

Meeting Platforms
Any platform + in-person

Any platform incl. in-person (device-audio capture)

Zoom, Meet, Teams (+ desktop audio)

Zoom, Meet, Teams (bot mode); any platform via desktop audio (bot-free mode)

Zoom, Meet, Teams, Webex (+ desktop audio)

Zoom, Meet, Teams, Webex (bot mode); any platform via desktop audio (bot-free mode)

Any communication app

Any communication app (audio-layer on 800+ apps)

Zoom, Meet, Teams, Discord, Slack
Any platform + in-person

Any platform incl. in-person (device audio)

Browser-based only

Browser-based only (Meet/Zoom web/Teams web)

Data Residency
EU (GDPR)

EU (German company, GDPR by default)

US (AWS)

US (AWS US-West region; EU-U.S. DPF certified for transfers)

US (EU option on Business+)

US default (GCP servers + AWS DB); EU Private Storage option on Business+ tier

US only

US only (AWS) — no regional residency outside US

Not disclosed

Not disclosed (public security page not found)

SOC-2 Type-2 + GDPR

SOC-2 Type-2 + GDPR compliant

Likely US (GCP)

Likely US (Google Cloud); current SOC-2 attestation

Recording Stored?
YesNo, auto-deleted
Yes
Yes
Yes (when activated)

Yes (when features activated)

Yes
YesNo, deleted immediately

No (audio deleted immediately)

User-controlled
Ask AI / Cross-Meeting Q&A
YesYes, cross-meeting

Yes, semantic, cross-meeting

YesYes, workspace-scoped

Yes, Otter AI Chat (workspace-scoped)

Smart Search (keyword)

Smart Search (keyword only, not semantic)

Via 3rd-party MCP

Via MCP integration with 3rd-party AI tools (no native Ask AI)

NoNo
NoNo
YesYes — Ask Tactiq AI

Yes — Ask Tactiq AI (single + multi-meeting analysis up to 10 on Team plan)

MCP Integration
YesYes
YesYes

Yes (bidirectional client+server)

YesYes

Yes (Claude connector directory + OAuth)

YesYes

Yes (Krisp MCP server, OAuth, hosted)

Not disclosed
Not disclosed
Not disclosed
Scroll horizontally for more tools
Click any cell with this icon to read more
import { ComparisonTable } from '@jamie/ui/compose/comparison-table'
 
type Verdict = 'yes' | 'no' | 'partial' | 'na'
 
type CellValue = {
  short: string
  long?: string
  verdict?: Verdict
}
 
type Tool = {
  name: string
  isJamie?: boolean
  values: Record<string, CellValue>
}
 
const features = [
  { key: 'botFree', label: 'Bot-Free' },
  { key: 'freePlan', label: 'Free Plan' },
  { key: 'languages', label: 'Languages' },
  { key: 'integrations', label: 'Integrations' },
  { key: 'devices', label: 'Supported Devices' },
  { key: 'platforms', label: 'Meeting Platforms' },
  { key: 'residency', label: 'Data Residency' },
  { key: 'recording', label: 'Recording Stored?' },
  { key: 'askAI', label: 'Ask AI / Cross-Meeting Q&A' },
  { key: 'mcp', label: 'MCP Integration' }
] as const
 
const tools: Tool[] = [
  {
    name: 'Jamie',
    isJamie: true,
    values: {
      botFree: { short: 'Yes', verdict: 'yes' },
      freePlan: { short: '10 meetings/mo', long: '10 meetings/mo, 30-min cap, all features' },
      languages: { short: '100+', verdict: 'yes' },
      integrations: {
        short: '17 integrations',
        long: 'HubSpot, Salesforce, Attio, Notion, Asana, Make.com + 11 more (CRM + note-taking + task + calendar + AI/MCP + SSO + webhooks)'
      },
      devices: { short: 'Mac, Windows, iOS', long: 'Mac, Windows, iOS' },
      platforms: {
        short: 'Any platform + in-person',
        long: 'Any platform incl. in-person (device-audio capture)'
      },
      residency: { short: 'EU (GDPR)', long: 'EU (German company, GDPR by default)' },
      recording: { short: 'No, auto-deleted', verdict: 'yes' },
      askAI: { short: 'Yes, cross-meeting', long: 'Yes, semantic, cross-meeting', verdict: 'yes' },
      mcp: { short: 'Yes', verdict: 'yes' }
    }
  },
  {
    name: 'Otter AI',
    values: {
      botFree: {
        short: 'Both modes',
        long: 'Both modes (bot default; bot-free desktop on Mac/Win)'
      },
      freePlan: { short: '300 min/mo', long: '300 min/mo, 30-min cap per call' },
      languages: { short: '4', long: '4 (English, Spanish, French, Japanese)' },
      integrations: {
        short: 'Slack, Salesforce, HubSpot…',
        long: 'Slack, Salesforce, HubSpot, Zoom, Teams, Meet + others (full count not publicly disclosed)'
      },
      devices: { short: 'Web, iOS, Android', long: 'Web, iOS, Android' },
      platforms: {
        short: 'Zoom, Meet, Teams (+ desktop audio)',
        long: 'Zoom, Meet, Teams (bot mode); any platform via desktop audio (bot-free mode)'
      },
      residency: {
        short: 'US (AWS)',
        long: 'US (AWS US-West region; EU-U.S. DPF certified for transfers)'
      },
      recording: { short: 'Yes' },
      askAI: {
        short: 'Yes, workspace-scoped',
        long: 'Yes, Otter AI Chat (workspace-scoped)',
        verdict: 'yes'
      },
      mcp: { short: 'Yes', long: 'Yes (bidirectional client+server)', verdict: 'yes' }
    }
  },
  {
    name: 'Fireflies.ai',
    values: {
      botFree: {
        short: 'Both modes',
        long: 'Both modes (bot default; bot-free desktop on Mac/Win)'
      },
      freePlan: { short: '800-min lifetime', long: '800-min storage lifetime cap' },
      languages: { short: '100+', verdict: 'yes' },
      integrations: {
        short: '40+ integrations',
        long: 'HubSpot, Salesforce, Pipedrive, Zoho, Dynamics 365, Slack, Notion + others (40+ total)'
      },
      devices: { short: 'Web, iOS, Android', long: 'Web, iOS, Android' },
      platforms: {
        short: 'Zoom, Meet, Teams, Webex (+ desktop audio)',
        long: 'Zoom, Meet, Teams, Webex (bot mode); any platform via desktop audio (bot-free mode)'
      },
      residency: {
        short: 'US (EU option on Business+)',
        long: 'US default (GCP servers + AWS DB); EU Private Storage option on Business+ tier'
      },
      recording: { short: 'Yes' },
      askAI: {
        short: 'Smart Search (keyword)',
        long: 'Smart Search (keyword only, not semantic)'
      },
      mcp: { short: 'Yes', long: 'Yes (Claude connector directory + OAuth)', verdict: 'yes' }
    }
  },
  {
    name: 'Krisp',
    values: {
      botFree: { short: 'Yes', long: 'Yes (no bot unless video)', verdict: 'yes' },
      freePlan: {
        short: '60 min/day + 2 notes/day',
        long: '60 daily min noise cancel + unlimited transcripts + 2 mtg notes/day'
      },
      languages: { short: '16 (95+ in beta)', long: '16 on Core / 95+ in beta' },
      integrations: {
        short: '800+ via audio layer',
        long: 'HubSpot, Salesforce, Pipedrive, Affinity (paid CRMs) + 800+ communication apps via audio layer'
      },
      devices: { short: 'Mac, Windows', long: 'Mac, Windows' },
      platforms: {
        short: 'Any communication app',
        long: 'Any communication app (audio-layer on 800+ apps)'
      },
      residency: {
        short: 'US only',
        long: 'US only (AWS) — no regional residency outside US'
      },
      recording: { short: 'Yes (when activated)', long: 'Yes (when features activated)' },
      askAI: {
        short: 'Via 3rd-party MCP',
        long: 'Via MCP integration with 3rd-party AI tools (no native Ask AI)'
      },
      mcp: { short: 'Yes', long: 'Yes (Krisp MCP server, OAuth, hosted)', verdict: 'yes' }
    }
  },
  {
    name: 'Sonnet',
    values: {
      botFree: { short: 'Yes', long: 'Yes (invisible recorder)', verdict: 'yes' },
      freePlan: { short: '5 recordings/mo', long: '5 recordings/mo, 30-min cap' },
      languages: { short: '~10', long: '~10 (English, Spanish, French, German + 6)' },
      integrations: {
        short: 'HubSpot, Salesforce',
        long: 'HubSpot, Salesforce + CRM-focused (full count not publicly disclosed)'
      },
      devices: { short: 'Mac (mobile soon)', long: 'Mac (mobile companion app launching)' },
      platforms: {
        short: 'Zoom, Meet, Teams, Discord, Slack',
        long: 'Zoom, Meet, Teams, Discord, Slack'
      },
      residency: { short: 'Not disclosed', long: 'Not disclosed (public security page not found)' },
      recording: { short: 'Yes' },
      askAI: { short: 'No', verdict: 'no' },
      mcp: { short: 'Not disclosed' }
    }
  },
  {
    name: 'Superpowered',
    values: {
      botFree: { short: 'Yes', verdict: 'yes' },
      freePlan: {
        short: 'Calendar + 1-mo history',
        long: 'Calendar auto-join + 1-month notes history (AI Notes paid only)'
      },
      languages: { short: '50+' },
      integrations: {
        short: 'Slack, Notion, Drive, CRMs',
        long: 'Slack, Notion, Google Drive, Salesforce, HubSpot, Zapier, email + others'
      },
      devices: { short: 'Mac, Windows' },
      platforms: {
        short: 'Any platform + in-person',
        long: 'Any platform incl. in-person (device audio)'
      },
      residency: { short: 'SOC-2 Type-2 + GDPR', long: 'SOC-2 Type-2 + GDPR compliant' },
      recording: {
        short: 'No, deleted immediately',
        long: 'No (audio deleted immediately)',
        verdict: 'yes'
      },
      askAI: { short: 'No', verdict: 'no' },
      mcp: { short: 'Not disclosed' }
    }
  },
  {
    name: 'Tactiq',
    values: {
      botFree: { short: 'Yes (Chrome ext.)', long: 'Yes (Chrome extension)', verdict: 'yes' },
      freePlan: {
        short: '10 transcripts + 5 AI',
        long: '10 transcripts/mo + 5 AI summary credits'
      },
      languages: { short: '60+' },
      integrations: {
        short: 'Drive, CRMs, Slack, Notion',
        long: 'Google Drive, HubSpot, Salesforce, Pipedrive, Slack, Notion + others'
      },
      devices: { short: 'Chrome only', long: 'Browser only (Chrome)' },
      platforms: {
        short: 'Browser-based only',
        long: 'Browser-based only (Meet/Zoom web/Teams web)'
      },
      residency: {
        short: 'Likely US (GCP)',
        long: 'Likely US (Google Cloud); current SOC-2 attestation'
      },
      recording: { short: 'User-controlled' },
      askAI: {
        short: 'Yes — Ask Tactiq AI',
        long: 'Yes — Ask Tactiq AI (single + multi-meeting analysis up to 10 on Team plan)',
        verdict: 'yes'
      },
      mcp: { short: 'Not disclosed' }
    }
  }
]
 
const verdictToIcon: Record<Verdict, 'check' | 'cross' | 'partial' | 'na'> = {
  yes: 'check',
  no: 'cross',
  partial: 'partial',
  na: 'na'
}
 
export function ComparisonTableDemo() {
  const highlightCol = tools.findIndex((t) => t.isJamie)
 
  return (
    <ComparisonTable.Root highlightCol={highlightCol >= 0 ? highlightCol : null}>
      <ComparisonTable.Table>
        <ComparisonTable.Head>
          <ComparisonTable.HeaderRow>
            <ComparisonTable.RowHeaderSlot />
            {tools.map((tool, idx) => (
              <ComparisonTable.HeaderCell key={tool.name} col={idx}>
                {tool.name}
              </ComparisonTable.HeaderCell>
            ))}
          </ComparisonTable.HeaderRow>
        </ComparisonTable.Head>
        <ComparisonTable.Body>
          {features.map((feature, rowIdx) => (
            <ComparisonTable.Row key={feature.key} row={rowIdx}>
              <ComparisonTable.RowHeader row={rowIdx}>{feature.label}</ComparisonTable.RowHeader>
              {tools.map((tool, colIdx) => {
                const value = tool.values[feature.key]
                if (!value) {
                  return (
                    <ComparisonTable.Cell
                      key={`${feature.key}-${tool.name}`}
                      row={rowIdx}
                      col={colIdx}
                    >
                      <span className="text-on-surface-variant/60">—</span>
                    </ComparisonTable.Cell>
                  )
                }
 
                const expandable =
                  value.long && value.long !== value.short ? <p>{value.long}</p> : undefined
 
                return (
                  <ComparisonTable.Cell
                    key={`${feature.key}-${tool.name}`}
                    row={rowIdx}
                    col={colIdx}
                    expandable={expandable}
                  >
                    {value.verdict ? (
                      <ComparisonTable.Icon variant={verdictToIcon[value.verdict]} />
                    ) : null}
                    <span>{value.short}</span>
                  </ComparisonTable.Cell>
                )
              })}
            </ComparisonTable.Row>
          ))}
        </ComparisonTable.Body>
      </ComparisonTable.Table>
      <ComparisonTable.Footer
        left="Scroll horizontally for more tools"
        right="Click any cell with this icon to read more"
      />
    </ComparisonTable.Root>
  )
}

CSV input

CSV input

4 tools × 4 features detected.

JamieFireflies.aiOtterGranola
Transcription
Yes
Full verbatim transcript and summary in under 30 seconds.
Yes
Timestamped, searchable transcripts; multi-language requires paid plan.
Yes
Live streaming transcription with editable transcript.
Yes
Real-time desktop transcription; web cannot capture audio.
Meeting Summaries
Yes
Executive and Full summary types with Brief or Extensive detail toggle, on every plan.
Yes
Timestamped chapter summaries; full detail needs Pro plan.
Yes
Live topic chapter summaries; auto-generated after 500 words.
Yes
AI-merged human notes and transcript; detail adjusted via chat prompt.
Action Items
Yes
Tasks auto-extracted with assignees, due dates, priority, completion.
Yes
Auto-assigns action items; syncs to PM tools.
Yes
Auto-assigns action items; centralized cross-meeting view.
Yes
AI extracts action items with assigned owners.
Speaker Memory
Yes
Remembers any speaker's voice; names them in future meetings.
No
Speaker naming draws from calendar names each meeting.
Yes
Manual tagging builds voice memory; recognized in future meetings.
No
Each meeting starts fresh; speakers labeled from scratch each time.
Scroll horizontally for more tools
Click any cell with a chevron to read more
'use client'
 
import { Button } from '@jamie/ui/button'
import { ComparisonTable } from '@jamie/ui/compose/comparison-table'
import { buildMatrixFromCsv, type ParsedCell } from '@jamie/ui/lib/comparison-table-csv'
import { Textarea } from '@jamie/ui/textarea'
import { X } from 'lucide-react'
import {
  type ChangeEvent,
  type DragEvent,
  type MouseEvent,
  useCallback,
  useMemo,
  useRef,
  useState
} from 'react'
 
const SAMPLE_CSV = `Tool,Jamie,Fireflies.ai,Otter,Granola
Transcription,Yes,Yes,Yes,Yes
Expandable Answer,Full verbatim transcript and summary in under 30 seconds.,"Timestamped, searchable transcripts; multi-language requires paid plan.",Live streaming transcription with editable transcript.,Real-time desktop transcription; web cannot capture audio.
Meeting Summaries,Yes,Yes,Yes,Yes
Expandable Answer,"Executive and Full summary types with Brief or Extensive detail toggle, on every plan.",Timestamped chapter summaries; full detail needs Pro plan.,Live topic chapter summaries; auto-generated after 500 words.,AI-merged human notes and transcript; detail adjusted via chat prompt.
Action Items,Yes,Yes,Yes,Yes
Expandable Answer,"Tasks auto-extracted with assignees, due dates, priority, completion.",Auto-assigns action items; syncs to PM tools.,Auto-assigns action items; centralized cross-meeting view.,AI extracts action items with assigned owners.
Speaker Memory,Yes,No,Yes,No
Expandable Answer,Remembers any speaker's voice; names them in future meetings.,Speaker naming draws from calendar names each meeting.,Manual tagging builds voice memory; recognized in future meetings.,Each meeting starts fresh; speakers labeled from scratch each time.
`
 
const renderIcon = (icon: ParsedCell['icon']) => {
  if (icon === 'none') return null
  return <ComparisonTable.Icon variant={icon} />
}
 
export function ComparisonTableCsvDemo() {
  const [csv, setCsv] = useState(SAMPLE_CSV)
  const [isDragging, setIsDragging] = useState(false)
  const [parseError, setParseError] = useState<string | null>(null)
  const fileInputRef = useRef<HTMLInputElement>(null)
 
  const matrix = useMemo(() => buildMatrixFromCsv(csv ?? ''), [csv])
 
  const handleFile = useCallback((file: File) => {
    setParseError(null)
    if (!file) return
    const isCsv =
      file.type === 'text/csv' ||
      file.type === 'application/vnd.ms-excel' ||
      file.name.toLowerCase().endsWith('.csv') ||
      file.type === 'text/plain'
    if (!isCsv) {
      setParseError(`Expected a .csv file, got "${file.name}".`)
      return
    }
    const reader = new FileReader()
    reader.onload = () => {
      const text = typeof reader.result === 'string' ? reader.result : ''
      setCsv(text)
    }
    reader.onerror = () => setParseError('Failed to read file.')
    reader.readAsText(file)
  }, [])
 
  const onDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
    e.preventDefault()
    e.stopPropagation()
    setIsDragging(true)
  }, [])
  const onDragLeave = useCallback((e: DragEvent<HTMLButtonElement>) => {
    e.preventDefault()
    e.stopPropagation()
    setIsDragging(false)
  }, [])
  const onDrop = useCallback(
    (e: DragEvent<HTMLButtonElement>) => {
      e.preventDefault()
      e.stopPropagation()
      setIsDragging(false)
      const file = e.dataTransfer?.files?.[0]
      if (file) handleFile(file)
    },
    [handleFile]
  )
  const onPickFile = useCallback((e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()
    fileInputRef.current?.click()
  }, [])
  const onFileInputChange = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0]
      if (file) handleFile(file)
      e.target.value = ''
    },
    [handleFile]
  )
  const onClear = useCallback((e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()
    setParseError(null)
    setCsv('')
  }, [])
 
  const hasCsv = Boolean(csv?.length)
  const hasMatrix = matrix.columns.length > 0 && matrix.rows.length > 0
 
  return (
    <div className="flex w-full flex-col gap-4">
      <div className="flex flex-col gap-2">
        <div className="flex items-center justify-between">
          <span className="text-sm font-medium">CSV input</span>
          {hasCsv ? (
            <Button onClick={onClear} size="sm" type="button" variant="outline">
              <X className="size-3.5" />
              Clear
            </Button>
          ) : null}
        </div>
 
        <button
          onClick={onPickFile}
          onDragEnter={onDragOver}
          onDragLeave={onDragLeave}
          onDragOver={onDragOver}
          onDrop={onDrop}
          className={`flex w-full flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors ${
            isDragging
              ? 'border-primary bg-primary/10'
              : 'border-outline-variant bg-surface-container-lowest hover:bg-surface-container-low'
          }`}
          type="button"
        >
          <span className="text-sm">Drop a CSV file here or click to browse</span>
          <span className="text-xs text-on-surface-variant">
            Parsed in your browser. You can also paste CSV text below.
          </span>
        </button>
        <input
          accept=".csv,text/csv"
          className="hidden"
          onChange={onFileInputChange}
          ref={fileInputRef}
          type="file"
        />
 
        <Textarea
          className="max-h-60 font-mono text-xs"
          onChange={(e) => setCsv(e.target.value)}
          placeholder={SAMPLE_CSV}
          rows={10}
          value={csv}
        />
 
        {parseError ? (
          <p className="text-xs text-destructive">{parseError}</p>
        ) : hasMatrix ? (
          <p className="text-xs text-on-surface-variant">
            {matrix.columns.length} tools × {matrix.rows.length} features detected.
          </p>
        ) : hasCsv ? (
          <p className="text-xs text-destructive">Could not detect any columns or feature rows.</p>
        ) : null}
      </div>
 
      <div className="border-t border-outline-variant pt-4">
        {hasMatrix ? (
          <ComparisonTable.Root highlightCol={0}>
            <ComparisonTable.Table>
              <ComparisonTable.Head>
                <ComparisonTable.HeaderRow>
                  <ComparisonTable.RowHeaderSlot />
                  {matrix.columns.map((col, idx) => (
                    <ComparisonTable.HeaderCell col={idx} key={col.name}>
                      {col.name}
                    </ComparisonTable.HeaderCell>
                  ))}
                </ComparisonTable.HeaderRow>
              </ComparisonTable.Head>
              <ComparisonTable.Body>
                {matrix.rows.map((row, rowIdx) => (
                  <ComparisonTable.Row key={row.label} row={rowIdx}>
                    <ComparisonTable.RowHeader row={rowIdx}>{row.label}</ComparisonTable.RowHeader>
                    {matrix.columns.map((col, colIdx) => {
                      const cell = row.cells[colIdx]
                      const cellKey = `${row.label}-${col.name}`
                      if (!cell) {
                        return (
                          <ComparisonTable.Cell col={colIdx} key={cellKey} row={rowIdx}>
                            <span className="text-on-surface-variant/60">—</span>
                          </ComparisonTable.Cell>
                        )
                      }
 
                      return (
                        <ComparisonTable.Cell
                          col={colIdx}
                          expandable={
                            cell.expandedHtml ? (
                              // biome-ignore lint/security/noDangerouslySetInnerHtml: demo content escaped by parser
                              <div dangerouslySetInnerHTML={{ __html: cell.expandedHtml }} />
                            ) : undefined
                          }
                          key={cellKey}
                          row={rowIdx}
                        >
                          {renderIcon(cell.icon)}
                          {cell.text ? <span>{cell.text}</span> : null}
                        </ComparisonTable.Cell>
                      )
                    })}
                  </ComparisonTable.Row>
                ))}
              </ComparisonTable.Body>
            </ComparisonTable.Table>
            <ComparisonTable.Footer
              left="Scroll horizontally for more tools"
              right="Click any cell with a chevron to read more"
            />
          </ComparisonTable.Root>
        ) : (
          <p className="py-8 text-center text-sm text-on-surface-variant">
            Paste a CSV or drop a file above to render the table.
          </p>
        )}
      </div>
    </div>
  )
}

Indexing

Each interactive part takes a numeric index:

  • HeaderCell takes col (0-based)
  • Row and RowHeader take row (0-based, must match)
  • Cell takes both row and col, matching its Row and the HeaderCell above it

The leftmost feature column is not a col. It uses RowHeaderSlot in the header and RowHeader in the body. Body columns start at col={0}. Derive both indices from the same .map() so hover alignment stays consistent.

import { ComparisonTable } from '@jamie/ui/compose/comparison-table'
 
<ComparisonTable.Root>
  <ComparisonTable.Table>
    <ComparisonTable.Head>
      <ComparisonTable.HeaderRow>
        <ComparisonTable.RowHeaderSlot />
        {tools.map((tool, col) => (
          <ComparisonTable.HeaderCell key={tool.id} col={col}>
            {tool.name}
          </ComparisonTable.HeaderCell>
        ))}
      </ComparisonTable.HeaderRow>
    </ComparisonTable.Head>

Body

    <ComparisonTable.Body>
      {features.map((feature, row) => (
        <ComparisonTable.Row key={feature.key} row={row}>
          <ComparisonTable.RowHeader row={row}>{feature.label}</ComparisonTable.RowHeader>
          {tools.map((tool, col) => (
            <ComparisonTable.Cell key={tool.id} row={row} col={col}>
              {tool.values[feature.key]}
            </ComparisonTable.Cell>
          ))}
        </ComparisonTable.Row>
      ))}
    </ComparisonTable.Body>
  </ComparisonTable.Table>
</ComparisonTable.Root>

Highlighted column

Pin and tint a column with highlightCol on Root. It stays sticky next to the feature column while the rest scrolls.

<ComparisonTable.Root highlightCol={ourIndex}>
  {/* … */}
</ComparisonTable.Root>

Expandable cells

expandable adds a chevron that swaps the short content for the expanded node in place. The cell never grows.

<ComparisonTable.Cell row={row} col={col} expandable={<p>{value.long}</p>}>
  {value.short}
</ComparisonTable.Cell>

Verdict icons

Variants: check, cross, partial, na.

<ComparisonTable.Icon variant="check" />

Optional caption with left and right slots. Renders nothing if both are omitted.

<ComparisonTable.Footer left="Scroll for more" right="Click chevrons to expand" />

Anatomy

  • Root accepts highlightCol?: number | null and featureColWidth?: string (default '180px')
  • Table, Head, Body are the semantic wrappers. Table also renders the fullscreen dialog
  • HeaderRow and HeaderCell (col)
  • RowHeaderSlot is the sticky top-left cell and hosts the fullscreen toggle
  • Row (row) and RowHeader (row)
  • Cell (row, col, optional expandable)
  • Footer (left, right)
  • Icon (variant)

Notes

  • Column widths are derived from content. Cell clamps to a min of [88, 180] and a max of 200. HeaderCell clamps to a min of [100, 160] and a max of 160. The widest cell in a column wins.
  • featureColWidth on Root sets the first column width and the sticky offset used by RowHeader and the highlighted column.
  • The component is 'use client', but the rendered DOM is a plain <table>, so it stays SEO and AEO friendly.
  • Used in apps/payload as the comparisonMatrixBlock rich-text block.