Review
Reviews written so a reader can decide. The verdict with score ring and pros and cons, the scores with weights to adjust, the data sheet promised against measured, the winners of a comparison and the long test as a diary.
One of 3 components in Editorial
Variants
Details
What Review brings along, read from the registry and the source.
- Category
- Editorial
- Area
- Content
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/review-article.tsx
- npm packages
- None
- Languages
- English and German, 21 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ReviewArticleProps interface from components/ui/review-article.tsx, with the comments from the code.
variantoptionalReviewArticleVariantverdictscoresspecsrounduptest-log
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeproductoptionalReviewProductproductsoptionalReviewProduct[]roundup
verdictoptionalstringverdict: the sentence
prosoptionalstring[]consoptionalstring[]goodForoptionalstringverdict: who it is for and who should skip
skipIfoptionalstringcriteriaoptionalReviewCriterion[]specsoptionalReviewSpec[]logoptionalReviewLogEntry[]scaleoptionalnumberHighest possible score, default 10
actionoptional{ label: string; href: string }Main action, e.g. to the shop
Built-in texts
Review writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 21 texts
| Key | English | German |
|---|---|---|
| score | English Score | German Wertung |
| of | English out of {m} | German von {m} |
| pros | English What we liked | German Was uns gefallen hat |
| cons | English What could be better | German Was besser sein könnte |
| goodFor | English Buy it if | German Kauf es, wenn |
| skipIf | English Skip it if | German Lass es, wenn |
| verdict | English Our verdict | German Unser Urteil |
| criteria | English Criteria | German Kriterien |
| weight | English Weight | German Gewicht |
| yourScore | English Your score | German Deine Wertung |
| ourScore | English Our weighting | German Unsere Gewichtung |
| reset | English Back to our weighting | German Zurück zu unserer Gewichtung |
| claimed | English Maker says | German Laut Hersteller |
| measured | English We measured | German Gemessen |
| better | English better than promised | German besser als versprochen |
| worse | English below the promise | German unter dem Versprechen |
| same | English as promised | German wie versprochen |
| onlyDiff | English Only deviations | German Nur Abweichungen |
| all | English All values | German Alle Werte |
| log | English Test diary | German Testtagebuch |
| since | English since the start | German seit Beginn |
Accessibility
Read from the source of the component.
- Sets aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- Uses the roles group and img.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- With reduced motion (prefers-reduced-motion) it stays still. 3 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, 649 lines
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Magnetic, Parallax, Stagger } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Slider } from '@/components/ui/slider';
import { Eyebrow } from '@/components/ui/section';
/*
* Reviews and tests, written so a reader can decide. Scores are numbers,
* so every variant can draw, weigh and compare them.
*
* verdict the result first: score ring, the verdict in one sentence,
* pros and cons, who should buy it and who should not
* scores the score by criterion; readers move the weights to their own
* priorities and the total follows
* specs the data sheet, promised against measured, deviations marked
* roundup the winners of a comparison test, each with its award
* test-log the long test as a diary, the score changing over the months
*/
export type ReviewArticleVariant = 'verdict' | 'scores' | 'specs' | 'roundup' | 'test-log';
export interface ReviewProduct {
name: string;
media?: React.ReactNode;
/** Total score */
score?: number;
/** As shown, e.g. €3,490 */
price?: string;
href?: string;
/** roundup: e.g. Best overall */
award?: string;
/** roundup: one sentence why */
reason?: string;
/** roundup: two or three facts */
facts?: string[];
}
export interface ReviewCriterion {
label: string;
score: number;
/** Weight in percent, the weights add up to 100 */
weight: number;
note?: string;
}
export interface ReviewSpec {
group?: string;
label: string;
/** What the maker says */
claimed?: string;
/** What we measured */
measured: string;
/** Mark the measured value: better, worse or same */
verdict?: 'better' | 'worse' | 'same';
}
export interface ReviewLogEntry {
/** e.g. Week 1 */
when: string;
title: string;
text: string;
score?: number;
/** km, hours, uses, as shown */
mileage?: string;
}
export interface ReviewArticleProps {
variant?: ReviewArticleVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
product?: ReviewProduct;
/** roundup */
products?: ReviewProduct[];
/** verdict: the sentence */
verdict?: string;
pros?: string[];
cons?: string[];
/** verdict: who it is for and who should skip */
goodFor?: string;
skipIf?: string;
criteria?: ReviewCriterion[];
specs?: ReviewSpec[];
log?: ReviewLogEntry[];
/** Highest possible score, default 10 */
scale?: number;
/** Main action, e.g. to the shop */
action?: { label: string; href: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
score: 'Score',
of: 'out of {m}',
pros: 'What we liked',
cons: 'What could be better',
goodFor: 'Buy it if',
skipIf: 'Skip it if',
verdict: 'Our verdict',
criteria: 'Criteria',
weight: 'Weight',
yourScore: 'Your score',
ourScore: 'Our weighting',
reset: 'Back to our weighting',
claimed: 'Maker says',
measured: 'We measured',
better: 'better than promised',
worse: 'below the promise',
same: 'as promised',
onlyDiff: 'Only deviations',
all: 'All values',
log: 'Test diary',
since: 'since the start',
},
de: {
score: 'Wertung',
of: 'von {m}',
pros: 'Was uns gefallen hat',
cons: 'Was besser sein könnte',
goodFor: 'Kauf es, wenn',
skipIf: 'Lass es, wenn',
verdict: 'Unser Urteil',
criteria: 'Kriterien',
weight: 'Gewicht',
yourScore: 'Deine Wertung',
ourScore: 'Unsere Gewichtung',
reset: 'Zurück zu unserer Gewichtung',
claimed: 'Laut Hersteller',
measured: 'Gemessen',
better: 'besser als versprochen',
worse: 'unter dem Versprechen',
same: 'wie versprochen',
onlyDiff: 'Nur Abweichungen',
all: 'Alle Werte',
log: 'Testtagebuch',
since: 'seit Beginn',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Shared pieces */
/* ------------------------------------------------------------------ */
function useScore() {
const locale = useLocale();
const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
return (n: number) => nf.format(n);
}
/** A ring that fills to the score once it scrolls into view */
function ScoreRing({ score, scale, size = 120, label }: { score: number; scale: number; size?: number; label: string }) {
const fmt = useScore();
const ref = useRef<HTMLDivElement>(null);
const [on, setOn] = useState(true);
useEffect(() => {
const el = ref.current;
if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
setOn(false);
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) {
setOn(true);
io.disconnect();
}
}, { threshold: 0.4 });
io.observe(el);
return () => io.disconnect();
}, []);
const R = 44;
const C = 2 * Math.PI * R;
return (
<div ref={ref} className="relative grid shrink-0 place-items-center" style={{ width: size, height: size }} role="img" aria-label={label}>
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full -rotate-90" aria-hidden>
<circle cx="50" cy="50" r={R} fill="none" stroke="var(--border)" strokeWidth="6" />
<circle cx="50" cy="50" r={R} fill="none" stroke="var(--primary)" strokeWidth="6" strokeLinecap="round" strokeDasharray={round(C)} strokeDashoffset={round(C * (1 - (on ? score / scale : 0)))} className="transition-[stroke-dashoffset] duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none" />
</svg>
<span className="text-center leading-none" aria-hidden>
<span className="block font-semibold tabular-nums tracking-[-0.03em] text-foreground" style={{ fontSize: round(size * 0.3) }}>
{fmt(score)}
</span>
<span className="mt-1 block text-[12px] text-muted-foreground">/ {scale}</span>
</span>
</div>
);
}
function Picture({ media, aspect = '4 / 3', className, parallax }: { media?: React.ReactNode; aspect?: string; className?: string; parallax?: boolean }) {
if (!media) return null;
return (
<div data-clip-ok className={cn('relative overflow-hidden rounded-2xl bg-muted', className)} style={{ aspectRatio: aspect }}>
{parallax ? (
<Parallax speed={36} className="size-full scale-[1.1] [&>*]:size-full">
{media}
</Parallax>
) : (
<div className="size-full [&>*]:size-full">{media}</div>
)}
</div>
);
}
function Head({ eyebrow, title, description, aside }: { eyebrow?: React.ReactNode; title?: React.ReactNode; description?: React.ReactNode; aside?: React.ReactNode }) {
if (!title && !eyebrow) return null;
return (
<div className="mb-10 flex flex-col gap-6 sm:mb-12 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[42rem]">
{eyebrow && <Eyebrow className="kit-rise">{eyebrow}</Eyebrow>}
{title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{title}</h2>}
{description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{description}</p>}
</div>
{aside}
</div>
);
}
/* ------------------------------------------------------------------ */
/* verdict */
/* ------------------------------------------------------------------ */
function Verdict(p: ReviewArticleProps & { t: Texts }) {
const { t } = p;
const scale = p.scale ?? 10;
const fmt = useScore();
const pr = p.product;
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<Reveal effect="rise" className="kit-card overflow-hidden">
<div className="grid grid-cols-[minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<div className="relative border-b border-border lg:border-b-0 lg:border-r">
<div data-clip-ok className="relative aspect-[4/3] overflow-hidden bg-muted lg:aspect-auto lg:h-full">
<Parallax speed={36} className="absolute inset-0 scale-[1.1] [&>*]:size-full">
{pr?.media}
</Parallax>
</div>
</div>
<div className="flex flex-col p-6 sm:p-8">
<div className="flex items-start gap-5 sm:gap-6">
{pr?.score !== undefined && <ScoreRing score={pr.score} scale={scale} size={112} label={`${t.score} ${fmt(pr.score)} ${t.of.replace('{m}', String(scale))}`} />}
<div className="min-w-0">
<p className="text-[13px] font-semibold uppercase tracking-[0.08em] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{t.verdict}</p>
<h3 className="mt-1.5 text-balance text-[22px] font-semibold leading-[1.25] tracking-[-0.012em] text-foreground sm:text-[24px]">{pr?.name}</h3>
{pr?.price && <p className="mt-1 text-[15px] tabular-nums text-muted-foreground">{pr.price}</p>}
</div>
</div>
{p.verdict && <p className="mt-6 text-pretty text-[18px] font-medium leading-[1.55] text-foreground sm:text-[19px]">{p.verdict}</p>}
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 sm:grid-cols-2">
{[
{ list: p.pros ?? [], head: t.pros, icon: 'plus' as const, tone: 'text-success-foreground bg-[color-mix(in_oklab,var(--success)_14%,transparent)]' },
{ list: p.cons ?? [], head: t.cons, icon: 'minus' as const, tone: 'text-destructive bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)]' },
].map((b) => (
<div key={b.head}>
<p className="text-[13.5px] font-semibold text-foreground">{b.head}</p>
<Stagger as="ul" step={70} className="mt-3 space-y-2.5">
{b.list.map((x, i) => (
<li key={i} className="flex gap-2.5 text-[14.5px] leading-[1.5] text-foreground">
<span className={cn('mt-px grid size-5 shrink-0 place-items-center rounded-full', b.tone)} aria-hidden>
<Icon name={b.icon} size={12} strokeWidth={2.4} />
</span>
<span className="text-pretty">{x}</span>
</li>
))}
</Stagger>
</div>
))}
</div>
{(p.goodFor || p.skipIf || p.action) && (
<div className="mt-auto pt-7">
<dl className="grid grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-2">
{p.goodFor && (
<div className="bg-surface p-4">
<dt className="text-[12.5px] font-semibold text-foreground">{t.goodFor}</dt>
<dd className="mt-1 text-pretty text-[14px] leading-[1.5] text-muted-foreground">{p.goodFor}</dd>
</div>
)}
{p.skipIf && (
<div className="bg-surface p-4">
<dt className="text-[12.5px] font-semibold text-foreground">{t.skipIf}</dt>
<dd className="mt-1 text-pretty text-[14px] leading-[1.5] text-muted-foreground">{p.skipIf}</dd>
</div>
)}
</dl>
{p.action && (
<Magnetic className="mt-5 flex w-full sm:inline-flex sm:w-auto">
<Button href={p.action.href} icon="arrow-up-right" className="w-full sm:w-auto">
{p.action.label}
</Button>
</Magnetic>
)}
</div>
)}
</div>
</div>
</Reveal>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* scores */
/* ------------------------------------------------------------------ */
function Scores(p: ReviewArticleProps & { t: Texts }) {
const { t } = p;
const locale = useLocale();
const scale = p.scale ?? 10;
const fmt = useScore();
const criteria = p.criteria ?? [];
const base = useMemo(() => criteria.map((c) => c.weight), [criteria]);
const [w, setW] = useState<number[]>(base);
useEffect(() => setW(base), [base]);
const sum = w.reduce((a, b) => a + b, 0) || 1;
const total = criteria.reduce((a, c, i) => a + c.score * (w[i] ?? 0), 0) / sum;
const ours = criteria.reduce((a, c, i) => a + c.score * base[i], 0) / (base.reduce((a, b) => a + b, 0) || 1);
const changed = w.some((x, i) => x !== base[i]);
const [ref, inView] = useSeen<HTMLUListElement>();
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,20rem)] lg:gap-14">
<ul ref={ref} className="divide-y divide-border border-y border-border">
{criteria.map((c, i) => (
<li key={c.label} className="py-5">
<div className="flex items-center justify-between gap-4">
<h3 className="min-w-0 text-[16.5px] font-semibold text-foreground">{c.label}</h3>
<span className="flex shrink-0 items-center gap-3">
<span className="h-2.5 w-24 overflow-hidden rounded-full bg-track sm:w-40" aria-hidden>
<span className="block h-full origin-left rounded-full bg-[color-mix(in_oklab,var(--foreground)_80%,var(--background))] transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(c.score / scale, 3) : 0})`, transitionDelay: `${i * 80}ms` }} />
</span>
<span className="w-9 text-right text-[18px] font-semibold tabular-nums text-foreground">{fmt(c.score)}</span>
</span>
</div>
{c.note && <p className="mt-2.5 text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">{c.note}</p>}
<Slider
className="mt-3"
label={t.weight}
min={0}
max={50}
step={5}
value={w[i] ?? 0}
onValueChange={(v: number) => setW((ws) => ws.map((x, k) => (k === i ? v : x)))}
format={() => `${round(((w[i] ?? 0) / sum) * 100, 0)}${locale === 'de' ? ' %' : '%'}`}
/>
</li>
))}
</ul>
{/* Phones and tablets: the total rides along at the bottom while the weights move */}
<div className="sticky bottom-3 z-10 flex items-center gap-4 rounded-2xl border border-border bg-[color-mix(in_oklab,var(--card)_92%,transparent)] p-3 pr-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-md lg:hidden">
<ScoreRing score={round(total, 1)} scale={scale} size={64} label={`${t.score} ${fmt(total)} ${t.of.replace('{m}', String(scale))}`} />
<p className="min-w-0 flex-1 text-[14px] leading-snug">
<span className="block font-semibold text-foreground">{changed ? t.yourScore : t.ourScore}</span>
{changed && (
<span className="text-muted-foreground">
{t.ourScore} <span className="tabular-nums">{fmt(ours)}</span>
</span>
)}
</p>
{changed && (
<button type="button" onClick={() => setW(base)} aria-label={t.reset} className="grid size-11 shrink-0 place-items-center rounded-lg border border-border text-foreground transition-colors hover:bg-accent">
<Icon name="rotate-ccw" size={16} />
</button>
)}
</div>
<div className="hidden lg:sticky lg:top-10 lg:block lg:self-start">
<div className="kit-card flex flex-col items-center p-6 text-center sm:p-8">
<p className="text-[13px] font-semibold text-muted-foreground">{changed ? t.yourScore : t.ourScore}</p>
<div className="mt-4">
<ScoreRing score={round(total, 1)} scale={scale} size={150} label={`${t.score} ${fmt(total)} ${t.of.replace('{m}', String(scale))}`} />
</div>
<p className="mt-4 text-[14px] text-muted-foreground" aria-live="polite">
{changed ? (
<>
{t.ourScore} <span className="font-semibold tabular-nums text-foreground">{fmt(ours)}</span>
</>
) : (
p.product?.name
)}
</p>
<button type="button" onClick={() => setW(base)} disabled={!changed} className="mt-4 inline-flex min-h-11 items-center gap-2 rounded-lg px-3 text-[14px] font-medium text-foreground transition-[background-color,opacity] hover:bg-accent disabled:pointer-events-none disabled:opacity-0">
<Icon name="rotate-ccw" size={15} />
{t.reset}
</button>
</div>
</div>
</div>
</div>
</section>
);
}
function useSeen<T extends Element>() {
const ref = useRef<T>(null);
const [v, setV] = useState(true);
useEffect(() => {
const el = ref.current;
if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (el.getBoundingClientRect().top < window.innerHeight * 0.85) return;
setV(false);
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) {
setV(true);
io.disconnect();
}
}, { rootMargin: '0px 0px -15% 0px' });
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, v] as const;
}
/* ------------------------------------------------------------------ */
/* specs */
/* ------------------------------------------------------------------ */
function Specs(p: ReviewArticleProps & { t: Texts }) {
const { t } = p;
const specs = p.specs ?? [];
const [only, setOnly] = useState(false);
const groups = useMemo(() => {
const m = new Map<string, ReviewSpec[]>();
for (const s of specs) m.set(s.group ?? '', [...(m.get(s.group ?? '') ?? []), s]);
return [...m.entries()];
}, [specs]);
const mark = (v?: ReviewSpec['verdict']) =>
v === 'better' ? { icon: 'arrow-up' as const, cls: 'text-success-foreground bg-[color-mix(in_oklab,var(--success)_14%,transparent)]', text: t.better } : v === 'worse' ? { icon: 'arrow-down' as const, cls: 'text-destructive bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)]', text: t.worse } : { icon: 'check' as const, cls: 'text-muted-foreground bg-muted', text: t.same };
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
aside={
<div role="group" className="inline-flex self-start rounded-xl border border-border bg-surface p-1 lg:self-end">
{[false, true].map((v) => (
<button key={String(v)} type="button" aria-pressed={only === v} onClick={() => setOnly(v)} className={cn('min-h-10 rounded-lg px-3.5 text-[14px] font-medium transition-[background-color,color] duration-300 max-sm:min-h-11', only === v ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
{v ? t.onlyDiff : t.all}
</button>
))}
</div>
}
/>
<div className="overflow-hidden rounded-2xl border border-border">
<div className="hidden grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)] gap-4 border-b border-border bg-surface px-5 py-3 text-[12.5px] font-semibold uppercase tracking-[0.06em] text-muted-foreground sm:grid">
<span />
<span>{t.claimed}</span>
<span>{t.measured}</span>
</div>
{groups.map(([g, rows]) => {
const visible = rows.filter((r) => !only || (r.verdict && r.verdict !== 'same'));
if (!visible.length) return null;
return (
<div key={g}>
{g && <p className="border-b border-border bg-[color-mix(in_oklab,var(--surface)_50%,var(--background))] px-5 py-2.5 text-[13px] font-semibold text-foreground">{g}</p>}
<dl>
{visible.map((r) => {
const m = mark(r.verdict);
return (
<div key={r.label} className="kit-rise grid grid-cols-2 gap-x-4 gap-y-1.5 border-b border-border px-5 py-4 last:border-b-0 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)] sm:items-center" style={{ animationDuration: '.5s' } as React.CSSProperties}>
<dt className="col-span-2 text-[15px] font-medium text-foreground sm:col-span-1">{r.label}</dt>
<dd className="flex flex-wrap items-center gap-2 text-[14.5px] tabular-nums text-muted-foreground">
<span className="block w-full text-[12px] sm:hidden">{t.claimed}</span>
{r.claimed ?? '·'}
</dd>
<dd className="flex flex-wrap items-center gap-2 text-[14.5px] font-semibold tabular-nums text-foreground">
<span className="block w-full text-[12px] font-normal text-muted-foreground sm:hidden">{t.measured}</span>
{r.measured}
{r.verdict && (
<span className={cn('inline-flex h-5 items-center gap-1 rounded-md px-1.5 text-[12px] font-medium', m.cls)}>
<Icon name={m.icon} size={12} strokeWidth={2.2} />
<span className="sr-only lg:not-sr-only">{m.text}</span>
</span>
)}
</dd>
</div>
);
})}
</dl>
</div>
);
})}
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* roundup */
/* ------------------------------------------------------------------ */
function Roundup(p: ReviewArticleProps & { t: Texts }) {
const { t } = p;
const scale = p.scale ?? 10;
const fmt = useScore();
const products = p.products ?? [];
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<Stagger step={90} className="grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2 lg:grid-cols-4">
{products.map((x, i) => (
<article key={x.name} className={cn('group/p relative flex flex-col overflow-hidden rounded-2xl border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow,border-color] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-md)]', i === 0 ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))]' : 'border-border hover:border-border-strong')}>
{i === 0 && <span className="kit-beam pointer-events-none absolute inset-0 rounded-[inherit]" aria-hidden />}
<div className="relative overflow-hidden">
<div className="transition-transform duration-700 ease-[var(--ease-kit)] group-hover/p:scale-[1.04]">
<Picture media={x.media} aspect="4 / 3" className="rounded-none" />
</div>
{x.award && (
<span className={cn('absolute left-3 top-3 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12.5px] font-semibold shadow-[var(--shadow-md)]', i === 0 ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--background)_88%,transparent)] text-foreground backdrop-blur')}>
<Icon name={i === 0 ? 'trophy' : 'award'} size={14} />
{x.award}
</span>
)}
</div>
<div className="flex flex-1 flex-col p-5">
<div className="flex items-start justify-between gap-3">
<h3 className="text-pretty text-[18px] font-semibold leading-[1.3] text-foreground">{x.name}</h3>
{x.score !== undefined && (
<span className="shrink-0 rounded-lg bg-muted px-2 py-1 text-[15px] font-semibold tabular-nums text-foreground" aria-label={`${t.score} ${fmt(x.score)} ${t.of.replace('{m}', String(scale))}`}>
{fmt(x.score)}
</span>
)}
</div>
{x.reason && <p className="mt-2 text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">{x.reason}</p>}
{x.facts && (
<ul className="mt-4 space-y-1.5 border-t border-border pt-4">
{x.facts.map((f) => (
<li key={f} className="flex items-center gap-2 text-[13.5px] text-foreground">
<Icon name="check" size={14} className="text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
{f}
</li>
))}
</ul>
)}
<div className="mt-auto flex items-center justify-between gap-3 pt-5">
<span className="text-[16px] font-semibold tabular-nums text-foreground">{x.price}</span>
<a href={x.href ?? '#'} className="inline-flex min-h-11 items-center gap-1.5 text-[14px] font-medium text-foreground transition-colors hover:text-primary">
{p.action?.label}
<Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/p:translate-x-0.5" />
</a>
</div>
</div>
</article>
))}
</Stagger>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* test-log */
/* ------------------------------------------------------------------ */
function TestLog(p: ReviewArticleProps & { t: Texts }) {
const { t } = p;
const scale = p.scale ?? 10;
const fmt = useScore();
const log = p.log ?? [];
const scores = log.map((l) => l.score).filter((x): x is number => x !== undefined);
const lo = Math.min(...scores, scale) - 0.5;
const hi = Math.max(...scores, 0) + 0.3;
const W = 600;
const H = 120;
const pts = log.map((l, i) => ({ x: round((i / Math.max(1, log.length - 1)) * W), y: l.score === undefined ? null : round(H - 14 - ((l.score - lo) / Math.max(0.1, hi - lo)) * (H - 28)) }));
const path = pts.filter((q) => q.y !== null).map((q, i) => `${i ? 'L' : 'M'}${q.x} ${q.y}`).join(' ');
const [ref, inView] = useSeen<HTMLDivElement>();
const delta = scores.length > 1 ? scores[scores.length - 1] - scores[0] : 0;
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<div className="mx-auto max-w-[48rem]">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div ref={ref} className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6">
<div className="flex items-baseline justify-between gap-4">
<p className="text-[13px] font-semibold text-foreground">{t.log}</p>
{scores.length > 1 && (
<p className={cn('text-[13px] font-medium tabular-nums', delta >= 0 ? 'text-success-foreground' : 'text-destructive')}>
{delta >= 0 ? '+' : ''}
{fmt(delta)} {t.since}
</p>
)}
</div>
{/* The line is drawn in a stretched SVG, points and labels sit in HTML so they keep their size on every width */}
<div className="relative mx-3 mt-8 h-28 sm:mx-4">
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible transition-[clip-path] duration-[1600ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ clipPath: inView ? 'inset(-10px -10px -10px -10px)' : 'inset(-10px 100% -10px -10px)' }} aria-hidden>
<path d={path} fill="none" stroke="var(--primary)" strokeWidth="2.5" vectorEffect="non-scaling-stroke" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{pts.map((q, i) =>
q.y === null ? null : (
<span key={i} aria-hidden className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center transition-opacity duration-500" style={{ left: `${round((q.x / W) * 100, 3)}%`, top: `${round((q.y / H) * 100, 3)}%`, opacity: inView ? 1 : 0, transitionDelay: `${300 + i * 200}ms` }}>
<span className="absolute bottom-[calc(100%+4px)] text-[13px] font-semibold tabular-nums text-foreground">{fmt(log[i].score!)}</span>
<span className="size-3 rounded-full border-[2.5px] border-primary bg-card" />
</span>
),
)}
</div>
<div className="relative mx-3 mt-3 h-4 text-[12px] text-muted-foreground sm:mx-4" aria-hidden>
{log.map((l, i) => (
<span key={l.when} className={cn('absolute whitespace-nowrap', i === 0 ? '' : i === log.length - 1 ? '-translate-x-full' : '-translate-x-1/2')} style={{ left: `${round((pts[i].x / W) * 100, 3)}%` }}>
{l.when}
</span>
))}
</div>
</div>
<ol className="relative mt-12 space-y-10 pl-8 sm:pl-10">
<span aria-hidden className="absolute bottom-2 left-[5px] top-2 w-px bg-border" />
{log.map((l, i) => (
<Reveal as="li" key={i} effect="rise" className="relative">
<span aria-hidden className="absolute -left-8 top-[5px] size-[11px] rounded-full border-2 border-primary bg-background sm:-left-10" />
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[13.5px] text-muted-foreground">
<span className="font-semibold text-foreground">{l.when}</span>
{l.mileage && <span className="tabular-nums">{l.mileage}</span>}
{l.score !== undefined && <span className="rounded-md bg-muted px-1.5 py-0.5 text-[12.5px] font-semibold tabular-nums text-foreground">{fmt(l.score)}</span>}
</p>
<h3 className="mt-2 text-pretty text-[20px] font-semibold leading-[1.3] tracking-[-0.01em] text-foreground">{l.title}</h3>
<p className="mt-2 text-pretty text-[16px] leading-[1.7] text-muted-foreground">{l.text}</p>
</Reveal>
))}
</ol>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
export function ReviewArticle(props: ReviewArticleProps) {
const t = useTexts(TEXTS, props.labels);
const { variant = 'verdict' } = props;
if (variant === 'scores') return <Scores {...props} t={t} />;
if (variant === 'specs') return <Specs {...props} t={t} />;
if (variant === 'roundup') return <Roundup {...props} t={t} />;
if (variant === 'test-log') return <TestLog {...props} t={t} />;
return <Verdict {...props} t={t} />;
}Frequent questions
How do I install Review?
With aiw add @ai-website/review-article. The command puts components/ui/review-article.tsx into your project, the code is yours from then on. It brings along button, icon, slider, reveal, motion and section. It needs no further npm packages.
Which variants does Review have?
5 variants, Verdict, Scores with weights, Data sheet, Comparison winners and Long-term test. You pick one with the variant prop, for example variant="scores".
Can I translate the texts of Review?
Yes. Review carries 21 texts of its own in English and German, such as “Score”, “What we liked” and “What could be better”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Review?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Review with aiw_components and install it with aiw_add @ai-website/review-article. Name the variant in your prompt, for example variant="verdict", and the assistant writes your content straight into the props.
Can I adjust Review without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching ReviewArticle code to copy.