Comparison Table
Usage
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.
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:
HeaderCelltakescol(0-based)RowandRowHeadertakerow(0-based, must match)Celltakes bothrowandcol, matching itsRowand theHeaderCellabove 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.
Header
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" />Footer
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
RootacceptshighlightCol?: number | nullandfeatureColWidth?: string(default'180px')Table,Head,Bodyare the semantic wrappers.Tablealso renders the fullscreen dialogHeaderRowandHeaderCell(col)RowHeaderSlotis the sticky top-left cell and hosts the fullscreen toggleRow(row) andRowHeader(row)Cell(row,col, optionalexpandable)Footer(left,right)Icon(variant)
Notes
- Column widths are derived from content.
Cellclamps to a min of[88, 180]and a max of200.HeaderCellclamps to a min of[100, 160]and a max of160. The widest cell in a column wins. featureColWidthonRootsets the first column width and the sticky offset used byRowHeaderand 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/payloadas thecomparisonMatrixBlockrich-text block.