Zum Inhalt springen
UI Kit
Accent

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.

4 VariantsEine von 2 Components in Comparison

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

GegenüberNeu in 1.2

Zwei Seiten stehen sich gegenüber, dazwischen ein Medaillon, die Frage steht in der Mitte jeder Zeile. Oben die Alternative wählen, die Zeichen gleiten von beiden Rändern herein.

Gewichtete WertungNeu in 1.2

Wertungen je Kriterium als Balken, die hineinwachsen, die Summen darüber. Besucher haken an, was ihnen wichtig ist, und die Summen rechnen sofort mit.

Kosten über die ZeitNeu in 1.2

Besucher stellen ihre Teamgröße ein, die Linien zeichnen die laufenden Kosten Monat für Monat, und die Lücke zur günstigsten Alternative leuchtet mit dem gesparten Betrag.

Wettlauf der AbläufeNeu in 1.2

Dieselbe Aufgabe auf beiden Wegen, nebeneinander. Im Blick läuft eine Uhr, die Schritte haken sich im echten Tempo ab, am Ziel erscheinen die gewonnenen Minuten.

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

Props

Die Schnittstelle VsComparisonProps aus components/ui/vs-comparison.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    VsComparisonVariant

    face-offscorecardcost-over-timeworkflow-race

  • eyebrowoptional
    React.ReactNode
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • usPflicht
    VsSide

    Your product

  • themPflicht
    VsSide[]

    The alternatives, neutral names only

  • rowsoptional
    VsRow[]

    face-off

  • scoresoptional
    VsScore[]

    scorecard

  • costsoptional
    VsCostSide[]

    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

  • monthsoptional
    number

    cost-over-time: months on the chart

  • currencyoptional
    string

    ISO currency, e.g. EUR

  • taskoptional
    string

    workflow-race: the task, e.g. Send a quote

  • raceoptional
    VsRaceSide[]

    workflow-race: one entry per side, the first is yours

  • ctaoptional
    { label: string; href: string }
  • noteoptional
    string

    A 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
vsEnglisch vsDeutsch vs.
compareWithEnglisch Compare withDeutsch Vergleichen mit
yesEnglisch IncludedDeutsch Enthalten
noEnglisch Not includedDeutsch Nicht enthalten
mattersEnglisch What matters most to you?Deutsch Was ist dir am wichtigsten?
mattersHintEnglisch Tap what you care about, the totals follow.Deutsch Tippe an, was zählt, die Summen rechnen mit.
overallEnglisch OverallDeutsch Gesamt
outOfEnglisch out of 10Deutsch von 10
teamEnglisch Team sizeDeutsch Teamgröße
peopleEnglisch {n} peopleDeutsch {n} Personen
perMonthEnglisch {v} a monthDeutsch {v} im Monat
afterEnglisch After {n} monthsDeutsch Nach {n} Monaten
keepEnglisch You keepDeutsch Du behältst
saveLongEnglisch over {n} months with a team of {s} peopleDeutsch in {n} Monaten mit einem Team aus {s} Personen
monthEnglisch MonthDeutsch Monat
totalEnglisch TotalDeutsch Summe
taskEnglisch The taskDeutsch Die Aufgabe
startEnglisch Start the raceDeutsch Rennen starten
replayEnglisch Run it againDeutsch Noch einmal
stepsEnglisch {n} stepsDeutsch {n} Schritte
minEnglisch {n} minDeutsch {n} Min.
hoursEnglisch {h} h {m} minDeutsch {h} Std. {m} Min.
doneEnglisch DoneDeutsch Fertig
fasterEnglisch {n} minutes back, every timeDeutsch {n} Minuten zurück, jedes Mal
chartLabelEnglisch Running cost per month for each optionDeutsch 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
components/ui/vs-comparison.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert