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>
)
}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.