Skip to content
UI Kit
Accent color

Quick product comparison

Compare products in a few taps. A tray from the grid, a table with the best value marked, two products head to head or cards.

4 variantsOne of 4 components in Products

Side by side in a few taps

When products look alike, a comparison makes the difference visible. Quick compare lets shoppers pick right from the grid and see products side by side in a few taps, with the best value per figure marked.

Compare tray rises from the bottom once a card is ticked, with up to three slots, a fourth pick explains the limit and the button opens the comparison as a dialog with focus kept inside. Comparison table keeps the feature column fixed while it scrolls sideways on phones, marks the best value per row and a switch hides the rows where all are equal. Head to head sets two products face to face with a vs between them, each side can pick another product and bars with a trophy show who leads in every figure. Compare cards line up rows across the cards, chips choose the rows and on phones the cards snap one by one with arrows and dots.

Three products keep a comparison easy to read. Hide equal rows on phones. Name the unit in every row, then values compare at a glance.

Variants

Compare trayNew in 1.2

Tick "Compare" on any card and a tray rises from the bottom with up to three slots. A fourth pick explains the limit, the button opens the comparison as a dialog with focus kept inside.

Comparison tableNew in 1.2

All products in one table with the feature column fixed while it scrolls sideways on phones. The best value per row is marked, a switch hides the rows where all are equal.

Head to headNew in 1.2

Two products face to face with a vs between them. Each side can pick another product, the swap button turns the sides, bars and a trophy show who leads in every figure.

Compare cardsNew in 1.2

One card per product with rows that line up. Chips choose which rows to show, on phones the cards snap one by one with arrows and dots, on desktop they stand side by side.

Details

What Quick compare brings along, read from the registry and the source.

Category
Products
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/quick-compare.tsx
Brings along
utilskitbuttoniconproduct-image
npm packages
None
Languages
English and German, 30 texts of its own
Studio
Adjust in code

Props

The QuickCompareProps interface from components/ui/quick-compare.tsx, with the comments from the code.

  • variantoptional
    QuickCompareVariant

    traytablehead-to-headcards

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • productsoptional
    CompareProduct[]
  • specsoptional
    CompareSpec[]
  • maxoptional
    number

    tray: most products at once

  • defaultPickedoptional
    string[]

    Picked on load (tray, head-to-head)

  • defaultSpecsoptional
    string[]

    cards: spec keys shown on load

  • defaultOpenoptional
    boolean

    tray: open the comparison on load, for previews

  • onAddoptional
    (p: CompareProduct) => void
  • currencyoptional
    string

Built-in texts

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

Show all 30 texts
compareEnglish CompareGerman Vergleichen
compareNowEnglish Compare {n}German {n} vergleichen
trayEnglish Compare listGerman Vergleichsliste
clearEnglish ClearGerman Leeren
removeEnglish Remove {name}German {name} entfernen
emptyEnglish Add a productGerman Produkt hinzufügen
maxedEnglish Up to {n} products at once. Remove one to add another.German Bis zu {n} Produkte auf einmal. Entferne eines, um ein anderes hinzuzufügen.
needTwoEnglish Pick one more to compareGerman Wähl noch eins zum Vergleichen
closeEnglish Close comparisonGerman Vergleich schließen
specEnglish FeatureGerman Merkmal
priceEnglish PriceGerman Preis
ratingEnglish RatingGerman Bewertung
yesEnglish YesGerman Ja
noEnglish NoGerman Nein
bestEnglish BestGerman Bester Wert
onlyDiffEnglish Only differencesGerman Nur Unterschiede
hiddenRowsEnglish {n} equal rows hiddenGerman {n} gleiche Zeilen ausgeblendet
vsEnglish vsGerman vs.
swapEnglish Swap sidesGerman Seiten tauschen
pickLeftEnglish Product on the leftGerman Produkt links
pickRightEnglish Product on the rightGerman Produkt rechts
winsEnglish {name} leads in {n} of {total}German {name} liegt in {n} von {total} vorn
tieEnglish Even in {n} of {total}German Gleichstand in {n} von {total}
showEnglish Rows to showGerman Angezeigte Zeilen
addEnglish Add to cartGerman In den Warenkorb
addShortEnglish AddGerman In den Korb
viewEnglish View productGerman Zum Produkt
previousEnglish Previous productGerman Vorheriges Produkt
nextEnglish Next productGerman Nächstes Produkt
ofEnglish {n} of {total}German {n} von {total}

Accessibility

