# Review cards

> Five review cards — rating summary, standout quote, review composer, compact list and photo review — with count-ups, star pops, rolling vote counts and a shared-element photo viewer.

- Collection: Examples
- Tier: free
- Docs: https://geturui.com/docs/review-cards
- Install: `npx shadcn@latest add @geturui/review-cards`
- Dependencies: motion, lucide-react

## Variants

### Rating summary

Average that counts up over a star histogram whose bars grow in and filter the list.

Install: `npx shadcn@latest add @geturui/review-cards-summary`

```tsx
import { RatingSummaryCard } from "@/components/examples/review-cards/summary";

export function Reviews() {
  return <RatingSummaryCard counts={[1284, 412, 96, 31, 18]} onFilterChange={(stars) => setFilter(stars)} onWriteReview={openComposer} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| counts | `[number, number, number, number, number]` | [1284, 412, 96, 31, 18] | Review counts from 5 stars down to 1. |
| title / subtitle | `string` | — | Heading and product line. |
| defaultFilter | `number | null` | null | Initially selected star filter. |
| onFilterChange | `(stars: number | null) => void` | — | Called when a bar is toggled. |
| onWriteReview | `() => void` | — | Primary button handler. |
| className | `string` | — | Extra classes on the card. |

### Standout quote

Single verified review with popping stars, a body that springs open with Read more, and a helpful toggle.

Install: `npx shadcn@latest add @geturui/review-cards-quote`

```tsx
import { ReviewQuoteCard } from "@/components/examples/review-cards/quote";

