Skip to content
UI Kit
Accent color

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.

4 variantsOne of 2 components in Comparison

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

Face-offNew in 1.2

Two sides face each other with a medallion between them, the question sits in the middle of every row. Pick the alternative above, the marks slide in from both edges.

Weighted scorecardNew in 1.2

Scores per criterion as bars that grow in, totals on top. Visitors tick what matters to them and the totals follow at once.

Cost over timeNew in 1.2

Visitors set their team size, the lines draw the running cost month by month and the gap to the cheapest alternative lights up with the amount kept.

Workflow raceNew in 1.2

The same task done both ways, side by side. Once in view a clock runs, steps tick off at their real pace and the minutes kept appear at the finish.

Details

What Versus comparison brings along, read from the registry and the source.

Category
Comparison
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

Props

The VsComparisonProps interface from components/ui/vs-comparison.tsx, with the comments from the code.

  • variantoptional
    VsComparisonVariant

    face-offscorecardcost-over-timeworkflow-race

  • eyebrowoptional
    React.ReactNode
  • titlerequired
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • usrequired
    VsSide

    Your product

  • themrequired
    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

Built-in texts

Versus comparison writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 25 texts
vsEnglish vsGerman vs.
compareWithEnglish Compare withGerman Vergleichen mit
yesEnglish IncludedGerman Enthalten
noEnglish Not includedGerman Nicht enthalten
mattersEnglish What matters most to you?German Was ist dir am wichtigsten?
mattersHintEnglish Tap what you care about, the totals follow.German Tippe an, was zählt, die Summen rechnen mit.
overallEnglish OverallGerman Gesamt
outOfEnglish out of 10German von 10
teamEnglish Team sizeGerman Teamgröße
peopleEnglish {n} peopleGerman {n} Personen
perMonthEnglish {v} a monthGerman {v} im Monat
afterEnglish After {n} monthsGerman Nach {n} Monaten
keepEnglish You keepGerman Du behältst
saveLongEnglish over {n} months with a team of {s} peopleGerman in {n} Monaten mit einem Team aus {s} Personen
monthEnglish MonthGerman Monat
totalEnglish TotalGerman Summe
taskEnglish The taskGerman Die Aufgabe
startEnglish Start the raceGerman Rennen starten
replayEnglish Run it againGerman Noch einmal
stepsEnglish {n} stepsGerman {n} Schritte
minEnglish {n} minGerman {n} Min.
hoursEnglish {h} h {m} minGerman {h} Std. {m} Min.
doneEnglish DoneGerman Fertig
fasterEnglish {n} minutes back, every timeGerman {n} Minuten zurück, jedes Mal
chartLabelEnglish Running cost per month for each optionGerman 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
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} />;
}

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.

Often searched

Jump to

Copied to the clipboard