Vs comparison page
Your product against the usual alternatives, fair and specific: two sides facing each other, a weighted scorecard, the running cost for a team size and the same task raced both ways.
Your product against the alternative
A vs page meets visitors who already compare and want a fair answer. These four sections set one product against neutral alternatives, so the comparison works without naming real competitors and stays easy to keep up to date.
Face-off lets two sides face each other with a medallion between them and the question in the middle of every row, the visitor picks the alternative above and the marks slide in from both edges. Weighted scorecard shows scores per criterion as bars with totals on top, visitors tick what matters to them and the totals follow at once. Cost over time lets visitors set their team size while lines draw the running cost month by month and the gap to the cheapest alternative lights up with the amount kept. Workflow race does the same task both ways side by side, a clock runs, steps tick off at their real pace and the minutes kept appear at the finish.
Compare on the criteria your buyers really weigh and admit where the alternative is fine. Let visitors set their own numbers, a cost they calculated themselves convinces more than one you claim. Put the vs section right before pricing.
Variants
Details
What Versus comparison brings along, read from the registry and the source.
- Category
- Comparison
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/vs-comparison.tsx
- Brings along
- utilskitmotionbuttoniconnumber-tickersection
- npm packages
- None
- Languages
- English and German, 25 texts of its own
- Studio
- Adjust in code
- In collections
- SaaS landing page
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The VsComparisonProps interface from components/ui/vs-comparison.tsx, with the comments from the code.
variantoptionalVsComparisonVariantface-offscorecardcost-over-timeworkflow-race
eyebrowoptionalReact.ReactNodetitlerequiredReact.ReactNodedescriptionoptionalReact.ReactNodeusrequiredVsSideYour product
themrequiredVsSide[]The alternatives, neutral names only
rowsoptionalVsRow[]face-off
scoresoptionalVsScore[]scorecard
costsoptionalVsCostSide[]cost-over-time: one entry per side, the first is yours
seatsoptional{ min: number; max: number; value: number }cost-over-time: team size range and start value
monthsoptionalnumbercost-over-time: months on the chart
currencyoptionalstringISO currency, e.g. EUR
taskoptionalstringworkflow-race: the task, e.g. Send a quote
raceoptionalVsRaceSide[]workflow-race: one entry per side, the first is yours
ctaoptional{ label: string; href: string }noteoptionalstringA short line under the action
Built-in texts
Versus comparison writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 25 texts
| Key | English | German |
|---|---|---|
| vs | English vs | German vs. |
| compareWith | English Compare with | German Vergleichen mit |
| yes | English Included | German Enthalten |
| no | English Not included | German Nicht enthalten |
| matters | English What matters most to you? | German Was ist dir am wichtigsten? |
| mattersHint | English Tap what you care about, the totals follow. | German Tippe an, was zählt, die Summen rechnen mit. |
| overall | English Overall | German Gesamt |
| outOf | English out of 10 | German von 10 |
| team | English Team size | German Teamgröße |
| people | English {n} people | German {n} Personen |
| perMonth | English {v} a month | German {v} im Monat |
| after | English After {n} months | German Nach {n} Monaten |
| keep | English You keep | German Du behältst |
| saveLong | English over {n} months with a team of {s} people | German in {n} Monaten mit einem Team aus {s} Personen |
| month | English Month | German Monat |
| total | English Total | German Summe |
| task | English The task | German Die Aufgabe |
| start | English Start the race | German Rennen starten |
| replay | English Run it again | German Noch einmal |
| steps | English {n} steps | German {n} Schritte |
| min | English {n} min | German {n} Min. |
| hours | English {h} h {m} min | German {h} Std. {m} Min. |
| done | English Done | German Fertig |
| faster | English {n} minutes back, every time | German {n} Minuten zurück, jedes Mal |
| chartLabel | English Running cost per month for each option | German Laufende Kosten pro Monat je Option |
Accessibility
Read from the source of the component.
- Versus comparison handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
- Uses the roles img, radio and radiogroup.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 680 lines
'use client';
import { useEffect, useId, useMemo, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Magnetic } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Eyebrow, Section } from '@/components/ui/section';
/*
* One product against the usual alternatives, fair in tone and specific in
* substance. Alternatives are named neutrally (Alternative A, a spreadsheet,
* an agency), never with real brands. Four layouts:
*
* face-off two cards facing each other, the question in the middle
* scorecard scores per criterion, weighted by what the visitor cares about
* cost-over-time the running cost for a team size the visitor sets, with the gap
* workflow-race the same task done both ways, step by step, with a clock
*/
export type VsComparisonVariant = 'face-off' | 'scorecard' | 'cost-over-time' | 'workflow-race';
export interface VsSide {
name: string;
/** One short line under the name, e.g. what it is */
note?: string;
}
export interface VsRow {
label: string;
/** One value per side, the first is yours. true and false draw marks, text stays text. */
values: (boolean | string)[];
}
export interface VsScore {
label: string;
/** 0 to 10 per side, the first is yours */
values: number[];
}
export interface VsCostSide {
/** Price per seat and month */
perSeat: number;
/** One-off cost at the start, e.g. setup or migration */
setup?: number;
/** A fixed monthly base on top of the seats */
base?: number;
}
export interface VsRaceSide {
/** Steps of the task, in order */
steps: { label: string; minutes: number }[];
}
export interface VsComparisonProps {
variant?: VsComparisonVariant;
eyebrow?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
/** Your product */
us: VsSide;
/** The alternatives, neutral names only */
them: VsSide[];
/** face-off */
rows?: VsRow[];
/** scorecard */
scores?: VsScore[];
/** cost-over-time: one entry per side, the first is yours */
costs?: VsCostSide[];
/** cost-over-time: team size range and start value */
seats?: { min: number; max: number; value: number };
/** cost-over-time: months on the chart */
months?: number;
/** ISO currency, e.g. EUR */
currency?: string;
/** workflow-race: the task, e.g. Send a quote */
task?: string;
/** workflow-race: one entry per side, the first is yours */
race?: VsRaceSide[];
cta?: { label: string; href: string };
/** A short line under the action */
note?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
vs: 'vs',
compareWith: 'Compare with',
yes: 'Included',
no: 'Not included',
matters: 'What matters most to you?',
mattersHint: 'Tap what you care about, the totals follow.',
overall: 'Overall',
outOf: 'out of 10',
team: 'Team size',
people: '{n} people',
perMonth: '{v} a month',
after: 'After {n} months',
keep: 'You keep',
saveLong: 'over {n} months with a team of {s} people',
month: 'Month',
total: 'Total',
task: 'The task',
start: 'Start the race',
replay: 'Run it again',
steps: '{n} steps',
min: '{n} min',
hours: '{h} h {m} min',
done: 'Done',
faster: '{n} minutes back, every time',
chartLabel: 'Running cost per month for each option',
},
de: {
vs: 'vs.',
compareWith: 'Vergleichen mit',
yes: 'Enthalten',
no: 'Nicht enthalten',
matters: 'Was ist dir am wichtigsten?',
mattersHint: 'Tippe an, was zählt, die Summen rechnen mit.',
overall: 'Gesamt',
outOf: 'von 10',
team: 'Teamgröße',
people: '{n} Personen',
perMonth: '{v} im Monat',
after: 'Nach {n} Monaten',
keep: 'Du behältst',
saveLong: 'in {n} Monaten mit einem Team aus {s} Personen',
month: 'Monat',
total: 'Summe',
task: 'Die Aufgabe',
start: 'Rennen starten',
replay: 'Noch einmal',
steps: '{n} Schritte',
min: '{n} Min.',
hours: '{h} Std. {m} Min.',
done: 'Fertig',
faster: '{n} Minuten zurück, jedes Mal',
chartLabel: 'Laufende Kosten pro Monat je Option',
},
};
type Texts = (typeof TEXTS)['en'];
type ViewProps = VsComparisonProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const delay = (ms: number) => ({ '--kit-delay': `${ms}ms` }) as React.CSSProperties;
function useMoney(currency = 'EUR') {
const locale = useLocale();
return (n: number) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(n);
}
function Head({ eyebrow, title, description, center, className }: Pick<VsComparisonProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string }) {
return (
<div className={cn('max-w-[46rem]', center && 'mx-auto max-w-[42rem] text-center', className)}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className={cn('kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={delay(80)}>
{description}
</p>
)}
</div>
);
}
function Action({ cta, note, center, className }: Pick<VsComparisonProps, 'cta' | 'note'> & { center?: boolean; className?: string }) {
if (!cta && !note) return null;
return (
<div className={cn('flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-5', center && 'items-center sm:justify-center', className)}>
{cta && (
<Magnetic className="max-sm:flex max-sm:w-full">
<Button href={cta.href} size="lg" icon="arrow-right" className="max-sm:w-full">
{cta.label}
</Button>
</Magnetic>
)}
{note && <p className="text-pretty text-[13.5px] text-muted-foreground">{note}</p>}
</div>
);
}
/** A picker for one alternative, as a radio group of chips */
function SidePicker({ them, value, onChange, label }: { them: VsSide[]; value: number; onChange: (i: number) => void; label: string }) {
const id = useId();
if (them.length < 2) return null;
return (
<div className="flex flex-col items-center gap-3">
<p id={id} className="text-[13px] font-medium text-muted-foreground">
{label}
</p>
<div
role="radiogroup"
aria-labelledby={id}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const next = (value + d + them.length) % them.length;
onChange(next);
(e.currentTarget.querySelectorAll('[role="radio"]')[next] as HTMLElement | undefined)?.focus();
}}
className="inline-flex max-w-full flex-wrap justify-center gap-1 rounded-xl border border-border bg-surface p-1"
>
{them.map((s, i) => (
<button
key={i}
type="button"
role="radio"
aria-checked={value === i}
tabIndex={value === i ? 0 : -1}
onClick={() => onChange(i)}
className={cn(
'h-11 cursor-pointer rounded-lg px-4 text-[14px] font-medium transition-[background-color,color,box-shadow] duration-300 ease-[var(--ease-kit)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary sm:h-9',
value === i ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground',
)}
>
{s.name}
</button>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* face-off */
/* ------------------------------------------------------------------ */
function Mark({ value, ours, t }: { value: boolean | string; ours?: boolean; t: Texts }) {
if (value === true)
return (
<span className={cn('grid size-6 shrink-0 place-items-center rounded-full', ours ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)] text-foreground')}>
<Icon name="check" size={13} strokeWidth={2.4} label={t.yes} />
</span>
);
if (value === false)
return (
<span className="grid size-6 shrink-0 place-items-center rounded-full border border-dashed border-border-strong text-subtle-foreground">
<Icon name="minus" size={12} strokeWidth={2} label={t.no} />
</span>
);
return <span className={cn('text-pretty text-[14px] font-medium leading-snug', ours ? 'text-foreground' : 'text-muted-foreground')}>{value}</span>;
}
function FaceOff(p: ViewProps) {
const { t } = p;
const [pick, setPick] = useState(0);
const rows = p.rows ?? [];
const other = p.them[Math.min(pick, p.them.length - 1)];
const score = (side: number) => rows.filter((r) => r.values[side] === true).length;
const [ref, inView] = useInView<HTMLDivElement>();
return (
<Section className={p.className}>
<Head {...p} center />
<div className="kit-rise mt-10" style={delay(140)}>
<SidePicker them={p.them} value={pick} onChange={setPick} label={t.compareWith} />
</div>
<div ref={ref} className="relative mx-auto mt-10 max-w-[64rem]">
{/* the two sides as headers, a medallion between them */}
{/* the same three columns as the rows below, so each card stands over its own answers */}
<div className="grid grid-cols-[minmax(0,1fr)_2.75rem_minmax(0,1fr)] items-stretch gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,14rem)_minmax(0,1fr)] sm:gap-6">
<div className="relative overflow-hidden rounded-2xl border border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] p-4 shadow-[var(--highlight),0_0_0_4px_var(--glow),var(--shadow-md)] sm:p-6 sm:text-right">
<p className="text-[17px] font-semibold text-foreground sm:text-[20px]">{p.us.name}</p>
{p.us.note && <p className="mt-1 text-pretty text-[13px] text-muted-foreground max-sm:hidden">{p.us.note}</p>}
<p className="mt-3 text-[13px] text-muted-foreground">
<span className="text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground sm:text-[26px]">
<NumberTicker value={score(0)} />
</span>{' '}
/ {rows.length}
</p>
</div>
<div className="relative flex items-center justify-center">
<span aria-hidden className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-[linear-gradient(to_bottom,transparent,var(--border),transparent)]" />
<span className={cn('relative grid size-11 place-items-center rounded-full border border-border bg-background text-[13px] font-semibold uppercase tracking-[0.06em] text-muted-foreground shadow-[var(--shadow-md)] transition-transform duration-700 ease-[var(--ease-kit)] sm:size-14 sm:text-[15px]', inView ? 'scale-100' : 'scale-50')}>
{t.vs}
</span>
</div>
<div key={pick} className="kit-rise rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6">
<p className="text-[17px] font-semibold text-foreground sm:text-[20px]">{other?.name}</p>
{other?.note && <p className="mt-1 text-pretty text-[13px] text-muted-foreground max-sm:hidden">{other.note}</p>}
<p className="mt-3 text-[13px] text-muted-foreground">
<span className="text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground sm:text-[26px]">{score(pick + 1)}</span> / {rows.length}
</p>
</div>
</div>
{/* the rows: our answer, the question, their answer */}
<ul className="mt-4 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
{rows.map((r, i) => (
<li
key={i}
className={cn(
'group/row grid grid-cols-[minmax(0,1fr)_2.75rem_minmax(0,1fr)] gap-x-3 gap-y-2 border-border px-4 py-4 transition-colors duration-200 hover:bg-surface sm:grid-cols-[minmax(0,1fr)_minmax(0,14rem)_minmax(0,1fr)] sm:items-center sm:gap-6 sm:px-6',
i > 0 && 'border-t',
)}
>
<p className="col-span-3 text-[13px] font-medium text-muted-foreground sm:order-2 sm:col-span-1 sm:text-center sm:text-[14px] sm:text-foreground">{r.label}</p>
<div className={cn('flex items-center gap-2.5 sm:order-1 sm:justify-end sm:text-right transition-[opacity,transform] duration-500 ease-[var(--ease-kit)]', inView ? 'opacity-100' : '-translate-x-3 opacity-0')} style={{ transitionDelay: `${i * 60}ms` }}>
<span className="sr-only">{p.us.name}: </span>
<Mark value={r.values[0]} ours t={t} />
</div>
<div key={pick} className={cn('col-start-3 flex items-center gap-2.5 sm:order-3 sm:col-start-auto transition-[opacity,transform] duration-500 ease-[var(--ease-kit)]', inView ? 'opacity-100' : 'translate-x-3 opacity-0')} style={{ transitionDelay: `${i * 60}ms` }}>
<span className="sr-only">{other?.name}: </span>
<Mark value={r.values[pick + 1] ?? false} t={t} />
</div>
</li>
))}
</ul>
</div>
<Action cta={p.cta} note={p.note} center className="mt-10" />
</Section>
);
}
/* ------------------------------------------------------------------ */
/* scorecard */
/* ------------------------------------------------------------------ */
function Scorecard(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const scores = p.scores ?? [];
const sides = [p.us, ...p.them];
const [on, setOn] = useState<boolean[]>(() => scores.map(() => true));
const [ref, inView] = useInView<HTMLDivElement>();
const active = scores.filter((_, i) => on[i] ?? true);
const total = (side: number) => (active.length ? active.reduce((a, s) => a + (s.values[side] ?? 0), 0) / active.length : 0);
const fmt = (n: number) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n);
return (
<Section className={p.className}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:gap-16">
<div>
<Head {...p} />
<fieldset className="kit-rise mt-9 min-w-0" style={delay(140)}>
<legend className="text-[14px] font-semibold text-foreground">{t.matters}</legend>
<p className="mt-1 text-[13px] text-muted-foreground">{t.mattersHint}</p>
<div className="mt-4 flex flex-wrap gap-2">
{scores.map((s, i) => {
const checked = on[i] ?? true;
return (
<label
key={i}
className={cn(
'inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full border px-4 text-[14px] font-medium transition-[background-color,border-color,color] duration-200 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-primary sm:min-h-9',
checked ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground',
)}
>
<input
type="checkbox"
checked={checked}
onChange={() =>
setOn((x) => {
const next = scores.map((_, j) => (j === i ? !checked : (x[j] ?? true)));
return next.some(Boolean) ? next : x;
})
}
className="sr-only"
/>
<Icon name={checked ? 'check' : 'plus'} size={14} strokeWidth={2} className={cn(checked && 'text-primary')} />
{s.label}
</label>
);
})}
</div>
</fieldset>
<Action cta={p.cta} note={p.note} className="mt-10 max-lg:hidden" />
</div>
<div ref={ref} className="kit-rise" style={delay(200)}>
{/* totals */}
<div className="grid gap-px overflow-hidden rounded-2xl border border-border bg-border shadow-[var(--highlight),var(--shadow-sm)]" style={{ gridTemplateColumns: `repeat(${sides.length}, minmax(0, 1fr))` }}>
{sides.map((s, i) => {
const v = total(i);
const best = sides.every((_, j) => total(j) <= v);
return (
<div key={i} className={cn('relative p-4 sm:p-5', i === 0 ? 'bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'bg-card')}>
{i === 0 && <span aria-hidden className="absolute inset-x-0 top-0 h-0.5 bg-primary" />}
<p className="text-pretty text-[13px] font-medium leading-snug text-muted-foreground">{s.name}</p>
<p className="mt-2 flex items-baseline gap-1.5">
<span aria-live="polite" className={cn('text-[clamp(1.75rem,1.4rem+1.4vw,2.5rem)] font-semibold leading-none tabular-nums tracking-[-0.03em]', best ? 'text-foreground' : 'text-muted-foreground')}>
{fmt(v)}
</span>
<span className="text-[12px] text-subtle-foreground max-sm:hidden">{t.outOf}</span>
</p>
</div>
);
})}
</div>
{/* per criterion */}
<ul className="mt-6 grid gap-5">
{scores.map((s, i) => {
const off = !(on[i] ?? true);
return (
<li key={i} className={cn('transition-opacity duration-300', off && 'opacity-40')}>
<p className="flex items-baseline justify-between gap-3 text-[14px] font-medium text-foreground">
{s.label}
</p>
<div className="mt-2.5 grid gap-1.5">
{sides.map((side, j) => {
const v = s.values[j] ?? 0;
return (
<div key={j} className="grid grid-cols-[minmax(0,7.5rem)_minmax(0,1fr)_2.25rem] items-center gap-3 sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)_2.5rem]">
<span className={cn('truncate text-[12.5px]', j === 0 ? 'font-medium text-foreground' : 'text-muted-foreground')}>{side.name}</span>
<span className="h-2 overflow-hidden rounded-full bg-track">
<span
className={cn('block h-full origin-left rounded-full transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none', j === 0 ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_38%,transparent)]')}
style={{ transform: `scaleX(${inView ? round(v / 10, 3) : 0})`, transitionDelay: `${i * 70 + j * 40}ms` }}
/>
</span>
<span className="text-right text-[12.5px] tabular-nums text-muted-foreground">{v}</span>
</div>
);
})}
</div>
</li>
);
})}
</ul>
<Action cta={p.cta} note={p.note} className="mt-10 lg:hidden" />
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* cost-over-time */
/* ------------------------------------------------------------------ */
function CostOverTime(p: ViewProps) {
const { t } = p;
const id = useId();
const money = useMoney(p.currency);
const seats = p.seats ?? { min: 1, max: 50, value: 12 };
const [n, setN] = useState(seats.value);
const months = p.months ?? 24;
const costs = p.costs ?? [];
const sides = [p.us, ...p.them].slice(0, costs.length);
const [ref, inView] = useInView<HTMLDivElement>();
const series = useMemo(
() => costs.map((c) => Array.from({ length: months + 1 }, (_, m) => (c.setup ?? 0) + m * ((c.base ?? 0) + c.perSeat * n))),
[costs, months, n],
);
const totals = series.map((s) => s[months]);
const max = Math.max(1, ...totals);
const cheapestOther = Math.min(...totals.slice(1));
const saved = Math.max(0, cheapestOther - (totals[0] ?? 0));
const oi = Math.max(1, totals.indexOf(cheapestOther));
const W = 640;
const H = 280;
const pad = { l: 8, r: 8, t: 16, b: 8 };
const x = (m: number) => round(pad.l + (m / months) * (W - pad.l - pad.r));
const y = (v: number) => round(H - pad.b - (v / max) * (H - pad.t - pad.b));
const path = (s: number[]) => s.map((v, m) => `${m ? 'L' : 'M'}${x(m)} ${y(v)}`).join(' ');
const pct = round(((n - seats.min) / Math.max(1, seats.max - seats.min)) * 100, 2);
const ticks = [0, Math.round(months / 4), Math.round(months / 2), Math.round((3 * months) / 4), months];
return (
<Section background="surface" className={p.className}>
<Head {...p} />
<div ref={ref} className="kit-rise mt-12 grid grid-cols-[minmax(0,1fr)] overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]" style={delay(140)}>
{/* controls and totals */}
<div className="border-b border-border p-6 sm:p-7 lg:border-b-0 lg:border-r">
<div className="flex items-baseline justify-between gap-3">
<label htmlFor={id} className="text-[14px] font-medium text-foreground">
{t.team}
</label>
<output htmlFor={id} className="text-[15px] font-semibold tabular-nums text-foreground">
{fill(t.people, { n })}
</output>
</div>
<div className="relative mt-4 h-11">
<span aria-hidden className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-track" />
<span aria-hidden className="absolute left-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary" style={{ width: `${pct}%` }} />
<input
id={id}
type="range"
min={seats.min}
max={seats.max}
value={n}
onChange={(e) => setN(Number(e.target.value))}
className="relative h-11 w-full cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary [&::-moz-range-thumb]:size-6 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-card [&::-webkit-slider-thumb]:size-6 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:shadow-[var(--shadow-md)] [&::-webkit-slider-thumb]:transition-shadow focus-visible:[&::-webkit-slider-thumb]:shadow-[0_0_0_5px_var(--glow)]"
/>
</div>
<ul className="mt-6 grid gap-3">
{sides.map((s, i) => (
<li key={i} className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-2.5 text-[14px] text-foreground">
<span aria-hidden className={cn('w-5 shrink-0', i === 0 ? 'h-[3px] rounded-full bg-primary' : i === 1 ? 'h-0.5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_55%,transparent)]' : 'border-t-2 border-dotted border-[color-mix(in_oklab,var(--foreground)_40%,transparent)]')} />
<span className={cn('truncate', i === 0 && 'font-semibold')}>{s.name}</span>
</span>
<span className={cn('shrink-0 text-[14px] tabular-nums', i === 0 ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{money(totals[i] ?? 0)}</span>
</li>
))}
</ul>
<p className="mt-2 text-[12.5px] text-subtle-foreground">{fill(t.after, { n: months })}</p>
{saved > 0 && (
<div className="mt-6 rounded-xl bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-4">
<p className="text-[13px] font-medium text-success-foreground">{t.keep}</p>
<p aria-live="polite" className="mt-1 text-[28px] font-semibold leading-none tabular-nums tracking-[-0.03em] text-foreground">
{money(saved)}
</p>
<p className="mt-1.5 text-pretty text-[12.5px] text-muted-foreground">{fill(t.saveLong, { n: months, s: n })}</p>
</div>
)}
</div>
{/* chart */}
<figure className="p-4 sm:p-7">
<figcaption className="sr-only">{t.chartLabel}</figcaption>
<svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full overflow-visible" role="img" aria-label={sides.map((s, i) => `${s.name} ${money(totals[i] ?? 0)}`).join(', ')}>
{[0.25, 0.5, 0.75, 1].map((f) => (
<line key={f} x1={pad.l} x2={W - pad.r} y1={y(max * f)} y2={y(max * f)} stroke="var(--border)" strokeDasharray="3 5" />
))}
<line x1={pad.l} x2={W - pad.r} y1={H - pad.b} y2={H - pad.b} stroke="var(--border-strong)" />
{/* the gap between ours and the cheapest other, lit softly */}
{series.length > 1 && (
<path
d={`${path(series[0])} ${series[oi].map((_, m) => `L${x(months - m)} ${y(series[oi][months - m])}`).join(' ')} Z`}
fill="color-mix(in oklab, var(--success) 12%, transparent)"
className={cn('transition-opacity duration-700', inView ? 'opacity-100' : 'opacity-0')}
/>
)}
{series
.map((s, i) => ({ s, i }))
.reverse()
.map(({ s, i }) => (
<path
key={i}
d={path(s)}
fill="none"
pathLength={1}
stroke={i === 0 ? 'var(--primary)' : `color-mix(in oklab, var(--foreground) ${i === 1 ? 55 : 30}%, transparent)`}
strokeWidth={i === 0 ? 3 : 2}
strokeDasharray={i > 1 ? '0.012 0.012' : '1'}
strokeDashoffset={inView || i > 1 ? 0 : 1}
strokeLinecap="round"
className="transition-[stroke-dashoffset] duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none"
/>
))}
{series.map((s, i) => (
<circle key={i} cx={x(months)} cy={y(s[months])} r={i === 0 ? 5 : 4} fill="var(--card)" stroke={i === 0 ? 'var(--primary)' : 'var(--muted-foreground)'} strokeWidth="2" />
))}
</svg>
{/* the month ticks as text, so they keep their size on every screen */}
<div aria-hidden className="relative mt-1 h-4 text-[11.5px] tabular-nums text-subtle-foreground">
{ticks.map((m) => (
<span key={m} className={cn('absolute top-0', m === 0 ? 'translate-x-0' : m === months ? '-translate-x-full' : '-translate-x-1/2')} style={{ left: `${round((x(m) / W) * 100, 2)}%` }}>
{m === 0 ? t.month : m}
</span>
))}
</div>
</figure>
</div>
<Action cta={p.cta} note={p.note} className="mt-10" />
</Section>
);
}
/* ------------------------------------------------------------------ */
/* workflow-race */
/* ------------------------------------------------------------------ */
function WorkflowRace(p: ViewProps) {
const { t } = p;
const race = p.race ?? [];
const sides = [p.us, ...p.them].slice(0, race.length);
const totals = race.map((r) => r.steps.reduce((a, s) => a + s.minutes, 0));
const longest = Math.max(1, ...totals);
const [ref, inView] = useInView<HTMLDivElement>();
const [clock, setClock] = useState(-1);
const [run, setRun] = useState(0);
// one second of the race shows a few minutes, the slowest side takes about seven seconds
useEffect(() => {
if (!inView) return;
if (prefersReducedMotion()) {
setClock(longest);
return;
}
setClock(0);
const stepMs = 50;
const perTick = longest / (7000 / stepMs);
const id = window.setInterval(
() =>
setClock((c) => {
const next = Math.min(longest, c + perTick);
if (next >= longest) window.clearInterval(id);
return next;
}),
stepMs,
);
return () => window.clearInterval(id);
}, [inView, run, longest]);
const shown = clock < 0 ? longest : clock;
const fmt = (m: number) => (m >= 60 ? fill(t.hours, { h: Math.floor(m / 60), m: Math.round(m % 60) }) : fill(t.min, { n: Math.round(m) }));
const back = Math.max(0, Math.min(...totals.slice(1)) - (totals[0] ?? 0));
return (
<Section className={p.className}>
<Head {...p} center />
{p.task && (
<p className="kit-rise mx-auto mt-8 flex w-fit max-w-full items-center gap-2 rounded-full border border-border bg-card px-4 py-2 text-[14px] text-foreground shadow-[var(--shadow-sm)]" style={delay(140)}>
<Icon name="target" size={15} className="shrink-0 text-primary" />
<span className="text-muted-foreground">{t.task}</span>
<span className="font-medium">{p.task}</span>
</p>
)}
<div ref={ref} className={cn('mx-auto mt-10 grid max-w-[64rem] grid-cols-[minmax(0,1fr)] gap-4', race.length === 2 && 'md:grid-cols-2', race.length >= 3 && 'md:grid-cols-2 lg:grid-cols-3')}>
{race.map((r, i) => {
let acc = 0;
const done = shown >= totals[i];
return (
<div key={i} className={cn('relative overflow-hidden rounded-2xl border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6', i === 0 ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))]' : 'border-border')}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[16px] font-semibold text-foreground">{sides[i]?.name}</p>
<p className="mt-0.5 text-[13px] text-muted-foreground">{fill(t.steps, { n: r.steps.length })}</p>
</div>
<p className={cn('flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-[13px] font-semibold tabular-nums transition-colors duration-300', done ? (i === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground') : 'bg-surface text-muted-foreground')}>
<Icon name={done ? 'check' : 'timer'} size={14} strokeWidth={done ? 2.2 : 1.6} />
<span aria-live="off">{fmt(Math.min(shown, totals[i]))}</span>
</p>
</div>
{/* the overall track, its length is the share of the slowest side */}
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-track">
<span className={cn('block h-full rounded-full', i === 0 ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_45%,transparent)]')} style={{ width: `${round((Math.min(shown, totals[i]) / longest) * 100, 2)}%` }} />
</div>
<ol className="mt-5 grid gap-1">
{r.steps.map((s, j) => {
const start = acc;
acc += s.minutes;
const state = shown >= acc ? 'done' : shown > start ? 'now' : 'wait';
return (
<li key={j} className={cn('flex items-center gap-3 rounded-lg px-2 py-2 text-[14px] transition-[background-color,color] duration-300', state === 'now' && 'bg-surface', state === 'wait' ? 'text-subtle-foreground' : 'text-foreground')}>
<span className={cn('grid size-5 shrink-0 place-items-center rounded-full border text-[10px] font-semibold transition-colors duration-300', state === 'done' ? (i === 0 ? 'border-primary bg-primary text-primary-foreground' : 'border-foreground bg-foreground text-background') : state === 'now' ? 'border-primary text-primary' : 'border-border')}>
{state === 'done' ? <Icon name="check" size={11} strokeWidth={2.6} /> : j + 1}
</span>
<span className="min-w-0 flex-1 text-pretty">{s.label}</span>
<span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">{fmt(s.minutes)}</span>
</li>
);
})}
</ol>
</div>
);
})}
</div>
<div className="mt-8 flex flex-col items-center gap-4">
{back > 0 && (
<p className={cn('text-center text-[15px] font-medium text-foreground transition-opacity duration-500', shown >= longest ? 'opacity-100' : 'opacity-0')}>{fill(t.faster, { n: back })}</p>
)}
<button
type="button"
onClick={() => setRun((x) => x + 1)}
className="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg px-3 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary"
>
<Icon name="rotate-ccw" size={15} />
{t.replay}
</button>
</div>
<Action cta={p.cta} note={p.note} center className="mt-6" />
</Section>
);
}
/* ------------------------------------------------------------------ */
const VIEWS: Record<VsComparisonVariant, (p: ViewProps) => React.ReactNode> = {
'face-off': FaceOff,
scorecard: Scorecard,
'cost-over-time': CostOverTime,
'workflow-race': WorkflowRace,
};
export function VsComparison({ variant = 'face-off', labels, ...rest }: VsComparisonProps) {
const t = useTexts(TEXTS, labels);
const View = VIEWS[variant] ?? FaceOff;
return <View {...rest} variant={variant} t={t} />;
}Frequent questions
How do I install Versus comparison?
With aiw add @ai-website/vs-comparison. The command puts components/ui/vs-comparison.tsx into your project, the code is yours from then on. It brings along motion, button, icon, number-ticker and section. It needs no further npm packages.
Which variants does Versus comparison have?
4 variants, Face-off, Weighted scorecard, Cost over time and Workflow race. You pick one with the variant prop, for example variant="scorecard".
Can I translate the texts of Versus comparison?
Yes. Versus comparison carries 25 texts of its own in English and German, such as “vs”, “Compare with” and “Included”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Versus comparison?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Versus comparison with aiw_components and install it with aiw_add @ai-website/vs-comparison. Name the variant in your prompt, for example variant="face-off", and the assistant writes your content straight into the props.