export function Featured() {
  return <ReviewQuoteCard author="Noor Halabi" rating={5} headline="Quiet, sturdy, worth it" body={review.text} verified />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| author / meta / initials / date | `string` | — | Reviewer details. |
| rating | `number` | 5 | Stars out of five. |
| headline / body | `string` | — | Review title and text; blank lines split paragraphs. |
| verified | `boolean` | true | Show the verified buyer badge. |
| helpful | `number` | 38 | Existing helpful votes. |
| previewLength | `number` | 180 | Characters shown before Read more. |
| onHelpfulChange | `(helpful: boolean) => void` | — | Called when the vote toggles. |
| className | `string` | — | Extra classes on the card. |

### Write a review

Star radio group with hover preview and spring pop, quick tags, counted text box and a thank-you crossfade.

Install: `npx shadcn@latest add @geturui/review-cards-write`

```tsx
import { WriteReviewCard } from "@/components/examples/review-cards/write";

export function Composer() {
  return <WriteReviewCard product="Arden Wool Throw" onSubmit={(r) => saveReview(r)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| product | `string` | "Arden Wool Throw" | What is being reviewed. |
| labels | `[string × 5]` | — | Words shown for 1–5 stars. |
| tags | `string[]` | — | Quick tags the reviewer can toggle. |
| maxLength | `number` | 400 | Character limit for the text. |
| onSubmit | `(review: { rating, text, tags }) => void` | — | Called on post. |
| className | `string` | — | Extra classes on the card. |

### Compact list

Dense rows with star lines and optimistic helpful votes whose counts roll.

Install: `npx shadcn@latest add @geturui/review-cards-compact`

```tsx
import { CompactReviewList } from "@/components/examples/review-cards/compact";

export function Latest() {
  return <CompactReviewList reviews={reviews} onVote={(id, vote) => sendVote(id, vote)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| title | `string` | "Most helpful reviews" | List heading. |
| reviews | `CompactReview[]` | — | { id, author, initials, rating, date, text, up, down }. |
| onVote | `(id: string, vote: "up" | "down" | null) => void` | — | Called when a vote changes. |
| className | `string` | — | Extra classes on the card. |

### Photo review

Review with a drawn photo strip; a thumbnail grows into an in-card viewer with arrow keys and Escape.

Install: `npx shadcn@latest add @geturui/review-cards-photos`

```tsx
import { PhotoReviewCard } from "@/components/examples/review-cards/photos";

export function WithPhotos() {
  return <PhotoReviewCard author="Ines Duarte" rating={5} photos={[{ alt: "Mug on a table", src: "/reviews/mug.jpg" }]} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| author / initials / date | `string` | — | Reviewer details. |
| rating | `number` | 5 | Stars out of five. |
| title / body | `string` | — | Review content. |
| photos | `ReviewPhoto[]` | — | { alt, src?, scene?, tones? }; without src a drawn scene is used. |
| className | `string` | — | Extra classes on the card. |

## Accessibility

- Star ratings carry a text equivalent (“4.7 out of 5”); the star input is a radio group driven by arrow keys.
- Filters, helpful votes and likes are real buttons with aria-pressed, and counts are announced as text, not just icons.
- Reduced motion removes count-ups, bar growth and the photo zoom while keeping every state change.

## Source

### components/examples/review-cards/summary.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { Star, X } from "lucide-react";
import { cn } from "@/lib/utils";

export interface RatingSummaryCardProps {
  /** Review counts per star, index 0 = 5 stars … index 4 = 1 star. */
  counts?: [number, number, number, number, number];
  title?: string;
  /** Short line under the title, e.g. the product name. */
  subtitle?: string;
  /** Initially selected star filter (1–5), or null for all. */
  defaultFilter?: number | null;
  onFilterChange?: (stars: number | null) => void;
  onWriteReview?: () => void;
  className?: string;
}

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";

function Stars({ value, className }: { value: number; className?: string }) {
  return (
    <span className={cn("relative inline-flex", className)} aria-hidden>
      <span className="flex gap-0.5 text-ink-subtle/40">
        {Array.from({ length: 5 }, (_, i) => (
          <Star key={i} className="size-4" fill="currentColor" strokeWidth={0} />
        ))}
      </span>
      <span className="absolute inset-0 flex gap-0.5 overflow-hidden text-volt-ink" style={{ width: `${(value / 5) * 100}%` }}>
        {Array.from({ length: 5 }, (_, i) => (
          <Star key={i} className="size-4 shrink-0" fill="currentColor" strokeWidth={0} />
        ))}
      </span>
    </span>
  );
}

/** Rating summary: the average counts up, the star histogram grows in, and every bar filters the review list below it. */
export function RatingSummaryCard({
  counts = [1284, 412, 96, 31, 18],
  title = "Customer reviews",
  subtitle = "Halden Desk Lamp · Oak",
  defaultFilter = null,
  onFilterChange,
  onWriteReview,
  className,
}: RatingSummaryCardProps) {
  const reduce = useReducedMotion() ?? false;
  const [filter, setFilter] = React.useState<number | null>(defaultFilter);
  const total = counts.reduce((a, b) => a + b, 0);
  const average = total ? counts.reduce((sum, c, i) => sum + c * (5 - i), 0) / total : 0;
  const max = Math.max(...counts, 1);
  const recommend = total ? Math.round(((counts[0] + counts[1]) / total) * 100) : 0;

  const mv = useSpring(reduce ? average : 0, { stiffness: 60, damping: 18 });
  const shown = useTransform(mv, (v) => v.toFixed(1));
  React.useEffect(() => {
    if (reduce) mv.jump(average);
    else mv.set(average);
  }, [average, reduce, mv]);

  function choose(next: number | null) {
    setFilter(next);
    onFilterChange?.(next);
  }

  return (
    <section aria-label={title} className={cn("w-full max-w-[380px] rounded-xl border border-border bg-surface-raised p-5 shadow-sm", className)}>
      <header className="flex items-start justify-between gap-3">
        <div>
          <h3 className="text-[15px] font-semibold text-ink">{title}</h3>
          <p className="text-xs text-ink-subtle">{subtitle}</p>
        </div>
        <span className="rounded-full bg-volt-soft px-2.5 py-1 text-[11px] font-medium text-volt-ink">{recommend}% recommend</span>
      </header>

      <div className="mt-4 flex items-end gap-3">
        <p className="font-display text-[44px] font-semibold leading-none tracking-[-1.5px] text-ink tabular-nums">
          <motion.span aria-hidden>{shown}</motion.span>
          <span className="sr-only">{average.toFixed(1)} out of 5</span>
        </p>
        <div className="pb-1">
          <Stars value={average} />
          <p className="mt-1 text-xs text-ink-muted">Based on {total.toLocaleString("en-US")} reviews</p>
        </div>
      </div>

      <div role="group" aria-label="Filter reviews by rating" className="mt-5 space-y-1">
        {counts.map((count, i) => {
          const stars = 5 - i;
          const on = filter === stars;
          const dim = filter !== null && !on;
          const pct = total ? Math.round((count / total) * 100) : 0;
          return (
            <motion.button
              key={stars}
              type="button"
              aria-pressed={on}
              aria-label={`${stars} star, ${count} reviews, ${pct}%`}
              whileTap={reduce ? undefined : { scale: 0.98 }}
              onClick={() => choose(on ? null : stars)}
              className={cn("group flex w-full items-center gap-3 rounded-sm px-2 py-1.5 transition-colors hover:bg-surface-hover", on && "bg-surface-hover", focus)}
            >
              <span className={cn("flex w-7 items-center gap-0.5 text-xs font-medium tabular-nums transition-opacity", dim ? "text-ink-subtle" : "text-ink")}>
                {stars}
                <Star aria-hidden className="size-3" fill="currentColor" strokeWidth={0} />
              </span>
              <span className="relative h-2 flex-1 overflow-hidden rounded-full bg-surface-sunken">
                <motion.span
                  className={cn("absolute inset-y-0 left-0 origin-left rounded-full transition-colors", on ? "bg-volt" : dim ? "bg-ink-subtle/35" : "bg-ink/75")}
                  style={{ width: `${(count / max) * 100}%` }}
                  initial={reduce ? false : { scaleX: 0 }}
                  animate={{ scaleX: 1 }}
                  transition={{ type: "spring", stiffness: 120, damping: 20, delay: reduce ? 0 : 0.1 + i * 0.07 }}
                />
              </span>
              <span className={cn("w-10 text-right font-mono text-[11px] tabular-nums", dim ? "text-ink-subtle" : "text-ink-muted")}>{pct}%</span>
            </motion.button>
          );
        })}
      </div>

      <div className="mt-4 flex min-h-9 items-center justify-between gap-3 border-t border-border pt-4">
        <AnimatePresence mode="popLayout" initial={false}>
          {filter ? (
            <motion.button
              key="chip"
              type="button"
              aria-label={`Clear ${filter}-star filter`}
              onClick={() => choose(null)}
              initial={{ opacity: 0, filter: "blur(8px)" }}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, filter: "blur(8px)" }}
              className={cn("inline-flex h-8 items-center gap-1.5 rounded-full border border-border-strong px-3 text-xs text-ink", focus)}
            >
              {filter}-star · {counts[5 - filter].toLocaleString("en-US")}
              <X aria-hidden className="size-3.5 text-ink-muted" />
            </motion.button>
          ) : (
            <motion.p
              key="all"
              initial={{ opacity: 0, filter: "blur(8px)" }}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, filter: "blur(8px)" }}
              className="text-xs text-ink-subtle"
            >
              Showing all ratings
            </motion.p>
          )}
        </AnimatePresence>
        <motion.button
          type="button"
          whileTap={reduce ? undefined : { scale: 0.96 }}
          onClick={onWriteReview}
          className={cn("h-9 shrink-0 rounded-full bg-volt px-4 text-[13px] font-semibold text-on-volt", focus)}
        >
          Write a review
        </motion.button>
      </div>
      <p aria-live="polite" className="sr-only">
        {filter ? `Showing ${filter}-star reviews` : "Showing all reviews"}
      </p>
    </section>
  );
}

```

### components/examples/review-cards/quote.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { BadgeCheck, ChevronDown, Star, ThumbsUp } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ReviewQuoteCardProps {
  author?: string;
  /** Role or context, e.g. "Bought in Sage, size M". */
  meta?: string;
  /** Initials used for the avatar. */
  initials?: string;
  rating?: number;
  headline?: string;
  /** Full review text; paragraphs separated by blank lines. */
  body?: string;
  /** Formatted date, e.g. "Sep 14, 2026". */
  date?: string;
  verified?: boolean;
  helpful?: number;
  /** Characters shown before "Read more". */
  previewLength?: number;
  onHelpfulChange?: (helpful: boolean) => void;
  className?: string;
}

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";

/** A single standout review: verified badge, star line, and a body that springs open in place with "Read more". */
export function ReviewQuoteCard({
  author = "Noor Halabi",
  meta = "Product designer · Owned for 4 months",
  initials = "NH",
  rating = 5,
  headline = "The first standing desk that disappears into the room",
  body = "I was worried the motor would be loud on calls, but it is quieter than my laptop fan. The presets took a minute to learn and now I switch heights three or four times a day without thinking about it.\n\nCable tray is generous enough for a dock, two chargers and a power strip. The only thing I would change is the finish on the crossbar, which shows fingerprints. Still an easy five stars.",
  date = "Sep 14, 2026",
  verified = true,
  helpful = 38,
  previewLength = 180,
  onHelpfulChange,
  className,
}: ReviewQuoteCardProps) {
  const reduce = useReducedMotion() ?? false;
  const [open, setOpen] = React.useState(false);
  const [voted, setVoted] = React.useState(false);
  const bodyId = React.useId();
  const long = body.length > previewLength;
  const preview = long ? body.slice(0, previewLength).replace(/\s+\S*$/, "") + "…" : body;

  function vote() {
    const next = !voted;
    setVoted(next);
    onHelpfulChange?.(next);
  }

  return (
    <figure className={cn("relative w-full max-w-[440px] overflow-hidden rounded-xl border border-border bg-surface-raised p-6 shadow-sm", className)}>
      <svg aria-hidden viewBox="0 0 48 36" className="absolute right-5 top-5 h-9 w-12 text-ink/[0.06]">
        <path fill="currentColor" d="M0 36V20C0 8 7 1 19 0l2 5c-7 2-10 6-10 12h9v19H0Zm27 0V20C27 8 34 1 46 0l2 5c-7 2-10 6-10 12h9v19H27Z" />
      </svg>

      <div className="flex items-center gap-1" role="img" aria-label={`Rated ${rating} out of 5`}>
        {Array.from({ length: 5 }, (_, i) => (
          <motion.span
            key={i}
            initial={reduce ? false : { opacity: 0, scale: 0.4 }}
            animate={{ opacity: 1, scale: 1 }}
            transition={{ type: "spring", stiffness: 480, damping: 18, delay: reduce ? 0 : 0.05 * i }}
          >
            <Star aria-hidden className={cn("size-4", i < rating ? "text-volt-ink" : "text-ink-subtle/40")} fill="currentColor" strokeWidth={0} />
          </motion.span>
        ))}
      </div>

      <blockquote className="mt-3">
        <p className="font-display text-lg font-semibold leading-snug tracking-[-0.3px] text-ink">{headline}</p>
        <motion.div
          id={bodyId}
          layout={!reduce}
          transition={{ type: "spring", stiffness: 300, damping: 32 }}
          className="mt-2 overflow-hidden text-[14px] leading-relaxed text-ink-muted"
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.div
              key={open ? "full" : "preview"}
              initial={{ opacity: 0, filter: "blur(8px)" }}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, filter: "blur(8px)" }}
              transition={{ duration: 0.25 }}
              className="space-y-3"
            >
              {(open ? body : preview).split(/\n\n+/).map((p, i) => (
                <p key={i}>{p}</p>
              ))}
            </motion.div>
          </AnimatePresence>
        </motion.div>
        {long && (
          <button
            type="button"
            aria-expanded={open}
            aria-controls={bodyId}
            onClick={() => setOpen((o) => !o)}
            className={cn("mt-2 inline-flex items-center gap-1 rounded-xs text-[13px] font-medium text-ink underline-offset-4 hover:underline", focus)}
          >
            {open ? "Show less" : "Read more"}
            <ChevronDown aria-hidden className={cn("size-3.5 transition-transform duration-300 ease-spring", open && "rotate-180")} />
          </button>
        )}
      </blockquote>

      <figcaption className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
        <div className="flex min-w-0 items-center gap-3">
          <span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-full bg-surface-sunken font-mono text-xs font-semibold text-ink">
            {initials}
          </span>
          <div className="min-w-0">
            <p className="flex items-center gap-1.5 text-sm font-semibold text-ink">
              {author}
              {verified && (
                <span className="inline-flex items-center gap-1 text-[11px] font-medium text-success">
                  <BadgeCheck aria-hidden className="size-3.5" />
                  Verified buyer
                </span>
              )}
            </p>
            <p className="truncate text-xs text-ink-subtle">
              {meta} · {date}
            </p>
          </div>
        </div>
        <motion.button
          type="button"
          aria-pressed={voted}
          whileTap={reduce ? undefined : { scale: 0.94 }}
          onClick={vote}
          className={cn(
            "inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition-colors",
            voted ? "border-transparent bg-volt-soft text-volt-ink" : "border-border-strong/40 text-ink-muted hover:text-ink",
            focus,
          )}
        >
          <motion.span key={voted ? "on" : "off"} initial={reduce || !voted ? false : { rotate: -24, scale: 1.3 }} animate={{ rotate: 0, scale: 1 }} transition={{ type: "spring", stiffness: 500, damping: 14 }}>
            <ThumbsUp aria-hidden className="size-3.5" fill={voted ? "currentColor" : "none"} />
          </motion.span>
          Helpful
          <span className="tabular-nums">{helpful + (voted ? 1 : 0)}</span>
        </motion.button>
      </figcaption>
    </figure>
  );
}

