Skip to content
UI Kit
Accent color

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

5 variants

Variants

Verdict

The result first. A ring fills to the score, the verdict in one sentence, pros and cons, who should buy it and who should skip it.

Open in Studio

Scores with weights

The score per criterion, bars fill as they scroll in. Readers move the weights to their own priorities, the ring follows and one press goes back.

Open in Studio

Data sheet

What the maker promises next to what we measured, each value marked better, below or as promised. A switch shows only the deviations.

Open in Studio

Comparison winners

The winners of a comparison test as cards, each with its award, score, two facts and price. The overall winner carries a light that circles.

Open in Studio

Long-term test

The long test as a diary. A line draws the score over the months once it scrolls in, each entry with distance, score and what happened.

Open in Studio

Details

What Review brings along, read from the registry and the source.

Category
Editorial
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/review-article.tsx
Brings along
utilskitbuttoniconsliderrevealmotionsection
npm packages
None
Languages
English and German, 21 texts of its own

Props

The ReviewArticleProps interface from components/ui/review-article.tsx, with the comments from the code.

  • variantoptional
    ReviewArticleVariant

    verdictscoresspecsrounduptest-log

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • productoptional
    ReviewProduct
  • productsoptional
    ReviewProduct[]

    roundup

  • verdictoptional
    string

    verdict: the sentence

  • prosoptional
    string[]
  • consoptional
    string[]
  • goodForoptional
    string

    verdict: who it is for and who should skip

  • skipIfoptional
    string
  • criteriaoptional
    ReviewCriterion[]
  • specsoptional
    ReviewSpec[]
  • logoptional
    ReviewLogEntry[]
  • scaleoptional
    number

    Highest 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
scoreEnglish ScoreGerman Wertung
ofEnglish out of {m}German von {m}
prosEnglish What we likedGerman Was uns gefallen hat
consEnglish What could be betterGerman Was besser sein könnte
goodForEnglish Buy it ifGerman Kauf es, wenn
skipIfEnglish Skip it ifGerman Lass es, wenn
verdictEnglish Our verdictGerman Unser Urteil
criteriaEnglish CriteriaGerman Kriterien
weightEnglish WeightGerman Gewicht
yourScoreEnglish Your scoreGerman Deine Wertung
ourScoreEnglish Our weightingGerman Unsere Gewichtung
resetEnglish Back to our weightingGerman Zurück zu unserer Gewichtung
claimedEnglish Maker saysGerman Laut Hersteller
measuredEnglish We measuredGerman Gemessen
betterEnglish better than promisedGerman besser als versprochen
worseEnglish below the promiseGerman unter dem Versprechen
sameEnglish as promisedGerman wie versprochen
onlyDiffEnglish Only deviationsGerman Nur Abweichungen
allEnglish All valuesGerman Alle Werte
logEnglish Test diaryGerman Testtagebuch
sinceEnglish since the startGerman 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
components/ui/review-article.tsx
'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.

Often searched

Jump to

Copied to the clipboard