Wettbewerbsvergleich
Dein Angebot gegen die üblichen Alternativen, fair und konkret: zwei Seiten im direkten Gegenüber, eine gewichtete Wertung, die laufenden Kosten für eine Teamgröße und dieselbe Aufgabe im Wettlauf.
Dein Produkt gegen die Alternative
Ein Direktvergleich trifft Besucher, die schon abwägen und eine faire Antwort suchen. Diese vier Sections stellen ein Produkt neutralen Alternativen gegenüber, der Vergleich funktioniert also ohne echte Wettbewerber zu nennen und bleibt leicht aktuell zu halten.
„Gegenüber“ stellt zwei Seiten mit einem Medaillon dazwischen gegeneinander, die Frage steht in der Mitte jeder Zeile, oben wählen Besucher die Alternative, und die Zeichen gleiten von beiden Rändern herein. Die „Gewichtete Wertung“ zeigt Wertungen je Kriterium als Balken mit den Summen darüber, Besucher haken an, was ihnen wichtig ist, und die Summen rechnen sofort mit. „Kosten über die Zeit“ lässt Besucher ihre Teamgröße einstellen, die Linien zeichnen die laufenden Kosten Monat für Monat, die Lücke zur günstigsten Alternative leuchtet mit dem gesparten Betrag. Der „Wettlauf der Abläufe“ zeigt dieselbe Aufgabe auf beiden Wegen nebeneinander, eine Uhr läuft, die Schritte haken sich im echten Tempo ab, am Ziel erscheinen die gewonnenen Minuten.
Vergleich nach den Kriterien, die deine Käufer wirklich abwägen, und sag offen, wo die Alternative gut ist. Lass Besucher ihre eigenen Zahlen einstellen, selbst gerechnete Kosten überzeugen mehr als behauptete. Setz den Vergleich direkt vor die Preise.
Variants
Details
Was Direktvergleich mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Comparison
- Bereich
- Content
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/vs-comparison.tsx
- Bringt mit
- utilskitmotionbuttoniconnumber-tickersection
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 25 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- SaaS-Landingpage
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle VsComparisonProps aus components/ui/vs-comparison.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalVsComparisonVariantface-offscorecardcost-over-timeworkflow-race
eyebrowoptionalReact.ReactNodetitlePflichtReact.ReactNodedescriptionoptionalReact.ReactNodeusPflichtVsSideYour product
themPflichtVsSide[]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
Eingebaute Texte
Diese Texte schreibt Direktvergleich selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 25 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| vs | Englisch vs | Deutsch vs. |
| compareWith | Englisch Compare with | Deutsch Vergleichen mit |
| yes | Englisch Included | Deutsch Enthalten |
| no | Englisch Not included | Deutsch Nicht enthalten |
| matters | Englisch What matters most to you? | Deutsch Was ist dir am wichtigsten? |
| mattersHint | Englisch Tap what you care about, the totals follow. | Deutsch Tippe an, was zählt, die Summen rechnen mit. |
| overall | Englisch Overall | Deutsch Gesamt |
| outOf | Englisch out of 10 | Deutsch von 10 |
| team | Englisch Team size | Deutsch Teamgröße |
| people | Englisch {n} people | Deutsch {n} Personen |
| perMonth | Englisch {v} a month | Deutsch {v} im Monat |
| after | Englisch After {n} months | Deutsch Nach {n} Monaten |
| keep | Englisch You keep | Deutsch Du behältst |
| saveLong | Englisch over {n} months with a team of {s} people | Deutsch in {n} Monaten mit einem Team aus {s} Personen |
| month | Englisch Month | Deutsch Monat |
| total | Englisch Total | Deutsch Summe |
| task | Englisch The task | Deutsch Die Aufgabe |
| start | Englisch Start the race | Deutsch Rennen starten |
| replay | Englisch Run it again | Deutsch Noch einmal |
| steps | Englisch {n} steps | Deutsch {n} Schritte |
| min | Englisch {n} min | Deutsch {n} Min. |
| hours | Englisch {h} h {m} min | Deutsch {h} Std. {m} Min. |
| done | Englisch Done | Deutsch Fertig |
| faster | Englisch {n} minutes back, every time | Deutsch {n} Minuten zurück, jedes Mal |
| chartLabel | Englisch Running cost per month for each option | Deutsch Laufende Kosten pro Monat je Option |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Direktvergleich reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen img, radio und radiogroup.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 680 Zeilen
'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} />;
}Häufige Fragen
Wie installiere ich Direktvergleich?
Mit aiw add @ai-website/vs-comparison. Der Befehl legt die Datei components/ui/vs-comparison.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion, button, icon, number-ticker und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Direktvergleich?
4 Varianten, Gegenüber, Gewichtete Wertung, Kosten über die Zeit und Wettlauf der Abläufe. Du wählst sie mit der Prop variant, zum Beispiel variant="scorecard".
Kann ich die Texte von Direktvergleich übersetzen?
Ja. Direktvergleich bringt 25 eigene Texte auf Deutsch und Englisch mit, etwa „vs.“, „Vergleichen mit“ und „Enthalten“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Direktvergleich einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Direktvergleich mit aiw_components und installieren sie mit aiw_add @ai-website/vs-comparison. Nenn im Prompt die Variante, etwa variant="face-off", dann schreibt der Assistent deine Inhalte direkt in die Props.