```

### components/examples/review-cards/write.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Star } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ReviewDraft {
  rating: number;
  text: string;
  tags: string[];
}

export interface WriteReviewCardProps {
  /** What is being reviewed. */
  product?: string;
  /** Words shown for 1–5 stars. */
  labels?: [string, string, string, string, string];
  /** Quick tags the reviewer can toggle. */
  tags?: string[];
  maxLength?: number;
  onSubmit?: (review: ReviewDraft) => void;
  className?: string;
}

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";
const fade = { initial: { opacity: 0, filter: "blur(8px)" }, animate: { opacity: 1, filter: "blur(0px)" }, exit: { opacity: 0, filter: "blur(8px)" } };

/** Review composer: a keyboard-friendly star radio group that pops on select, quick tags, a counted text box and a crossfade to a thank-you state. */
export function WriteReviewCard({
  product = "Arden Wool Throw",
  labels = ["Not for me", "Below expectations", "It’s fine", "Really good", "Love it"],
  tags = ["Soft", "True to colour", "Warm", "Well packaged", "Pills easily"],
  maxLength = 400,
  onSubmit,
  className,
}: WriteReviewCardProps) {
  const reduce = useReducedMotion() ?? false;
  const [rating, setRating] = React.useState(0);
  const [hover, setHover] = React.useState(0);
  const [text, setText] = React.useState("");
  const [picked, setPicked] = React.useState<string[]>([]);
  const [sent, setSent] = React.useState(false);
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const textId = React.useId();
  const shown = hover || rating;

  function pick(n: number) {
    setRating(n);
    refs.current[n - 1]?.focus();
  }

  function onKey(e: React.KeyboardEvent) {
    if (e.key === "ArrowRight" || e.key === "ArrowUp") {
      e.preventDefault();
      pick(Math.min(5, (rating || 0) + 1));
    } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
      e.preventDefault();
      pick(Math.max(1, (rating || 2) - 1));
    }
  }

  function submit(e: React.FormEvent) {
    e.preventDefault();
    if (!rating) return;
    onSubmit?.({ rating, text: text.trim(), tags: picked });
    setSent(true);
  }

  function reset() {
    setSent(false);
    setRating(0);
    setText("");
    setPicked([]);
  }

  return (
    <section aria-label={`Review ${product}`} className={cn("w-full max-w-[400px] overflow-hidden rounded-xl border border-border bg-surface-raised shadow-sm", className)}>
      <AnimatePresence mode="popLayout" initial={false}>
        {sent ? (
          <motion.div key="done" {...fade} className="flex flex-col items-center px-6 py-10 text-center">
            <motion.span
              initial={reduce ? false : { scale: 0.4, rotate: -30 }}
              animate={{ scale: 1, rotate: 0 }}
              transition={{ type: "spring", stiffness: 420, damping: 16 }}
              className="grid size-12 place-items-center rounded-full bg-volt text-on-volt"
            >
              <Check aria-hidden className="size-6" strokeWidth={2.5} />
            </motion.span>
            <h3 className="mt-4 text-base font-semibold text-ink">Thanks, your review is in</h3>
            <p className="mt-1 max-w-[260px] text-sm text-ink-muted">It will appear on {product} after a quick check, usually within an hour.</p>
            <button type="button" onClick={reset} className={cn("mt-5 h-9 rounded-full border border-border-strong/50 px-4 text-[13px] font-medium text-ink hover:bg-surface-hover", focus)}>
              Edit your review
            </button>
          </motion.div>
        ) : (
          <motion.form key="form" {...fade} onSubmit={submit} className="p-5">
            <p className="text-xs font-medium text-ink-subtle">Your review</p>
            <h3 className="mt-0.5 text-base font-semibold text-ink">{product}</h3>

            <div className="mt-4 flex items-center gap-3">
              <div role="radiogroup" aria-label="Rating" onKeyDown={onKey} onMouseLeave={() => setHover(0)} className="flex gap-1">
                {[1, 2, 3, 4, 5].map((n) => {
                  const lit = n <= shown;
                  return (
                    <motion.button
                      key={n}
                      ref={(el) => {
                        refs.current[n - 1] = el;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={rating === n}
                      aria-label={`${n} star${n > 1 ? "s" : ""}, ${labels[n - 1]}`}
                      tabIndex={rating ? (rating === n ? 0 : -1) : n === 1 ? 0 : -1}
                      onMouseEnter={() => setHover(n)}
                      onClick={() => setRating(n)}
                      whileTap={reduce ? undefined : { scale: 0.85 }}
                      animate={reduce ? undefined : { scale: rating === n ? [1, 1.25, 1] : 1 }}
                      transition={{ duration: 0.35 }}
                      className={cn("grid size-9 place-items-center rounded-sm", focus)}
                    >
                      <Star aria-hidden className={cn("size-7 transition-colors duration-150", lit ? "text-volt-ink" : "text-ink-subtle/35")} fill="currentColor" strokeWidth={0} />
                    </motion.button>
                  );
                })}
              </div>
              <div className="relative h-5 min-w-0 flex-1">
                <AnimatePresence mode="popLayout" initial={false}>
                  <motion.span key={shown} {...fade} transition={{ duration: 0.18 }} className={cn("absolute inset-0 truncate text-sm", shown ? "font-medium text-ink" : "text-ink-subtle")}>
                    {shown ? labels[shown - 1] : "Tap to rate"}
                  </motion.span>
                </AnimatePresence>
              </div>
            </div>

            <div role="group" aria-label="Quick tags" className="mt-4 flex flex-wrap gap-1.5">
              {tags.map((t) => {
                const on = picked.includes(t);
                return (
                  <motion.button
                    key={t}
                    type="button"
                    aria-pressed={on}
                    whileTap={reduce ? undefined : { scale: 0.94 }}
                    onClick={() => setPicked((p) => (on ? p.filter((x) => x !== t) : [...p, t]))}
                    className={cn(
                      "inline-flex h-7 items-center gap-1 rounded-full border px-2.5 text-xs transition-colors",
                      on ? "border-ink bg-ink text-bg" : "border-border-strong/40 text-ink-muted hover:text-ink",
                      focus,
                    )}
                  >
                    {on && <Check aria-hidden className="size-3" strokeWidth={3} />}
                    {t}
                  </motion.button>
                );
              })}
            </div>

            <label htmlFor={textId} className="mt-4 block text-xs font-medium text-ink-muted">
              Tell others what stood out
            </label>
            <div className="relative mt-1.5">
              <textarea
                id={textId}
                value={text}
                maxLength={maxLength}
                onChange={(e) => setText(e.target.value)}
                rows={3}
                placeholder="How does it feel after a few weeks of use?"
                className="w-full resize-none rounded-md border border-border-strong/40 bg-surface px-3 py-2.5 pb-6 text-sm text-ink placeholder:text-ink-subtle outline-none transition-colors focus:border-volt-ink"
              />
              <span aria-live="polite" className={cn("pointer-events-none absolute bottom-2 right-3 font-mono text-[10.5px] tabular-nums", text.length > maxLength * 0.9 ? "text-warning" : "text-ink-subtle")}>
                {text.length}/{maxLength}
              </span>
            </div>

            <div className="mt-4 flex items-center justify-between gap-3">
              <p className="text-[11.5px] text-ink-subtle">{rating ? "Posted with your first name" : "Choose a rating to post"}</p>
              <motion.button
                type="submit"
                disabled={!rating}
                whileTap={rating && !reduce ? { scale: 0.96 } : undefined}
                className={cn("h-9 shrink-0 rounded-full bg-volt px-4 text-[13px] font-semibold text-on-volt transition-opacity disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-ink-subtle", focus)}
              >
                Post review
              </motion.button>
            </div>
          </motion.form>
        )}
      </AnimatePresence>
    </section>
  );
}

```

