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.
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
Details
What Quick compare brings along, read from the registry and the source.
- Category
- Products
- Area
- E-Commerce
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The QuickCompareProps interface from components/ui/quick-compare.tsx, with the comments from the code.
variantoptionalQuickCompareVarianttraytablehead-to-headcards
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringproductsoptionalCompareProduct[]specsoptionalCompareSpec[]maxoptionalnumbertray: most products at once
defaultPickedoptionalstring[]Picked on load (tray, head-to-head)
defaultSpecsoptionalstring[]cards: spec keys shown on load
defaultOpenoptionalbooleantray: open the comparison on load, for previews
onAddoptional(p: CompareProduct) => voidcurrencyoptionalstring
Built-in texts
Quick compare writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 30 texts
| Key | English | German |
|---|---|---|
| compare | English Compare | German Vergleichen |
| compareNow | English Compare {n} | German {n} vergleichen |
| tray | English Compare list | German Vergleichsliste |
| clear | English Clear | German Leeren |
| remove | English Remove {name} | German {name} entfernen |
| empty | English Add a product | German Produkt hinzufügen |
| maxed | English 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. |
| needTwo | English Pick one more to compare | German Wähl noch eins zum Vergleichen |
| close | English Close comparison | German Vergleich schließen |
| spec | English Feature | German Merkmal |
| price | English Price | German Preis |
| rating | English Rating | German Bewertung |
| yes | English Yes | German Ja |
| no | English No | German Nein |
| best | English Best | German Bester Wert |
| onlyDiff | English Only differences | German Nur Unterschiede |
| hiddenRows | English {n} equal rows hidden | German {n} gleiche Zeilen ausgeblendet |
| vs | English vs | German vs. |
| swap | English Swap sides | German Seiten tauschen |
| pickLeft | English Product on the left | German Produkt links |
| pickRight | English Product on the right | German Produkt rechts |
| wins | English {name} leads in {n} of {total} | German {name} liegt in {n} von {total} vorn |
| tie | English Even in {n} of {total} | German Gleichstand in {n} von {total} |
| show | English Rows to show | German Angezeigte Zeilen |
| add | English Add to cart | German In den Warenkorb |
| addShort | English Add | German In den Korb |
| view | English View product | German Zum Produkt |
| previous | English Previous product | German Vorheriges Produkt |
| next | English Next product | German Nächstes Produkt |
| of | English {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
'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.