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
Built from
RatingIconButton
Rated 5 out of 5

Keeps coffee hot all morning

Still warm at noon after pouring at seven, and the lid never leaks.
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.

  • Rated 5 out of 5

    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?
  • Rated 4 out of 5

    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?
  • Rated 2 out of 5

    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.

Rated 3 out of 5

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.

Response from Fernhill Kitchen

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.

Was this helpful?
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.

Bewertet mit 4 von 5

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ß.

War das hilfreich?
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.

Rated 5 out of 5

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

PropTypeDefaultWhat it does
ratingnumbernoneStars given, 1 to 5, rounded to a whole star.
titleReactNodenoneThe review's headline, as a heading.
headingLevel2 | 3 | 4 | 5 | 63Level of the title heading.
authorstringnoneWho wrote it.
roleReactNodenoneWho the author is, such as Head chef, Marrow. Shown after the name.
avatarReactNodenoneA picture beside the byline, usually an Avatar with an empty alt since the name is written beside it.
locationstringnoneWhere they are, after the name.
datestringnoneISO date, shown in a time element and formatted in UTC. Leave out for a quote with no date.
localestring"en-US"Formats dates and counts.
verifiedbooleanfalseShows a Verified purchase mark, a tick and words.
variantstringnoneWhat was bought, such as Size: M, Colour: Oat.
childrenReactNodenoneThe review text.
helpful{ yes: number; no: number }noneShows Was this helpful with Yes and No toggle buttons. Counts exclude this reader's vote.
vote"yes" | "no" | nullnoneThis reader's vote, controlled.
defaultVote"yes" | "no" | nullnullThis reader's vote to start with.
onVote(vote: "yes" | "no" | null) => voidnoneCalled with the new vote, or null when it is taken back.
response{ author: string; date: string; body: ReactNode }noneA reply from the seller, set under the review.
mediaReactNodenoneThumbnails of photos the reviewer added, shown at 64px.
size"s" | "m""m"s is smaller type and tighter spacing, for a tile or sidebar.
labelsPartial<ReviewLabels>noneWording for another language: rating, verified, helpful, yes, no, response and media. {rating} and {author} are filled in.
...restComponentProps<"article">noneEvery native article attribute, including lang and id.

Anatomy

root.eq-reviewAn article.
rating.eq-review-ratingStars hidden from assistive tech, with a spoken sentence beside them.
title.eq-review-title
byline.eq-review-bylineAuthor, location and date, divided by drawn dots.
person.eq-review-personOnly 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-factsThe 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-helpfulA group labelled by its question.
helpful label.eq-review-helpful-label
votes.eq-review-votesThe count inside each vote button.
hidden.eq-review-hiddenThe visually hidden rating sentence.

Keyboard

TabMoves to the Yes and No buttons.
EnterSpaceVotes, 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index