### components/examples/review-cards/compact.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Star, ThumbsDown, ThumbsUp } from "lucide-react";
import { cn } from "@/lib/utils";

export interface CompactReview {
  id: string;
  author: string;
  initials: string;
  rating: number;
  /** Formatted date, e.g. "2 days ago" or "Sep 28". */
  date: string;
  text: string;
  /** Existing helpful votes. */
  up: number;
  down: number;
}

export interface CompactReviewListProps {
  title?: string;
  reviews?: CompactReview[];
  onVote?: (id: string, vote: "up" | "down" | null) => void;
  className?: string;
}

const DEFAULT_REVIEWS: CompactReview[] = [
  { id: "r1", author: "Jonas Weber", initials: "JW", rating: 5, date: "Sep 29", text: "Battery easily lasts a full week of commuting. The case magnets are strong without being fiddly.", up: 24, down: 1 },
  { id: "r2", author: "Amara Osei", initials: "AO", rating: 4, date: "Sep 26", text: "Great noise cancelling on trains. Ear tips run a little small, the medium pair is tight for me.", up: 11, down: 2 },
  { id: "r3", author: "Leo Martín", initials: "LM", rating: 3, date: "Sep 21", text: "Sound is good, but the touch controls trigger when I adjust them. Turned gestures off in the app.", up: 7, down: 4 },
];

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";