Read from the source of the component.

  • Quick compare handles the keys Escape and Tab itself.
  • Sets aria-describedby, aria-label, aria-labelledby, aria-live and aria-modal, so screen readers announce purpose and state.
  • Uses the roles dialog, region, status and switch.
  • 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).
  • Brings animations of its own, calmly timed and without wobble.
  • 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, 659 lines
components/ui/quick-compare.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { ProductMedia, Rating, ShopMotion, formatNumber, formatPrice } from '@/components/ui/product-image';

/*
 * Compare products quickly.
 *
 *   tray          tick "Compare" on cards, a tray collects up to three and opens the comparison in a dialog
 *   table         all products in one table, the best value per row marked, "only differences" hides the rest
 *   head-to-head  two products face to face with bars for every figure, swap or pick another
 *   cards         one card per product with aligned rows, choose which rows to show, swipe on phones
 *
 * Specs are defined once: label, unit and whether higher or lower is better.
 */

export type QuickCompareVariant = 'tray' | 'table' | 'head-to-head' | 'cards';

export interface CompareSpec {
  key: string;
  label: string;
  unit?: string;
  /** Marks the best value of a row */
  better?: 'higher' | 'lower';
  /** Decimals for numbers */
  digits?: number;
}

export interface CompareProduct {
  id: string;
  name: string;
  href?: string;
  image?: React.ReactNode;
  price: number;
  rating?: number;
  subtitle?: string;
  /** Values by spec key: numbers, text or yes/no */
  specs: Record<string, number | string | boolean>;
}

