Commerce

Review summary

The average rating, review count and a bar for each star level, optionally filtering the reviews below.

Built on
Container queriesIntl.NumberFormataria-pressed
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.2 kB
Built from
RatingIcon

Customer reviews

Rated 4.6 out of 5Based on 1,284 reviews
  • 5 stars, 812 reviews, 63%
  • 4 stars, 301 reviews, 23%
  • 3 stars, 98 reviews, 8%
  • 2 stars, 41 reviews, 3%
  • 1 star, 32 reviews, 2%
Try its options

Loading its options.

Code
import { ReviewSummary } from "@/components/equip/review-summary";

export default function Demo() {
  return (
    <ReviewSummary
      size="s"
      style={{ width: 224 }}
      average={4.6}
      distribution={{ 5: 812, 4: 301, 3: 98, 2: 41, 1: 32 }}
    />
  );
}
Your versionchange its options in Look, This part

Examples

With an action

Beside the bars once the section is about 460px wide, stacked below that; the container decides, not the viewport.

Customer reviews

Rated 4.6 out of 5Based on 1,284 reviews
  • 5 stars, 812 reviews, 63%
  • 4 stars, 301 reviews, 23%
  • 3 stars, 98 reviews, 8%
  • 2 stars, 41 reviews, 3%
  • 1 star, 32 reviews, 2%
Code
import { Button } from "@/components/equip/button";
import { ReviewSummary } from "@/components/equip/review-summary";

export default function Example() {
  return (
    <ReviewSummary
      headingLevel={2}
      average={4.6}
      distribution={{ 5: 812, 4: 301, 3: 98, 2: 41, 1: 32 }}
      action={<Button variant="outline">Write a review</Button>}
    />
  );
}

Filtering reviews

With onFilter each row is a toggle button; pressing the active row again clears the filter.

Customer reviews

Rated 4.6 out of 5Based on 290 reviews
Showing all reviews
  • 5 of 5: Keeps coffee hot for hours
  • 5 of 5: The lid finally does not leak
  • 4 of 5: Good, but the handle is small
  • 2 of 5: Chipped within a month
  • 1 of 5: Arrived cracked
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { ReviewSummary, type ReviewStars } from "@/components/equip/review-summary";

const reviews = [
  { stars: 5, title: "Keeps coffee hot for hours" },
  { stars: 5, title: "The lid finally does not leak" },
  { stars: 4, title: "Good, but the handle is small" },
  { stars: 2, title: "Chipped within a month" },
  { stars: 1, title: "Arrived cracked" },
];

export default function Example() {
  const [stars, setStars] = useState<ReviewStars | null>(null);
  const shown = stars === null ? reviews : reviews.filter((r) => r.stars === stars);
  return (
    <div style={{ display: "grid", gap: 16, width: "100%" }}>
      <ReviewSummary
        headingLevel={2}
        distribution={{ 5: 214, 4: 61, 3: 0, 2: 9, 1: 6 }}
        filter={stars}
        onFilter={setStars}
      />
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, fontSize: 13 }}>
        <span style={{ color: "var(--eq-ink-2)" }}>
          {stars === null ? "Showing all reviews" : `Showing ${stars} star reviews`}
        </span>
        {stars !== null && (
          <Button size="s" variant="ghost" onClick={() => setStars(null)}>
            Clear filter
          </Button>
        )}
      </div>
      <ul style={{ display: "grid", gap: 6, margin: 0, padding: 0, listStyle: "none", fontSize: 13 }}>
        {shown.map((r) => (
          <li key={r.title} style={{ paddingBottom: 6, borderBottom: "1px solid var(--eq-line)" }}>
            {r.stars} of 5: {r.title}
          </li>
        ))}
      </ul>
    </div>
  );
}

Another language

Numbers follow locale; labels are templates, so the spoken rows read naturally in German too.

Kundenbewertungen

Bewertet mit 4,6 von 5Basierend auf 2.764 Bewertungen
  • 5 Sterne, 1.904 Bewertungen, 69 %
  • 4 Sterne, 622 Bewertungen, 23 %
  • 3 Sterne, 140 Bewertungen, 5 %
  • 2 Sterne, 51 Bewertungen, 2 %
  • 1 Stern, 47 Bewertungen, 2 %
Code
import { ReviewSummary } from "@/components/equip/review-summary";