function Count({ value, reduce }: { value: number; reduce: boolean }) {
  return (
    <span className="relative inline-flex h-4 min-w-[1.25rem] overflow-hidden tabular-nums">
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={value}
          initial={reduce ? { opacity: 0 } : { y: "100%", opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={reduce ? { opacity: 0 } : { y: "-100%", opacity: 0 }}
          transition={{ type: "spring", stiffness: 500, damping: 34 }}
        >
          {value}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

/** Dense review list: one row per review with a star line, and optimistic helpful votes whose counts roll as you tap. */
export function CompactReviewList({ title = "Most helpful reviews", reviews = DEFAULT_REVIEWS, onVote, className }: CompactReviewListProps) {
  const reduce = useReducedMotion() ?? false;
  const [votes, setVotes] = React.useState<Record<string, "up" | "down" | undefined>>({});

  function vote(id: string, v: "up" | "down") {
    const next = votes[id] === v ? undefined : v;
    setVotes((s) => ({ ...s, [id]: next }));
    onVote?.(id, next ?? null);
  }

  return (
    <section aria-label={title} className={cn("w-full max-w-[460px] rounded-xl border border-border bg-surface-raised shadow-sm", className)}>
      <header className="flex items-center justify-between px-5 pb-2 pt-4">
        <h3 className="text-sm font-semibold text-ink">{title}</h3>
        <span className="font-mono text-[11px] text-ink-subtle">{reviews.length} reviews</span>
      </header>
      <ul className="divide-y divide-border">
        {reviews.map((r, i) => {
          const mine = votes[r.id];
          return (
            <motion.li
              key={r.id}
              initial={reduce ? false : { opacity: 0, y: 8 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ type: "spring", stiffness: 320, damping: 30, delay: reduce ? 0 : i * 0.06 }}
              className="flex gap-3 px-5 py-4"
            >
              <span aria-hidden className="grid size-8 shrink-0 place-items-center rounded-full bg-surface-sunken font-mono text-[11px] font-semibold text-ink">
                {r.initials}
              </span>
              <div className="min-w-0 flex-1">
                <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
                  <p className="text-[13px] font-semibold text-ink">{r.author}</p>
                  <span className="flex gap-px" role="img" aria-label={`${r.rating} out of 5`}>
                    {Array.from({ length: 5 }, (_, s) => (
                      <Star key={s} aria-hidden className={cn("size-3", s < r.rating ? "text-volt-ink" : "text-ink-subtle/35")} fill="currentColor" strokeWidth={0} />
                    ))}
                  </span>
                  <span className="ml-auto text-[11px] text-ink-subtle">{r.date}</span>
                </div>
                <p className="mt-1 text-[13px] leading-relaxed text-ink-muted">{r.text}</p>
                <div className="mt-2.5 flex items-center gap-1.5 text-[11.5px] text-ink-subtle">
                  <span className="mr-1">Helpful?</span>
                  {(["up", "down"] as const).map((v) => {
                    const on = mine === v;
                    const Icon = v === "up" ? ThumbsUp : ThumbsDown;
                    const n = (v === "up" ? r.up : r.down) + (on ? 1 : 0);
                    return (
                      <motion.button
                        key={v}
                        type="button"
                        aria-pressed={on}
                        aria-label={`${v === "up" ? "Yes" : "No"}, ${n} votes`}
                        whileTap={reduce ? undefined : { scale: 0.9 }}
                        onClick={() => vote(r.id, v)}
                        className={cn(
                          "inline-flex h-7 items-center gap-1 rounded-full px-2 font-medium transition-colors",
                          on ? (v === "up" ? "bg-volt-soft text-volt-ink" : "bg-surface-sunken text-ink") : "hover:bg-surface-hover hover:text-ink",
                          focus,
                        )}
                      >
                        <Icon aria-hidden className="size-3.5" fill={on ? "currentColor" : "none"} />
                        <Count value={n} reduce={reduce} />
                      </motion.button>
                    );
                  })}
                </div>
              </div>
            </motion.li>
          );
        })}
      </ul>
    </section>
  );
}

```

### components/examples/review-cards/photos.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { BadgeCheck, ChevronLeft, ChevronRight, Star, X } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ReviewPhoto {
  alt: string;
  /** Real image URL. When omitted a drawn placeholder scene is used. */
  src?: string;
  /** Placeholder scene and its three tones (background, surface, object). */
  scene?: "still" | "detail" | "stack" | "window";
  tones?: [string, string, string];
}

export interface PhotoReviewCardProps {
  author?: string;
  initials?: string;
  date?: string;
  rating?: number;
  title?: string;
  body?: string;
  photos?: ReviewPhoto[];
  className?: string;
}

const DEFAULT_PHOTOS: ReviewPhoto[] = [
  { alt: "Mug on an oak table in morning light", scene: "still", tones: ["#d8cdbd", "#a98a66", "#3f4b41"] },
  { alt: "Close-up of the speckled glaze", scene: "detail", tones: ["#33403a", "#56665b", "#e7dfd1"] },
  { alt: "Three mugs stacked on a shelf", scene: "stack", tones: ["#e9e3d8", "#c8bba6", "#4a5a4e"] },
  { alt: "Mug by a window with steam rising", scene: "window", tones: ["#bfae96", "#efe8dc", "#2e3a32"] },
];

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";
const ctl = cn("grid size-8 place-items-center rounded-full border border-border bg-surface text-ink hover:border-border-strong", focus);

function Scene({ photo }: { photo: ReviewPhoto }) {
  if (photo.src) {
    // eslint-disable-next-line @next/next/no-img-element
    return <img src={photo.src} alt="" className="size-full object-cover" />;
  }
  const [bg, mid, obj] = photo.tones ?? ["#d8cdbd", "#a98a66", "#3f4b41"];
  return (
    <svg viewBox="0 0 120 120" preserveAspectRatio="xMidYMid slice" className="size-full" aria-hidden>
      <rect width="120" height="120" fill={bg} />
      {photo.scene === "detail" ? (
        <>
          <circle cx="60" cy="60" r="70" fill={mid} />
          {[[30, 34, 3], [72, 28, 2], [88, 66, 4], [44, 80, 2.5], [64, 54, 1.8], [20, 70, 2], [96, 98, 3]].map(([x, y, r], i) => (
            <circle key={i} cx={x} cy={y} r={r} fill={obj} opacity={0.85} />
          ))}
          <path d="M0 96c20-8 40 6 60-2s40-10 60 0v26H0z" fill={bg} opacity={0.6} />
        </>
      ) : photo.scene === "stack" ? (
        <>
          <rect y="84" width="120" height="36" fill={mid} />
          {[0, 1, 2].map((i) => (
            <g key={i} transform={`translate(${30 + i * 4} ${74 - i * 18})`}>
              <rect width="44" height="20" rx="4" fill={obj} opacity={1 - i * 0.18} />
              <ellipse cx="22" cy="1" rx="22" ry="3" fill={bg} opacity={0.35} />
            </g>
          ))}
        </>
      ) : photo.scene === "window" ? (
        <>
          <rect x="62" y="10" width="46" height="62" rx="2" fill={mid} />
          <path d="M85 10v62M62 41h46" stroke={bg} strokeWidth="2" />
          <rect y="88" width="120" height="32" fill={obj} opacity={0.25} />
          <path d="M28 60h34v26a8 8 0 0 1-8 8H36a8 8 0 0 1-8-8z" fill={obj} />
          <path d="M62 66h4a7 7 0 0 1 0 14h-4" fill="none" stroke={obj} strokeWidth="3.5" />
          <path d="M40 52c-4-6 4-8 0-14M50 52c-4-6 4-8 0-14" fill="none" stroke={mid} strokeWidth="2" strokeLinecap="round" opacity={0.8} />
        </>
      ) : (
        <>
          <rect y="78" width="120" height="42" fill={mid} />
          <ellipse cx="58" cy="92" rx="30" ry="5" fill={obj} opacity={0.25} />
          <path d="M38 48h40v34a10 10 0 0 1-10 10H48a10 10 0 0 1-10-10z" fill={obj} />
          <path d="M78 56h5a9 9 0 0 1 0 18h-5" fill="none" stroke={obj} strokeWidth="4" />
          <rect x="38" y="48" width="40" height="5" fill={bg} opacity={0.25} />
        </>
      )}
    </svg>
  );
}

/** Review with a drawn photo strip: tap a thumbnail and it grows into a viewer inside the card, with arrow keys and Escape. */
export function PhotoReviewCard({
  author = "Ines Duarte",
  initials = "ID",
  date = "Sep 30, 2026",
  rating = 5,
  title = "Glaze looks even better in person",
  body = "Ordered four in moss. Each one is slightly different, which I love. They hold heat well and the handle fits two fingers comfortably.",
  photos = DEFAULT_PHOTOS,
  className,
}: PhotoReviewCardProps) {
  const reduce = useReducedMotion() ?? false;
  const [open, setOpen] = React.useState<number | null>(null);
  const baseId = React.useId();
  const viewerRef = React.useRef<HTMLDivElement>(null);
  const thumbs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 340, damping: 32 };

  React.useEffect(() => {
    if (open !== null) viewerRef.current?.focus();
  }, [open]);

  function close() {
    const i = open;
    setOpen(null);
    if (i !== null) requestAnimationFrame(() => thumbs.current[i]?.focus());
  }

  function step(d: number) {
    setOpen((o) => (o === null ? o : (o + d + photos.length) % photos.length));
  }

  return (
    <article className={cn("relative w-full max-w-[420px] overflow-hidden rounded-xl border border-border bg-surface-raised p-5 shadow-sm", className)}>
      <header className="flex items-center gap-3">
        <span aria-hidden className="grid size-9 place-items-center rounded-full bg-surface-sunken font-mono text-[11px] font-semibold text-ink">
          {initials}
        </span>
        <div className="min-w-0 flex-1">
          <p className="text-sm font-semibold text-ink">{author}</p>
          <p className="inline-flex items-center gap-1 text-[11.5px] text-success">
            <BadgeCheck aria-hidden className="size-3.5" />
            Verified buyer <span className="text-ink-subtle">· {date}</span>
          </p>
        </div>
        <span className="flex gap-px" role="img" aria-label={`${rating} out of 5`}>
          {Array.from({ length: 5 }, (_, s) => (
            <Star key={s} aria-hidden className={cn("size-3.5", s < rating ? "text-volt-ink" : "text-ink-subtle/35")} fill="currentColor" strokeWidth={0} />
          ))}
        </span>
      </header>
      <h3 className="mt-4 text-[15px] font-semibold text-ink">{title}</h3>
      <p className="mt-1 text-[13.5px] leading-relaxed text-ink-muted">{body}</p>

      <ul aria-label={`${photos.length} photos`} className="mt-4 grid grid-cols-4 gap-2">
        {photos.map((p, i) => (
          <li key={i}>
            <motion.button
              ref={(el) => {
                thumbs.current[i] = el;
              }}
              type="button"
              aria-label={`Open photo ${i + 1}: ${p.alt}`}
              onClick={() => setOpen(i)}
              whileHover={reduce ? undefined : { y: -2 }}
              whileTap={reduce ? undefined : { scale: 0.96 }}
              className={cn("block aspect-square w-full overflow-hidden rounded-md", focus)}
            >
              {open !== i ? (
                <motion.span layoutId={`${baseId}-p${i}`} transition={spring} className="block size-full overflow-hidden rounded-md">
                  <Scene photo={p} />
                </motion.span>
              ) : (
                <span className="block size-full rounded-md bg-surface-sunken" />
              )}
            </motion.button>
          </li>
        ))}
      </ul>

      <AnimatePresence>
        {open !== null && (
          <motion.div
            ref={viewerRef}
            role="dialog"
            aria-label={`Photo ${open + 1} of ${photos.length}`}
            tabIndex={-1}
            onKeyDown={(e) => {
              if (e.key === "Escape") close();
              if (e.key === "ArrowRight") step(1);
              if (e.key === "ArrowLeft") step(-1);
            }}
            className="absolute inset-0 z-10 flex flex-col p-3 outline-none"
          >
            <motion.div aria-hidden initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-surface-raised" />
            <motion.div key={open} layoutId={`${baseId}-p${open}`} transition={spring} className="relative min-h-0 flex-1 overflow-hidden rounded-lg shadow-lg">
              <Scene photo={photos[open]} />
            </motion.div>
            <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="relative mt-2 flex items-center gap-2">
              <p className="min-w-0 flex-1 truncate text-xs text-ink-muted">{photos[open].alt}</p>
              <span className="font-mono text-[11px] text-ink-subtle">
                {open + 1}/{photos.length}
              </span>
              <button type="button" aria-label="Previous photo" onClick={() => step(-1)} className={ctl}>
                <ChevronLeft aria-hidden className="size-4" />
              </button>
              <button type="button" aria-label="Next photo" onClick={() => step(1)} className={ctl}>
                <ChevronRight aria-hidden className="size-4" />
              </button>
              <button type="button" aria-label="Close photo" onClick={close} className={ctl}>
                <X aria-hidden className="size-4" />
              </button>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </article>
  );
}

```
