Schnellvergleich für Produkte
Produkte mit wenigen Klicks vergleichen. Eine Leiste aus dem Raster, eine Tabelle mit Bestwert, ein Duell oder Karten.
Nebeneinander mit wenigen Klicks
Wenn Produkte sich ähneln, macht ein Vergleich den Unterschied sichtbar. Der Schnellvergleich lässt Käufer direkt im Raster auswählen und Produkte mit wenigen Klicks nebeneinander sehen, der Bestwert je Kennzahl ist markiert.
Die „Vergleichsleiste“ steigt von unten auf, sobald eine Karte angehakt ist, mit bis zu drei Plätzen, eine vierte Wahl erklärt die Grenze und der Knopf öffnet den Vergleich als Dialog mit gehaltenem Fokus. Die „Vergleichstabelle“ hält die Merkmalsspalte fest, während sie am Handy seitlich scrollt, markiert den Bestwert je Zeile, und ein Schalter blendet Zeilen aus, in denen alle gleich sind. Das „Direkte Duell“ stellt zwei Produkte gegenüber, jede Seite kann ihr Produkt tauschen, Balken und ein Pokal zeigen, wer bei jeder Kennzahl vorn liegt. „Vergleichskarten“ richten Zeilen über alle Karten aus, Chips wählen die Zeilen und am Handy rasten die Karten einzeln mit Pfeilen und Punkten ein.
Drei Produkte halten einen Vergleich gut lesbar. Blende gleiche Zeilen am Handy aus. Nenn in jeder Zeile die Einheit, dann vergleichen sich Werte auf einen Blick.
Variants
Details
Was Schnellvergleich mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Products
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/quick-compare.tsx
- Bringt mit
- utilskitbuttoniconproduct-image
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 30 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle QuickCompareProps aus components/ui/quick-compare.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Schnellvergleich selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 30 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| compare | Englisch Compare | Deutsch Vergleichen |
| compareNow | Englisch Compare {n} | Deutsch {n} vergleichen |
| tray | Englisch Compare list | Deutsch Vergleichsliste |
| clear | Englisch Clear | Deutsch Leeren |
| remove | Englisch Remove {name} | Deutsch {name} entfernen |
| empty | Englisch Add a product | Deutsch Produkt hinzufügen |
| maxed | Englisch Up to {n} products at once. Remove one to add another. | Deutsch Bis zu {n} Produkte auf einmal. Entferne eines, um ein anderes hinzuzufügen. |
| needTwo | Englisch Pick one more to compare | Deutsch Wähl noch eins zum Vergleichen |
| close | Englisch Close comparison | Deutsch Vergleich schließen |
| spec | Englisch Feature | Deutsch Merkmal |
| price | Englisch Price | Deutsch Preis |
| rating | Englisch Rating | Deutsch Bewertung |
| yes | Englisch Yes | Deutsch Ja |
| no | Englisch No | Deutsch Nein |
| best | Englisch Best | Deutsch Bester Wert |
| onlyDiff | Englisch Only differences | Deutsch Nur Unterschiede |
| hiddenRows | Englisch {n} equal rows hidden | Deutsch {n} gleiche Zeilen ausgeblendet |
| vs | Englisch vs | Deutsch vs. |
| swap | Englisch Swap sides | Deutsch Seiten tauschen |
| pickLeft | Englisch Product on the left | Deutsch Produkt links |
| pickRight | Englisch Product on the right | Deutsch Produkt rechts |
| wins | Englisch {name} leads in {n} of {total} | Deutsch {name} liegt in {n} von {total} vorn |
| tie | Englisch Even in {n} of {total} | Deutsch Gleichstand in {n} von {total} |
| show | Englisch Rows to show | Deutsch Angezeigte Zeilen |
| add | Englisch Add to cart | Deutsch In den Warenkorb |
| addShort | Englisch Add | Deutsch In den Korb |
| view | Englisch View product | Deutsch Zum Produkt |
| previous | Englisch Previous product | Deutsch Vorheriges Produkt |
| next | Englisch Next product | Deutsch Nächstes Produkt |
| of | Englisch {n} of {total} | Deutsch {n} von {total} |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Schnellvergleich reagiert selbst auf die Tasten Escape und Tab.
- Setzt aria-describedby, aria-label, aria-labelledby, aria-live und aria-modal, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen dialog, region, status und switch.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 659 Zeilen
'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} />;
}Häufige Fragen
Wie installiere ich Schnellvergleich?
Mit aiw add @ai-website/quick-compare. Der Befehl legt die Datei components/ui/quick-compare.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon und product-image. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Schnellvergleich?
4 Varianten, Vergleichsleiste, Vergleichstabelle, Direktes Duell und Vergleichskarten. Du wählst sie mit der Prop variant, zum Beispiel variant="table".
Kann ich die Texte von Schnellvergleich übersetzen?
Ja. Schnellvergleich bringt 30 eigene Texte auf Deutsch und Englisch mit, etwa „Vergleichen“, „Vergleichsliste“ und „Leeren“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Schnellvergleich einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Schnellvergleich mit aiw_components und installieren sie mit aiw_add @ai-website/quick-compare. Nenn im Prompt die Variante, etwa variant="tray", dann schreibt der Assistent deine Inhalte direkt in die Props.