export interface QuickCompareProps {
  variant?: QuickCompareVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  products?: CompareProduct[];
  specs?: CompareSpec[];
  /** tray: most products at once */
  max?: number;
  /** Picked on load (tray, head-to-head) */
  defaultPicked?: string[];
  /** cards: spec keys shown on load */
  defaultSpecs?: string[];
  /** tray: open the comparison on load, for previews */
  defaultOpen?: boolean;
  onAdd?: (p: CompareProduct) => void;
  currency?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    compare: 'Compare',
    compareNow: 'Compare {n}',
    tray: 'Compare list',
    clear: 'Clear',
    remove: 'Remove {name}',
    empty: 'Add a product',
    maxed: 'Up to {n} products at once. Remove one to add another.',
    needTwo: 'Pick one more to compare',
    close: 'Close comparison',
    spec: 'Feature',
    price: 'Price',
    rating: 'Rating',
    yes: 'Yes',
    no: 'No',
    best: 'Best',
    onlyDiff: 'Only differences',
    hiddenRows: '{n} equal rows hidden',
    vs: 'vs',
    swap: 'Swap sides',
    pickLeft: 'Product on the left',
    pickRight: 'Product on the right',
    wins: '{name} leads in {n} of {total}',
    tie: 'Even in {n} of {total}',
    show: 'Rows to show',
    add: 'Add to cart',
    addShort: 'Add',
    view: 'View product',
    previous: 'Previous product',
    next: 'Next product',
    of: '{n} of {total}',
  },
  de: {
    compare: 'Vergleichen',
    compareNow: '{n} vergleichen',
    tray: 'Vergleichsliste',
    clear: 'Leeren',
    remove: '{name} entfernen',
    empty: 'Produkt hinzufügen',
    maxed: 'Bis zu {n} Produkte auf einmal. Entferne eines, um ein anderes hinzuzufügen.',
    needTwo: 'Wähl noch eins zum Vergleichen',
    close: 'Vergleich schließen',
    spec: 'Merkmal',
    price: 'Preis',
    rating: 'Bewertung',
    yes: 'Ja',
    no: 'Nein',
    best: 'Bester Wert',
    onlyDiff: 'Nur Unterschiede',
    hiddenRows: '{n} gleiche Zeilen ausgeblendet',
    vs: 'vs.',
    swap: 'Seiten tauschen',
    pickLeft: 'Produkt links',
    pickRight: 'Produkt rechts',
    wins: '{name} liegt in {n} von {total} vorn',
    tie: 'Gleichstand in {n} von {total}',
    show: 'Angezeigte Zeilen',
    add: 'In den Warenkorb',
    addShort: 'In den Korb',
    view: 'Zum Produkt',
    previous: 'Vorheriges Produkt',
    next: 'Nächstes Produkt',
    of: '{n} von {total}',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const currencyOf = (locale: Locale, currency?: string) => currency ?? (locale === 'de' ? 'EUR' : 'USD');

/** The index of the best product in a row, or -1 when there is no winner */
function bestOf(spec: CompareSpec, ps: CompareProduct[]) {
  if (!spec.better) return -1;
  const vals = ps.map((p) => p.specs[spec.key]);
  if (vals.some((v) => typeof v !== 'number')) {
    if (vals.every((v) => typeof v === 'boolean')) {
      const yes = vals.filter(Boolean).length;
      return yes === 1 ? vals.findIndex(Boolean) : -1;
    }
    return -1;
  }
  const nums = vals as number[];
  const target = spec.better === 'higher' ? Math.max(...nums) : Math.min(...nums);
  return nums.filter((n) => n === target).length === 1 ? nums.indexOf(target) : -1;
}

const same = (spec: CompareSpec, ps: CompareProduct[]) => ps.every((p) => String(p.specs[spec.key]) === String(ps[0]?.specs[spec.key]));

function Value({ v, spec, t, strong }: { v: number | string | boolean | undefined; spec: CompareSpec; t: Texts; strong?: boolean }) {
  const locale = useLocale();
  if (typeof v === 'boolean')
    return v ? (
      <span className="inline-flex items-center gap-1.5 text-foreground">
        <Icon name="check" size={15} strokeWidth={2.4} className="text-success" />
        {t.yes}
      </span>
    ) : (
      <span className="inline-flex items-center gap-1.5 text-muted-foreground">
        <Icon name="x" size={14} />
        {t.no}
      </span>
    );
  if (typeof v === 'number')
    return (
      <span className={cn('tabular-nums', strong ? 'font-semibold text-foreground' : 'text-foreground')}>
        {formatNumber(locale, v, spec.digits ?? 0)}
        {spec.unit && <span className="ml-0.5 text-muted-foreground">{spec.unit === '%' ? (locale === 'de' ? ' %' : '%') : ` ${spec.unit}`}</span>}
      </span>
    );
  return <span className="text-foreground">{v ?? ''}</span>;
}

function Head({ props }: { props: QuickCompareProps }) {
  if (!props.title) return null;
  return (
    <div className="mb-8 max-w-[40rem] sm:mb-10">
      {props.eyebrow && <p className="mb-3 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
      <h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>
      {props.description && <p className="mt-3 text-pretty text-[16px] text-muted-foreground">{props.description}</p>}
    </div>
  );
}

/** The comparison table, used by table and inside the tray dialog */
function Table({ ps, specs, t, currency, diffOnly }: { ps: CompareProduct[]; specs: CompareSpec[]; t: Texts; currency: string; diffOnly: boolean }) {
  const locale = useLocale();
  const rows = diffOnly ? specs.filter((s) => !same(s, ps)) : specs;
  const hidden = specs.length - rows.length;
  const cols = `minmax(8.5rem,1fr) repeat(${ps.length}, minmax(9.5rem,1fr))`;
  return (
    <div>
      <div className="kit-scrollbar relative overflow-x-auto rounded-2xl border border-border bg-card" tabIndex={0} role="region" aria-label={t.compare}>
        <table className="w-full min-w-max border-collapse text-left text-[14px]" style={{ minWidth: `calc(8.5rem + ${ps.length} * 9.5rem)` }}>
          <colgroup>
            <col style={{ width: '22%' }} />
            {ps.map((p) => (
              <col key={p.id} />
            ))}
          </colgroup>
          <thead>
            <tr className="border-b border-border">
              <th scope="col" className="sticky left-0 z-10 bg-card px-4 py-4 align-bottom text-[12px] font-medium uppercase tracking-[0.06em] text-muted-foreground sm:px-5">
                {t.spec}
              </th>
              {ps.map((p) => (
                <th key={p.id} scope="col" className="px-4 py-4 align-bottom font-normal sm:px-5">
                  <ProductMedia className="mb-3 aspect-square w-full max-w-[8rem] rounded-xl">{p.image}</ProductMedia>
                  <a href={p.href ?? '#'} className="block text-[14px] font-semibold text-foreground hover:underline hover:underline-offset-4 relative after:absolute after:inset-x-0 after:-inset-y-3">
                    {p.name}
                  </a>
                  <span className="mt-0.5 block text-[14px] tabular-nums text-foreground">{formatPrice(locale, p.price, currency)}</span>
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {rows.map((s) => {
              const b = bestOf(s, ps);
              return (
                <tr key={s.key} className="border-b border-border last:border-b-0">
                  <th scope="row" className="sticky left-0 z-10 bg-card px-4 py-3.5 text-[13px] font-medium text-muted-foreground sm:px-5">
                    {s.label}
                  </th>
                  {ps.map((p, i) => (
                    <td key={p.id} className={cn('px-4 py-3.5 transition-colors sm:px-5', i === b && 'bg-[color-mix(in_oklab,var(--success)_7%,var(--card))]')}>
                      <span className="flex flex-wrap items-center gap-2">
                        <Value v={p.specs[s.key]} spec={s} t={t} strong={i === b} />
                        {i === b && (
                          <span className="inline-flex h-5 items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] px-1.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-success-foreground">
                            <Icon name="trophy" size={10} />
                            {t.best}
                          </span>
                        )}
                      </span>
                    </td>
                  ))}
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      {hidden > 0 && <p className="mt-3 text-[12px] text-muted-foreground">{fill(t.hiddenRows, { n: hidden })}</p>}
    </div>
  );
}

function DiffSwitch({ on, setOn, t }: { on: boolean; setOn: (v: boolean) => void; t: Texts }) {
  return (
    <label className="inline-flex min-h-11 cursor-pointer items-center gap-3 text-[14px] font-medium text-foreground">
      <input type="checkbox" role="switch" checked={on} onChange={(e) => setOn(e.target.checked)} className="peer sr-only" />
      <span aria-hidden className={cn('relative h-6 w-10 shrink-0 rounded-full transition-colors duration-300 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)] peer-focus-visible:outline-solid', on ? 'bg-primary' : 'bg-track')}>
        <span className={cn('absolute left-0.5 top-0.5 size-5 rounded-full bg-white shadow-[var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]', on && 'translate-x-4')} />
      </span>
      {t.onlyDiff}
    </label>
  );
}

/* ------------------------------------------------------------------ */
/* tray                                                                */
/* ------------------------------------------------------------------ */

function Tray(props: QuickCompareProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const ps = props.products ?? [];
  const max = props.max ?? 3;
  const [picked, setPicked] = useState<string[]>(props.defaultPicked ?? []);
  const [warn, setWarn] = useState(false);
  const [open, setOpen] = useState(!!props.defaultOpen);
  const [diff, setDiff] = useState(false);
  const dialog = useRef<HTMLDivElement>(null);
  const opener = useRef<HTMLButtonElement>(null);
  const titleId = useId();
  const warnId = useId();
  const chosen = picked.map((id) => ps.find((p) => p.id === id)!).filter(Boolean);
  const toggle = (id: string) => {
    if (picked.includes(id)) {
      setPicked((x) => x.filter((y) => y !== id));
      setWarn(false);
    } else if (picked.length >= max) setWarn(true);
    else {
      setPicked((x) => [...x, id]);
      setWarn(false);
    }
  };
  useEffect(() => {
    if (!open) return;
    const was = document.activeElement as HTMLElement | null;
    requestAnimationFrame(() => dialog.current?.querySelector<HTMLElement>('[data-autofocus]')?.focus());
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') return setOpen(false);
      if (e.key !== 'Tab' || !dialog.current) return;
      const list = [...dialog.current.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input, [tabindex="0"]')];
      if (!list.length) return;
      if (e.shiftKey && document.activeElement === list[0]) { e.preventDefault(); list[list.length - 1].focus(); }
      else if (!e.shiftKey && document.activeElement === list[list.length - 1]) { e.preventDefault(); list[0].focus(); }
    };
    window.addEventListener('keydown', onKey);
    return () => { window.removeEventListener('keydown', onKey); (opener.current ?? was)?.focus(); };
  }, [open]);
  return (
    <section className={cn('relative pb-40 pt-16 sm:pt-20', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} />
        <ul className="grid grid-cols-2 gap-x-3 gap-y-8 sm:gap-x-5 lg:grid-cols-4 lg:gap-x-6">
          {ps.map((p, i) => {
            const on = picked.includes(p.id);
            return (
              <li key={p.id} className="kit-rise group/c" style={{ '--kit-delay': `${i * 60}ms` } as React.CSSProperties}>
                <ProductMedia className={cn('aspect-square rounded-xl ring-2 transition-[box-shadow] duration-300', on ? 'ring-primary' : 'ring-transparent')}>{p.image}</ProductMedia>
                <a href={p.href ?? '#'} className="mt-3 block text-[14px] font-medium text-foreground hover:underline hover:underline-offset-4 relative after:absolute after:inset-x-0 after:-inset-y-3">
                  {p.name}
                </a>
                <div className="mt-0.5 flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
                  <span className="text-[14px] tabular-nums text-foreground">{formatPrice(locale, p.price, currency)}</span>
                  {p.rating ? <Rating value={p.rating} size={12} /> : null}
                </div>
                <label className="mt-2 inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-[13px] font-medium text-foreground">
                  <input type="checkbox" checked={on} onChange={() => toggle(p.id)} className="peer sr-only" aria-describedby={warn && !on ? warnId : undefined} />
                  <span aria-hidden className={cn('grid size-5 place-items-center rounded-[6px] border transition-colors peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)] peer-focus-visible:outline-solid', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
                    {on && <Icon name="check" size={13} strokeWidth={2.6} className="animate-[kitshop-pop_0.3s_var(--ease-kit)]" />}
                  </span>
                  {t.compare}
                </label>
              </li>
            );
          })}
        </ul>
      </div>

      {/* The tray at the foot of the section */}
      <div className={cn('fixed inset-x-0 bottom-0 z-30 transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', chosen.length ? 'translate-y-0' : 'translate-y-full')} aria-hidden={!chosen.length || undefined} inert={!chosen.length}>
        <div className="border-t border-border bg-background/92 shadow-[0_-8px_24px_rgb(0_0_0/0.08)] backdrop-blur-xl">
          <div className="kit-container flex items-center gap-3 py-3 sm:gap-5 sm:py-4">
            <p className="text-[13px] font-medium text-foreground max-md:sr-only">{t.tray}</p>
            <ul className="flex min-w-0 flex-1 gap-2 sm:gap-3" aria-label={t.tray}>
              {Array.from({ length: max }, (_, i) => {
                const p = chosen[i];
                return p ? (
                  <li key={p.id} className="relative flex min-w-0 animate-[kitshop-pop_0.35s_var(--ease-kit)] items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 sm:flex-1 sm:pr-2">
                    <ProductMedia className="size-10 shrink-0 rounded-lg">{p.image}</ProductMedia>
                    <span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground max-sm:hidden">{p.name}</span>
                    <button type="button" onClick={() => toggle(p.id)} aria-label={fill(t.remove, { name: p.name })} className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-foreground text-background shadow-[var(--shadow-sm)] after:absolute after:-inset-2.5 sm:static sm:size-8 sm:bg-transparent sm:text-muted-foreground sm:shadow-none sm:hover:bg-accent sm:hover:text-foreground">
                      <Icon name="x" size={13} />
                    </button>
                  </li>
                ) : (
                  <li key={`e${i}`} className="grid size-[3.25rem] shrink-0 place-items-center rounded-xl border border-dashed border-border-strong text-[12px] text-muted-foreground sm:size-auto sm:h-[3.25rem] sm:flex-1">
                    <span className="max-sm:sr-only">{t.empty}</span>
                    <Icon name="plus" size={15} className="sm:hidden" />
                  </li>
                );
              })}
            </ul>
            <button type="button" onClick={() => { setPicked([]); setWarn(false); }} className="h-11 shrink-0 rounded-md px-2 text-[13px] font-medium text-muted-foreground hover:text-foreground max-sm:hidden">
              {t.clear}
            </button>
            <Button ref={opener} onClick={() => setOpen(true)} disabled={chosen.length < 2} className="shrink-0">
              <Icon name="columns" size={16} />
              {fill(t.compareNow, { n: chosen.length })}
            </Button>
          </div>
          <p id={warnId} role="status" className={cn('kit-container pb-3 text-[12px]', warn ? 'text-warning-foreground' : chosen.length === 1 ? 'text-muted-foreground' : 'sr-only')}>
            {warn ? fill(t.maxed, { n: max }) : chosen.length === 1 ? t.needTwo : ''}
          </p>
        </div>
      </div>

      {open && (
        <>
          <div className="fixed inset-0 z-40 animate-[kitshop-fade_0.25s_var(--ease-kit)] bg-scrim backdrop-blur-[2px]" onClick={() => setOpen(false)} aria-hidden />
          <div ref={dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} className="fixed inset-x-0 bottom-0 z-50 flex max-h-[92dvh] animate-[kitshop-sheet_0.4s_var(--ease-kit)] flex-col rounded-t-3xl border border-border bg-background shadow-[var(--shadow-lg)] sm:inset-x-6 sm:bottom-6 sm:rounded-3xl lg:inset-x-auto lg:left-1/2 lg:w-[min(62rem,calc(100vw-4rem))] lg:-translate-x-1/2">
            <div className="flex items-center justify-between gap-4 border-b border-border px-5 py-3 sm:px-6">
              <h2 id={titleId} className="text-[17px] font-semibold text-foreground">
                {fill(t.compareNow, { n: chosen.length })}
              </h2>
              <div className="flex items-center gap-2">
                <span className="max-sm:hidden">
                  <DiffSwitch on={diff} setOn={setDiff} t={t} />
                </span>
                <button type="button" data-autofocus onClick={() => setOpen(false)} aria-label={t.close} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground">
                  <Icon name="x" size={19} />
                </button>
              </div>
            </div>
            <div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
              <div className="mb-3 sm:hidden">
                <DiffSwitch on={diff} setOn={setDiff} t={t} />
              </div>
              <Table ps={chosen} specs={props.specs ?? []} t={t} currency={currency} diffOnly={diff} />
            </div>
          </div>
        </>
      )}
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* table                                                               */
/* ------------------------------------------------------------------ */

function TableVariant(props: QuickCompareProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const [diff, setDiff] = useState(false);
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
          <Head props={props} />
          <div className="md:mb-10">
            <DiffSwitch on={diff} setOn={setDiff} t={t} />
          </div>
        </div>
        <div className="kit-rise">
          <Table ps={props.products ?? []} specs={props.specs ?? []} t={t} currency={currency} diffOnly={diff} />
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* head-to-head                                                        */
/* ------------------------------------------------------------------ */

function HeadToHead(props: QuickCompareProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const ps = props.products ?? [];
  const specs = props.specs ?? [];
  const [ids, setIds] = useState<[string, string]>([props.defaultPicked?.[0] ?? ps[0]?.id ?? '', props.defaultPicked?.[1] ?? ps[1]?.id ?? '']);
  const [flip, setFlip] = useState(0);
  const base = useId();
  const a = ps.find((p) => p.id === ids[0]);
  const b = ps.find((p) => p.id === ids[1]);
  if (!a || !b) return null;
  const pair = [a, b];
  const score = [0, 0];
  let ties = 0;
  const scored = specs.filter((s) => s.better);
  for (const s of scored) {
    const w = bestOf(s, pair);
    if (w < 0) ties++;
    else score[w]++;
  }
  const lead = score[0] === score[1] ? -1 : score[0] > score[1] ? 0 : 1;
  const pick = (side: 0 | 1, id: string) => setIds((x) => (side === 0 ? [id, x[1] === id ? x[0] : x[1]] : [x[0] === id ? x[1] : x[0], id]));
  const selectClass = 'h-11 w-full cursor-pointer appearance-none truncate rounded-lg border border-input bg-background pl-3.5 pr-9 text-base font-medium text-foreground hover:border-border-strong focus-visible:border-primary sm:text-[14px]';
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} />
        <div className="mx-auto max-w-[56rem]">
          <div className="relative grid grid-cols-2 gap-3 sm:gap-8">
            {pair.map((p, side) => (
              <div key={side} className={cn('flex flex-col', side === 1 && 'items-end text-right')}>
                <div key={p.id + flip} className="w-full animate-[kitshop-zoom_0.45s_var(--ease-kit)]">
                  <ProductMedia className={cn('aspect-square w-full rounded-2xl ring-2 transition-[box-shadow] duration-500', lead === side ? 'ring-primary' : 'ring-transparent')}>{p.image}</ProductMedia>
                </div>
                <label htmlFor={`${base}-${side}`} className="sr-only">
                  {side === 0 ? t.pickLeft : t.pickRight}
                </label>
                <div className="relative mt-3 w-full">
                  <select id={`${base}-${side}`} value={p.id} onChange={(e) => pick(side as 0 | 1, e.target.value)} className={selectClass}>
                    {ps.map((x) => (
                      <option key={x.id} value={x.id}>
                        {x.name}
                      </option>
                    ))}
                  </select>
                  <Icon name="chevrons-up-down" size={15} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
                </div>
                <p className="mt-2 text-[16px] font-semibold tabular-nums text-foreground">{formatPrice(locale, p.price, currency)}</p>
              </div>
            ))}
            <div className="pointer-events-none absolute left-1/2 top-[calc((100%-6.5rem)/2)] -translate-x-1/2 -translate-y-1/2">
              <span className="grid size-12 place-items-center rounded-full border border-border bg-background text-[14px] font-semibold text-foreground shadow-[var(--shadow-lg)] sm:size-14 sm:text-[15px]" aria-hidden>
                {t.vs}
              </span>
            </div>
          </div>
          <div className="mt-4 flex justify-center">
            <Button variant="ghost" iconLeft="arrows-left-right" onClick={() => { setIds(([x, y]) => [y, x]); setFlip((f) => f + 1); }}>
              {t.swap}
            </Button>
          </div>
          <p className="mt-6 text-center text-[15px] font-medium text-foreground" aria-live="polite">
            {lead >= 0 ? fill(t.wins, { name: pair[lead].name, n: score[lead], total: scored.length }) : fill(t.tie, { n: ties, total: scored.length })}
          </p>
          <ul className="mt-8 divide-y divide-border rounded-2xl border border-border bg-card">
            {specs.map((s) => {
              const va = a.specs[s.key];
              const vb = b.specs[s.key];
              const w = bestOf(s, pair);
              const numeric = typeof va === 'number' && typeof vb === 'number';
              const max = numeric ? Math.max(va as number, vb as number) || 1 : 1;
              return (
                <li key={s.key} className="px-4 py-4 sm:px-6">
                  <p className="mb-2 text-center text-[12px] font-medium uppercase tracking-[0.06em] text-muted-foreground">{s.label}</p>
                  <div className="grid grid-cols-2 items-center gap-4 sm:gap-8">
                    {[va, vb].map((v, side) => (
                      <div key={side} className={cn('min-w-0', side === 0 ? 'text-left' : 'text-right')}>
                        <div className={cn('flex items-center gap-2 text-[15px]', side === 1 && 'justify-end')}>
                          {w === side && <Icon name="trophy" size={14} className="shrink-0 text-success" />}
                          <Value v={v} spec={s} t={t} strong={w === side} />
                        </div>
                        {numeric && (
                          <div className={cn('mt-2 flex h-1.5 overflow-hidden rounded-full bg-track', side === 0 && 'justify-end')} aria-hidden>
                            <span className={cn('h-full rounded-full transition-[width] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', w === side ? 'bg-primary' : 'bg-border-strong')} style={{ width: `${round(((v as number) / max) * 100)}%` }} />
                          </div>
                        )}
                      </div>
                    ))}
                  </div>
                </li>
              );
            })}
          </ul>
          <div className="mt-6 grid grid-cols-2 gap-3 sm:gap-8">
            {pair.map((p) => (
              <Button key={p.id} variant="outline" onClick={() => props.onAdd?.(p)} className="w-full">
                <Icon name="bag" size={16} />
                <span className="truncate sm:hidden">{t.addShort}</span>
                <span className="truncate max-sm:hidden">{t.add}</span>
              </Button>
            ))}
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* cards                                                               */
/* ------------------------------------------------------------------ */

function Cards(props: QuickCompareProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const ps = props.products ?? [];
  const specs = props.specs ?? [];
  const [shown, setShown] = useState<string[]>(props.defaultSpecs ?? specs.slice(0, 4).map((s) => s.key));
  const [idx, setIdx] = useState(0);
  const row = useRef<HTMLUListElement>(null);
  const rows = specs.filter((s) => shown.includes(s.key));
  const go = (i: number) => {
    const n = Math.max(0, Math.min(ps.length - 1, i));
    setIdx(n);
    const el = row.current?.children[n] as HTMLElement | undefined;
    row.current?.scrollTo({ left: el ? el.offsetLeft - row.current.offsetLeft : 0, behavior: 'smooth' });
  };
  const onScroll = () => {
    const el = row.current;
    if (!el) return;
    const w = (el.children[0] as HTMLElement | undefined)?.offsetWidth ?? 1;
    setIdx(Math.round(el.scrollLeft / (w + 12)));
  };
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} />
        <fieldset className="mb-6 min-w-0">
          <legend className="mb-2.5 text-[13px] font-medium text-muted-foreground">{t.show}</legend>
          <div className="kit-no-scrollbar relative flex gap-2 overflow-x-auto pb-1">
            {specs.map((s) => {
              const on = shown.includes(s.key);
              return (
                <label key={s.key} className={cn('flex h-10 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3.5 text-[13px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid max-sm:h-11', on ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:border-foreground/50')}>
                  <input type="checkbox" checked={on} onChange={() => setShown((x) => (on ? x.filter((k) => k !== s.key) : [...x, s.key]))} className="sr-only" />
                  {on && <Icon name="check" size={13} strokeWidth={2.4} />}
                  {s.label}
                </label>
              );
            })}
          </div>
        </fieldset>
        <ul ref={row} onScroll={onScroll} className="kit-no-scrollbar relative -mx-1 flex snap-x snap-mandatory gap-3 overflow-x-auto px-1 pb-2 md:grid md:snap-none md:overflow-visible" style={{ gridTemplateColumns: `repeat(${ps.length}, minmax(0, 1fr))` }}>
          {ps.map((p, i) => (
            <li key={p.id} className="kit-rise flex w-[82%] shrink-0 snap-center flex-col rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-sm)] sm:w-[60%] md:w-auto" style={{ '--kit-delay': `${i * 80}ms` } as React.CSSProperties} aria-label={fill(t.of, { n: i + 1, total: ps.length })}>
              <ProductMedia className="aspect-[4/3] rounded-xl">{p.image}</ProductMedia>
              <a href={p.href ?? '#'} className="mt-4 text-[16px] font-semibold text-foreground hover:underline hover:underline-offset-4 relative after:absolute after:inset-x-0 after:-inset-y-3">
                {p.name}
              </a>
              {p.subtitle && <p className="text-[13px] text-muted-foreground">{p.subtitle}</p>}
              <div className="mt-2 flex items-center justify-between gap-2">
                <span className="text-[17px] font-semibold tabular-nums text-foreground">{formatPrice(locale, p.price, currency)}</span>
                {p.rating ? <Rating value={p.rating} size={13} /> : null}
              </div>
              <dl className="mt-4 flex-1 border-t border-border">
                {rows.map((s) => {
                  const b = bestOf(s, ps);
                  return (
                    <div key={s.key} className="flex min-h-11 animate-[kitshop-in_0.35s_var(--ease-kit)] items-center justify-between gap-3 border-b border-border text-[13px] last:border-b-0">
                      <dt className="text-muted-foreground">{s.label}</dt>
                      <dd className="flex items-center gap-1.5 text-right">
                        {b === i && <Icon name="trophy" size={12} className="text-success" />}
                        <Value v={p.specs[s.key]} spec={s} t={t} strong={b === i} />
                      </dd>
                    </div>
                  );
                })}
              </dl>
              <Button onClick={() => props.onAdd?.(p)} variant={i === 0 ? 'primary' : 'outline'} className="mt-4 w-full">
                {t.add}
              </Button>
            </li>
          ))}
        </ul>
        {/* Phone and tablet: dots and arrows under the cards */}
        <div className="mt-4 flex items-center justify-center gap-3 md:hidden">
          <button type="button" onClick={() => go(idx - 1)} disabled={idx <= 0} aria-label={t.previous} className="grid size-11 place-items-center rounded-full border border-border text-foreground disabled:opacity-35">
            <Icon name="chevron-left" size={17} />
          </button>
          <span className="flex gap-1.5" aria-hidden>
            {ps.map((p, i) => (
              <span key={p.id} className={cn('h-1.5 rounded-full transition-[width,background-color] duration-300', i === idx ? 'w-5 bg-foreground' : 'w-1.5 bg-border-strong')} />
            ))}
          </span>
          <span className="sr-only" aria-live="polite">
            {fill(t.of, { n: idx + 1, total: ps.length })}
          </span>
          <button type="button" onClick={() => go(idx + 1)} disabled={idx >= ps.length - 1} aria-label={t.next} className="grid size-11 place-items-center rounded-full border border-border text-foreground disabled:opacity-35">
            <Icon name="chevron-right" size={17} />
          </button>
        </div>
      </div>
    </section>
  );
}

/**
 * Quick product comparison: a tray that collects products from the grid
 * and opens a table, a full table with the best value marked, two products
 * face to face with bars, or cards with chosen rows to swipe through.
 */
export function QuickCompare(props: QuickCompareProps) {
  const locale = useLocale();
  const t = useTexts(TEXTS, props.labels);
  const currency = currencyOf(locale, props.currency);
  const { variant = 'tray' } = props;
  if (variant === 'table') return <TableVariant {...props} t={t} currency={currency} />;
  if (variant === 'head-to-head') return <HeadToHead {...props} t={t} currency={currency} />;
  if (variant === 'cards') return <Cards {...props} t={t} currency={currency} />;
  return <Tray {...props} t={t} currency={currency} />;
}

Frequent questions

How do I install Quick compare?

With aiw add @ai-website/quick-compare. The command puts components/ui/quick-compare.tsx into your project, the code is yours from then on. It brings along button, icon and product-image. It needs no further npm packages.

Which variants does Quick compare have?

4 variants, Compare tray, Comparison table, Head to head and Compare cards. You pick one with the variant prop, for example variant="table".

Can I translate the texts of Quick compare?

Yes. Quick compare carries 30 texts of its own in English and German, such as “Compare”, “Compare list” and “Clear”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Quick compare?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Quick compare with aiw_components and install it with aiw_add @ai-website/quick-compare. Name the variant in your prompt, for example variant="tray", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard