Commerce
Review
One customer review: stars, title, author and date, verified mark, body, seller reply and a helpful vote.
- Built on
<article><time>aria-pressedIntl.DateTimeFormat- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.2 kB
Keeps coffee hot all morning
Code
import { Review } from "@/components/equip/review";
export default function Demo() {
return (
<Review
size="s"
style={{ width: 224 }}
rating={5}
title="Keeps coffee hot all morning"
author="Priya N."
date="2026-10-22"
>
Still warm at noon after pouring at seven, and the lid never leaks.
</Review>
);
}Examples
A list of reviews
Helpful counts exclude the reader's own vote; pressing Yes or No adds it, pressing again takes it back.
Soft from the first wash
Verified purchaseSize: M, Colour: Oat
Bought the oat and the sage. Both kept their shape after a dozen washes and the linen has softened nicely without going thin.
Was this helpful?Runs a little large
Verified purchaseSize: L, Colour: Navy
Lovely fabric and the buttons feel solid. I usually wear a large but could have gone down a size; the sleeves are long.
Was this helpful?Colour faded quickly
Verified purchaseSize: S, Colour: Terracotta
Comfortable, but the terracotta was noticeably lighter after three washes on cold.
Was this helpful?
Code
import { Review } from "@/components/equip/review";
const reviews = [
{
id: "r1",
rating: 5,
title: "Soft from the first wash",
author: "Hannah K.",
location: "Bristol",
date: "2026-10-03",
variant: "Size: M, Colour: Oat",
body: "Bought the oat and the sage. Both kept their shape after a dozen washes and the linen has softened nicely without going thin.",
helpful: { yes: 24, no: 1 },
},
{
id: "r2",
rating: 4,
title: "Runs a little large",
author: "Marcus T.",
date: "2026-09-18",
variant: "Size: L, Colour: Navy",
body: "Lovely fabric and the buttons feel solid. I usually wear a large but could have gone down a size; the sleeves are long.",
helpful: { yes: 41, no: 3 },
},
{
id: "r3",
rating: 2,
title: "Colour faded quickly",
author: "Ines R.",
location: "Lisbon",
date: "2026-08-30",
variant: "Size: S, Colour: Terracotta",
body: "Comfortable, but the terracotta was noticeably lighter after three washes on cold.",
helpful: { yes: 9, no: 6 },
},
];
export default function Example() {
return (
<ul style={{ display: "grid", margin: 0, padding: 0, listStyle: "none", maxWidth: 560 }}>
{reviews.map((r, i) => (
<li
key={r.id}
style={{ padding: "16px 0", borderTop: i === 0 ? undefined : "1px solid var(--eq-line)" }}
>
<Review
rating={r.rating}
title={r.title}
author={r.author}
location={r.location}
date={r.date}
variant={r.variant}
verified
helpful={r.helpful}
>
<p>{r.body}</p>
</Review>
</li>
))}
</ul>
);
}With a seller response
The reply sits under the review with its own author and date, set off by a rule on the leading edge.
Handle got hot on the hob
Verified purchaseSize: 2 litre, Colour: Slate
Boils fast and pours cleanly, but the handle needs a cloth on a gas ring.
Sorry about that, Tom. The steel handle does warm up on gas. We have sent you a silicone sleeve, and the sleeve now ships with every kettle.
Code
import { Review } from "@/components/equip/review";
export default function Example() {
return (
<Review
style={{ maxWidth: 520 }}
rating={3}
title="Handle got hot on the hob"
author="Tom W."
location="Leeds"
date="2026-09-02"
verified
variant="Size: 2 litre, Colour: Slate"
helpful={{ yes: 17, no: 2 }}
response={{
author: "Fernhill Kitchen",
date: "2026-09-04",
body: (
<p>
Sorry about that, Tom. The steel handle does warm up on gas. We have sent you a silicone sleeve, and the
sleeve now ships with every kettle.
</p>
),
}}
>
<p>Boils fast and pours cleanly, but the handle needs a cloth on a gas ring.</p>
</Review>
);
}In German
locale formats the date and counts; labels translate the fixed wording, including the spoken rating.
Schön schwer und gut verarbeitet
Verifizierter KaufGröße: 28 cm, Farbe: Anthrazit
Die Pfanne liegt gut in der Hand und verteilt die Hitze gleichmäßig. Nur der Griff wird im Ofen sehr heiß.
Code
import { Review } from "@/components/equip/review";
export default function Example() {
return (
<Review
lang="de"
locale="de-DE"
style={{ maxWidth: 520 }}
rating={4}
title="Schön schwer und gut verarbeitet"
author="Lena M."
location="Hamburg"
date="2026-11-02"
verified
variant="Größe: 28 cm, Farbe: Anthrazit"
helpful={{ yes: 1204, no: 37 }}
labels={{
rating: "Bewertet mit {rating} von 5",
verified: "Verifizierter Kauf",
helpful: "War das hilfreich?",
yes: "Ja",
no: "Nein",
response: "Antwort von {author}",
media: "Fotos zu dieser Bewertung",
}}
>
<p>Die Pfanne liegt gut in der Hand und verteilt die Hitze gleichmäßig. Nur der Griff wird im Ofen sehr heiß.</p>
</Review>
);
}As a quote with a face
Leave out date for a quote on a landing page; role says who the author is and avatar sits beside the byline.
We cut waste from 7% to 3% of food spend in ten weeks. We just stopped ordering cream for a Tuesday that was never going to come.
Code
import { Avatar } from "@/components/equip/avatar";
import { Review } from "@/components/equip/review";
export default function Example() {
return (
<Review
style={{ maxWidth: 480 }}
rating={5}
author="Ines Duarte"
role="Head chef, Marrow"
location="Hackney"
avatar={<Avatar name="Ines Duarte" alt="" />}
>
<p>
We cut waste from 7% to 3% of food spend in ten weeks. We just stopped ordering cream for a Tuesday that was
never going to come.
</p>
</Review>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
rating | number | none | Stars given, 1 to 5, rounded to a whole star. |
title | ReactNode | none | The review's headline, as a heading. |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Level of the title heading. |
author | string | none | Who wrote it. |
role | ReactNode | none | Who the author is, such as Head chef, Marrow. Shown after the name. |
avatar | ReactNode | none | A picture beside the byline, usually an Avatar with an empty alt since the name is written beside it. |
location | string | none | Where they are, after the name. |
date | string | none | ISO date, shown in a time element and formatted in UTC. Leave out for a quote with no date. |
locale | string | "en-US" | Formats dates and counts. |
verified | boolean | false | Shows a Verified purchase mark, a tick and words. |
variant | string | none | What was bought, such as Size: M, Colour: Oat. |
children | ReactNode | none | The review text. |
helpful | { yes: number; no: number } | none | Shows Was this helpful with Yes and No toggle buttons. Counts exclude this reader's vote. |
vote | "yes" | "no" | null | none | This reader's vote, controlled. |
defaultVote | "yes" | "no" | null | null | This reader's vote to start with. |
onVote | (vote: "yes" | "no" | null) => void | none | Called with the new vote, or null when it is taken back. |
response | { author: string; date: string; body: ReactNode } | none | A reply from the seller, set under the review. |
media | ReactNode | none | Thumbnails of photos the reviewer added, shown at 64px. |
size | "s" | "m" | "m" | s is smaller type and tighter spacing, for a tile or sidebar. |
labels | Partial<ReviewLabels> | none | Wording for another language: rating, verified, helpful, yes, no, response and media. {rating} and {author} are filled in. |
...rest | ComponentProps<"article"> | none | Every native article attribute, including lang and id. |
Anatomy
| root | .eq-review | An article. |
|---|---|---|
| rating | .eq-review-rating | Stars hidden from assistive tech, with a spoken sentence beside them. |
| title | .eq-review-title | |
| byline | .eq-review-byline | Author, location and date, divided by drawn dots. |
| person | .eq-review-person | Only with avatar: the avatar and the byline side by side. |
| avatar | .eq-review-avatar | |
| author | .eq-review-author | |
| role | .eq-review-role | |
| location | .eq-review-location | |
| date | .eq-review-date | |
| facts | .eq-review-facts | The verified mark and the variant line. |
| verified | .eq-review-verified | |
| variant | .eq-review-variant | |
| body | .eq-review-body | |
| media | .eq-review-media | |
| response | .eq-review-response | |
| response head | .eq-review-response-head | |
| response author | .eq-review-response-author | |
| response body | .eq-review-response-body | |
| helpful | .eq-review-helpful | A group labelled by its question. |
| helpful label | .eq-review-helpful-label | |
| votes | .eq-review-votes | The count inside each vote button. |
| hidden | .eq-review-hidden | The visually hidden rating sentence. |
Keyboard
| Tab | Moves to the Yes and No buttons. |
|---|---|
| EnterSpace | Votes, switches the vote or takes it back. |
Notes
- The stars are hidden from assistive tech and replaced by one sentence, Rated 4 out of 5, which labels can translate.
- Yes and No are toggle buttons with aria-pressed, so the reader hears which way they voted; the count updates in the label.
- The dots in the byline are drawn with CSS, so screen readers do not read out separators.
- Verified purchase is a tick and words, never colour alone.
- Put a list of reviews in a ul with each Review in an li; the article keeps each one self-contained.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/review.tsx200 lines
"use client";
import { useId, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import { Button } from "./button";
import { Icon } from "./icon";
import { Rating } from "./rating";
import "./review.css";
export type ReviewVote = "yes" | "no";
export type ReviewLabels = {
/** Spoken rating: "Rated {rating} out of 5". */
rating: string;
verified: string;
helpful: string;
yes: string;
no: string;
/** "Response from {author}". */
response: string;
/** Names the media thumbnails. */
media: string;
};
const english: ReviewLabels = {
rating: "Rated {rating} out of 5",
verified: "Verified purchase",
helpful: "Was this helpful?",
yes: "Yes",
no: "No",
response: "Response from {author}",
media: "Photos from this review",
};
export type ReviewProps = Omit<ComponentProps<"article">, "title" | "role"> & {
/** Stars given, 1 to 5. */
rating: number;
title?: ReactNode;
headingLevel?: 2 | 3 | 4 | 5 | 6;
author: string;
/** Who the author is, such as "Head chef, Marrow". Shown after the name. */
role?: ReactNode;
/** A picture beside the byline, usually an Avatar. Decorative: give it an empty alt, the name is written beside it. */
avatar?: ReactNode;
location?: string;
/** ISO date the review was written. Leave out for a quote with no date. */
date?: string;
locale?: string;
verified?: boolean;
/** What was bought, such as "Size: M, Colour: Oat". */
variant?: string;
/** Shows the helpful vote with these counts, which do not include this reader's vote. */
helpful?: { yes: number; no: number };
/** This reader's vote, controlled. */
vote?: ReviewVote | null;
defaultVote?: ReviewVote | null;
/** Called with the new vote, or null when the reader takes it back. */
onVote?: (vote: ReviewVote | null) => void;
/** A reply from the seller under the review. */
response?: { author: string; date: string; body: ReactNode };
/** Thumbnails of photos the reviewer added. */
media?: ReactNode;
size?: "s" | "m";
/** Wording, for another language. */
labels?: Partial<ReviewLabels>;
};
const fill = (template: string, values: Record<string, string>) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match);
export function Review({
rating,
title,
headingLevel = 3,
author,
role,
avatar,
location,
date,
locale = "en-US",
verified = false,
variant,
helpful,
vote,
defaultVote = null,
onVote,
response,
media,
size = "m",
labels,
className,
children,
...rest
}: ReviewProps) {
const id = useId();
const [inner, setInner] = useState<ReviewVote | null>(defaultVote);
const current = vote === undefined ? inner : vote;
const words = { ...english, ...labels };
const day = new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" });
const whole = new Intl.NumberFormat(locale);
const stars = Math.max(0, Math.min(5, Math.round(rating)));
const Heading = `h${headingLevel}` as "h3";
const cast = (choice: ReviewVote) => {
const next = current === choice ? null : choice;
if (vote === undefined) setInner(next);
onVote?.(next);
};
const helpfulId = `${id}-helpful`;
const byline = (
<p className="eq-review-byline">
<span className="eq-review-author">{author}</span>
{role != null && <span className="eq-review-role">{role}</span>}
{location && <span className="eq-review-location">{location}</span>}
{date && (
<time className="eq-review-date" dateTime={date}>
{day.format(new Date(date))}
</time>
)}
</p>
);
return (
<article {...rest} className={["eq-review", className].filter(Boolean).join(" ")} data-size={size}>
<div className="eq-review-rating">
<Rating label={fill(words.rating, { rating: whole.format(stars) })} value={stars} readOnly size="s" aria-hidden="true" />
<span className="eq-review-hidden">{fill(words.rating, { rating: whole.format(stars) })}</span>
</div>
{title != null && <Heading className="eq-review-title">{title}</Heading>}
{avatar != null ? (
<div className="eq-review-person">
<span className="eq-review-avatar">{avatar}</span>
{byline}
</div>
) : (
byline
)}
{(verified || variant) && (
<p className="eq-review-facts">
{verified && (
<span className="eq-review-verified">
<Icon name="check-circle" size={14} />
{words.verified}
</span>
)}
{variant && <span className="eq-review-variant">{variant}</span>}
</p>
)}
<div className="eq-review-body">{children}</div>
{media != null && (
<div className="eq-review-media" role="group" aria-label={words.media}>
{media}
</div>
)}
{response && (
<div className="eq-review-response">
<p className="eq-review-response-head">
<span className="eq-review-response-author">{fill(words.response, { author: response.author })}</span>
<time className="eq-review-date" dateTime={response.date}>
{day.format(new Date(response.date))}
</time>
</p>
<div className="eq-review-response-body">{response.body}</div>
</div>
)}
{helpful && (
<div className="eq-review-helpful" role="group" aria-labelledby={helpfulId}>
<span className="eq-review-helpful-label" id={helpfulId}>
{words.helpful}
</span>
{(["yes", "no"] as const).map((choice) => (
<Button
key={choice}
size="s"
variant="ghost"
pressed={current === choice}
onClick={() => cast(choice)}
trail={
<span className="eq-review-votes" data-pressed={current === choice || undefined}>
{whole.format(helpful[choice] + (current === choice ? 1 : 0))}
</span>
}
>
{words[choice]}
</Button>
))}
</div>
)}
</article>
);
}components/equip/review.css201 lines
@layer equip {
.eq-review {
box-sizing: border-box;
display: grid;
gap: var(--eq-space-2);
min-width: 0;
margin: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
}
.eq-review > * {
min-width: 0;
margin: 0;
}
.eq-review-rating {
display: flex;
align-items: center;
}
.eq-review-title {
font-size: var(--eq-text-m);
font-weight: var(--eq-weight-strong);
line-height: 1.35;
letter-spacing: -0.005em;
overflow-wrap: anywhere;
}
/* The rating and the title read as one line of thought, so they sit closer. */
.eq-review-rating + .eq-review-title {
margin-top: calc(-1 * var(--eq-space-1));
}
.eq-review-byline,
.eq-review-facts,
.eq-review-response-head {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: var(--eq-space-2);
row-gap: 2px;
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
line-height: 1.4;
}
.eq-review-author,
.eq-review-response-author {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
/* A middle dot between the byline pieces, drawn so it is never read aloud. */
.eq-review-byline > * + *::before,
.eq-review-facts > * + *::before {
content: "";
display: inline-block;
width: 3px;
height: 3px;
margin-inline-end: var(--eq-space-2);
border-radius: var(--eq-radius-full);
background: var(--eq-ink-3);
vertical-align: middle;
}
.eq-review-date {
white-space: nowrap;
}
/* With an avatar, the picture sits at the start and the byline wraps beside it. */
.eq-review-person {
display: flex;
align-items: center;
gap: var(--eq-space-2);
}
.eq-review-person > .eq-review-byline {
flex: 1;
min-width: 0;
margin: 0;
}
.eq-review-avatar {
display: flex;
flex: none;
}
.eq-review-verified {
display: inline-flex;
align-items: center;
gap: var(--eq-space-1);
color: var(--eq-ok);
font-weight: var(--eq-weight-strong);
}
.eq-review-body {
color: var(--eq-ink);
overflow-wrap: anywhere;
}
.eq-review-body > :first-child {
margin-top: 0;
}
.eq-review-body > :last-child {
margin-bottom: 0;
}
.eq-review-media {
display: flex;
flex-wrap: wrap;
gap: var(--eq-space-2);
}
.eq-review-media > :is(img, svg, div, span) {
width: 64px;
height: 64px;
overflow: hidden;
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-m);
object-fit: cover;
}
.eq-review-response {
display: grid;
gap: var(--eq-space-1);
margin-top: var(--eq-space-1);
padding: var(--eq-space-2) var(--eq-space-3);
border-inline-start: 2px solid var(--eq-line-strong);
border-radius: 0 var(--eq-radius-m) var(--eq-radius-m) 0;
background: var(--eq-tint);
font-size: var(--eq-text-s);
}
.eq-review-response-body {
color: var(--eq-ink-2);
}
.eq-review-response-body > :first-child {
margin-top: 0;
}
.eq-review-response-body > :last-child {
margin-bottom: 0;
}
.eq-review-helpful {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-1);
margin-top: var(--eq-space-1);
}
.eq-review-helpful-label {
margin-inline-end: var(--eq-space-1);
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
}
.eq-review-votes {
font-variant-numeric: tabular-nums;
opacity: 0.72;
}
.eq-review-votes[data-pressed] {
opacity: 1;
}
/* Small: tighter type for a sidebar, a tile or a quote on a product page. */
.eq-review[data-size="s"] {
gap: var(--eq-space-1);
font-size: var(--eq-text-s);
line-height: 1.45;
}
.eq-review[data-size="s"] .eq-review-title {
font-size: var(--eq-text-s);
}
.eq-review[data-size="s"] .eq-review-rating + .eq-review-title {
margin-top: 0;
}
.eq-review[data-size="s"] :is(.eq-review-byline, .eq-review-facts) {
font-size: var(--eq-text-xs);
}
.eq-review-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}