export default function Example() {
  return (
    <ReviewSummary
      lang="de"
      locale="de-DE"
      style={{ maxWidth: 320 }}
      distribution={{ 5: 1904, 4: 622, 3: 140, 2: 51, 1: 47 }}
      labels={{
        heading: "Kundenbewertungen",
        basedOn: "Basierend auf {count} Bewertungen",
        average: "Bewertet mit {average} von 5",
        row: "{stars} Sterne, {count} Bewertungen, {percent}",
        rowOne: "{stars} Stern, {count} Bewertungen, {percent}",
        distribution: "Bewertungen nach Sternen",
      }}
    />
  );
}

Props

PropTypeDefaultWhat it does
distributionRecord<1 | 2 | 3 | 4 | 5, number>noneNumber of reviews at each star level. The total and the percentages come from it.
averagenumbernoneThe average shown. Worked out from distribution when left out.
onFilter(stars: 1 | 2 | 3 | 4 | 5 | null) => voidnoneMakes each row a toggle button. Called with null when the active row is pressed again.
filter1 | 2 | 3 | 4 | 5 | nullnoneThe active row, controlled.
defaultFilter1 | 2 | 3 | 4 | 5 | nullnullThe active row to start with, uncontrolled.
actionReactNodenoneA slot under the summary, such as a Write a review button.
size"s" | "m""m"s puts the average, stars and count on one line, hides the heading visually and drops the percentages.
localestring"en-US"Formats the average, the counts and the percentages.
headingLevel2 | 3 | 43Level of the section heading.
labelsPartial<ReviewSummaryLabels>noneWording templates: heading, basedOn, average, row, rowOne and distribution, with {count}, {average}, {stars} and {percent}.
...restComponentProps<"section">noneEvery native section attribute, including lang and style.

Anatomy

root.eq-review-summaryA section labelled by its heading, and the query container.
layout.eq-review-summary-layoutThe grid that puts the overview beside the bars when wide.
overview.eq-review-summary-overview
heading.eq-review-summary-heading
score.eq-review-summary-scoreAverage, stars and count.
average.eq-review-summary-average
count.eq-review-summary-count
action.eq-review-summary-action
bars.eq-review-summary-barsA list, one item per star level.
row.eq-review-summary-row
line.eq-review-summary-lineA row without onFilter.
filter.eq-review-summary-filterA row with onFilter: a toggle button.
stars.eq-review-summary-starsThe star level with a small star glyph.
track.eq-review-summary-track
fill.eq-review-summary-fillIts width is the share of all reviews.
tally.eq-review-summary-tallyThe visible count for the row.
percent.eq-review-summary-percent
hidden.eq-review-summary-hiddenVisually hidden sentences for screen readers.

Keyboard

TabWith onFilter, moves through the star rows.
EnterSpaceFilters to that star level, or clears the filter when it is already active.

Notes

  • Each row is read as one sentence, such as 5 stars, 812 reviews, 63%; the visible pieces are hidden from assistive tech.
  • The stars are hidden from assistive tech and the average is spoken to one decimal, so it matches the number on screen.
  • Rows with no reviews are disabled as filters, since they would show an empty list.
  • Bars use the ink colours, not the accent, so the one primary action on the page stays the loudest thing.
  • Percentages are rounded on their own and may not add up to exactly 100.

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-summary.tsx169 lines
"use client";

import { useId, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import { Icon } from "./icon";
import { Rating } from "./rating";
import "./review-summary.css";

export type ReviewStars = 1 | 2 | 3 | 4 | 5;
type Stars = ReviewStars;

export type ReviewSummaryLabels = {
  /** Heading above the summary. */
  heading: string;
  /** "Based on {count} reviews". {count} is replaced with the grouped number. */
  basedOn: string;
  /** Spoken average: "Rated {average} out of 5". */
  average: string;
  /** Spoken row: "{stars} stars, {count} reviews, {percent}". */
  row: string;
  /** Spoken row for one star. */
  rowOne: string;
  /** Names the list of bars. */
  distribution: string;
};

const english: ReviewSummaryLabels = {
  heading: "Customer reviews",
  basedOn: "Based on {count} reviews",
  average: "Rated {average} out of 5",
  row: "{stars} stars, {count} reviews, {percent}",
  rowOne: "{stars} star, {count} reviews, {percent}",
  distribution: "Reviews by star rating",
};

export type ReviewSummaryProps = Omit<ComponentProps<"section">, "onChange"> & {
  /** Number of reviews at each star rating. */
  distribution: Record<Stars, number>;
  /** The average. Worked out from distribution when left out. */
  average?: number;
  /** The star rating the list is filtered to, or null for all. Controlled. */
  filter?: Stars | null;
  defaultFilter?: Stars | null;
  /** Makes each row a toggle button. Called with the stars, or null when the active row is pressed again. */
  onFilter?: (stars: Stars | null) => void;
  /** A slot under the summary, such as a "Write a review" button. */
  action?: ReactNode;
  /** s is one line of average and stars over the bars, for a sidebar or a tile. */
  size?: "s" | "m";
  locale?: string;
  /** Level of the heading. */
  headingLevel?: 2 | 3 | 4;
  /** Wording, for another language. Each string is a template. */
  labels?: Partial<ReviewSummaryLabels>;
};

const fill = (template: string, values: Record<string, string>) =>
  template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match);

export function ReviewSummary({
  distribution,
  average,
  filter,
  defaultFilter = null,
  onFilter,
  action,
  size = "m",
  locale = "en-US",
  headingLevel = 3,
  labels,
  className,
  ...rest
}: ReviewSummaryProps) {
  const id = useId();
  const [inner, setInner] = useState<Stars | null>(defaultFilter);
  const active = filter === undefined ? inner : filter;
  const words = { ...english, ...labels };

  const rows: Stars[] = [5, 4, 3, 2, 1];
  const total = rows.reduce((sum, n) => sum + (distribution[n] ?? 0), 0);
  const mean = average ?? (total === 0 ? 0 : rows.reduce((sum, n) => sum + n * (distribution[n] ?? 0), 0) / total);

  const one = new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
  const whole = new Intl.NumberFormat(locale);
  const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 });

  const choose = (n: Stars) => {
    const next = active === n ? null : n;
    if (filter === undefined) setInner(next);
    onFilter?.(next);
  };

  const Heading = `h${headingLevel}` as "h3";
  const headingId = `${id}-heading`;
  const averageText = one.format(mean);

  return (
    <section
      aria-labelledby={headingId}
      {...rest}
      className={["eq-review-summary", className].filter(Boolean).join(" ")}
      data-size={size}
    >
      <div className="eq-review-summary-layout">
        <div className="eq-review-summary-overview">
          <Heading className="eq-review-summary-heading" id={headingId}>
            {words.heading}
          </Heading>
          <div className="eq-review-summary-score">
            <span className="eq-review-summary-average" aria-hidden="true">
              {averageText}
            </span>
            <Rating label={fill(words.average, { average: averageText })} value={mean} readOnly allowHalf size="s" aria-hidden="true" />
            <span className="eq-review-summary-hidden">{fill(words.average, { average: averageText })}</span>
            <span className="eq-review-summary-count">{fill(words.basedOn, { count: whole.format(total) })}</span>
          </div>
          {action != null && <div className="eq-review-summary-action">{action}</div>}
        </div>

        <ul className="eq-review-summary-bars" role="list" aria-label={words.distribution}>
          {rows.map((n) => {
            const count = distribution[n] ?? 0;
            const share = total === 0 ? 0 : count / total;
            const spoken = fill(n === 1 ? words.rowOne : words.row, {
              stars: whole.format(n),
              count: whole.format(count),
              percent: percent.format(share),
            });
            const content = (
              <>
                <span className="eq-review-summary-stars" aria-hidden="true">
                  {whole.format(n)}
                  <Icon name="star" size={12} />
                </span>
                <span className="eq-review-summary-track" aria-hidden="true">
                  <span className="eq-review-summary-fill" style={{ width: `${share * 100}%` }} />
                </span>
                <span className="eq-review-summary-tally" aria-hidden="true">
                  {whole.format(count)}
                </span>
                <span className="eq-review-summary-percent" aria-hidden="true">
                  {percent.format(share)}
                </span>
                <span className="eq-review-summary-hidden">{spoken}</span>
              </>
            );
            return (
              <li key={n} className="eq-review-summary-row">
                {onFilter ? (
                  <button
                    type="button"
                    className="eq-review-summary-filter"
                    aria-pressed={active === n}
                    disabled={count === 0}
                    onClick={() => choose(n)}
                  >
                    {content}
                  </button>
                ) : (
                  <span className="eq-review-summary-line">{content}</span>
                )}
              </li>
            );
          })}
        </ul>
      </div>
    </section>
  );
}
components/equip/review-summary.css230 lines
@layer equip {
  .eq-review-summary {
    --_row: 24px;

    box-sizing: border-box;
    width: 100%;
    min-width: 0;
    container-type: inline-size;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  /* Stacked by default; summary beside the bars once the section is wide enough. */
  .eq-review-summary-layout {
    display: grid;
    gap: var(--eq-space-4);
  }

  @container (min-width: 460px) {
    .eq-review-summary[data-size="m"] .eq-review-summary-layout {
      grid-template-columns: minmax(150px, 200px) minmax(0, 1fr);
      align-items: start;
      gap: var(--eq-space-5);
    }
  }

  .eq-review-summary-overview {
    display: grid;
    align-content: start;
    gap: var(--eq-space-2);
    min-width: 0;
  }

  .eq-review-summary-heading {
    margin: 0;
    font-size: var(--eq-text-m);
    font-weight: var(--eq-weight-strong);
    line-height: 1.3;
  }

  .eq-review-summary-score {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--eq-space-1) var(--eq-space-2);
    margin: 0;
  }

  .eq-review-summary-average {
    font-size: calc(var(--eq-text-l) * 2.25);
    font-weight: var(--eq-weight-strong);
    line-height: 1;
    letter-spacing: -0.02em;
    font-variant-numeric: tabular-nums;
  }

  .eq-review-summary-count {
    flex-basis: 100%;
    color: var(--eq-ink-2);
  }

  .eq-review-summary-action {
    display: flex;
    flex-wrap: wrap;
    gap: var(--eq-space-2);
    margin-top: var(--eq-space-1);
  }

  /* Small: one line of average, stars and count over the bars. */
  .eq-review-summary[data-size="s"] {
    --_row: 18px;
  }

  .eq-review-summary[data-size="s"] .eq-review-summary-layout {
    gap: var(--eq-space-2);
  }

  .eq-review-summary[data-size="s"] .eq-review-summary-heading {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .eq-review-summary[data-size="s"] .eq-review-summary-average {
    font-size: var(--eq-text-l);
    letter-spacing: 0;
  }

  .eq-review-summary[data-size="s"] .eq-review-summary-count {
    flex-basis: auto;
    font-size: var(--eq-text-xs);
  }

  .eq-review-summary[data-size="s"] .eq-review-summary-percent {
    display: none;
  }

  .eq-review-summary-bars {
    display: grid;
    gap: 2px;
    min-width: 0;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .eq-review-summary-row {
    min-width: 0;
  }

  .eq-review-summary-line,
  .eq-review-summary-filter {
    box-sizing: border-box;
    display: grid;
    grid-template-columns: 2.4em minmax(0, 1fr) 5ch 4ch;
    align-items: center;
    gap: var(--eq-space-2);
    width: 100%;
    min-height: var(--_row);
    color: inherit;
    font: inherit;
    font-variant-numeric: tabular-nums;
  }

  .eq-review-summary[data-size="s"] :is(.eq-review-summary-line, .eq-review-summary-filter) {
    grid-template-columns: 2.4em minmax(0, 1fr) 5ch;
    font-size: var(--eq-text-xs);
  }

  .eq-review-summary-filter {
    /* A row you can press is at least 24px tall, even in the small size. */
    min-height: max(var(--_row), 24px);
    margin: 0;
    padding: 0 var(--eq-space-2);
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    text-align: start;
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    .eq-review-summary-filter:hover:not(:disabled, [aria-pressed="true"]) {
      background: var(--eq-tint);
    }
  }

  .eq-review-summary-filter:active:not(:disabled) {
    scale: var(--eq-press);
  }

  .eq-review-summary-filter:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-review-summary-filter:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-review-summary-filter[aria-pressed="true"] {
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
  }

  .eq-review-summary-stars {
    display: inline-flex;
    align-items: center;
    gap: 2px;
    font-weight: var(--eq-weight-strong);
  }

  .eq-review-summary-track {
    display: block;
    height: 8px;
    overflow: hidden;
    border-radius: var(--eq-radius-full);
    background: var(--eq-tint-strong);
  }

  .eq-review-summary-fill {
    display: block;
    height: 100%;
    border-radius: inherit;
    background: var(--eq-ink-2);
    transition: background-color var(--eq-dur) var(--eq-ease);
  }

  .eq-review-summary-filter[aria-pressed="true"] .eq-review-summary-track {
    background: color-mix(in srgb, var(--eq-solid-ink) 22%, transparent);
  }

  .eq-review-summary-filter[aria-pressed="true"] .eq-review-summary-fill {
    background: var(--eq-solid-ink);
  }

  .eq-review-summary-tally,
  .eq-review-summary-percent {
    text-align: end;
    white-space: nowrap;
  }

  .eq-review-summary-percent {
    color: var(--eq-ink-2);
  }

  .eq-review-summary-filter[aria-pressed="true"] .eq-review-summary-percent {
    color: inherit;
  }

  .eq-review-summary-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