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
Customer reviews
Rated 4.6 out of 5Based on 1,284 reviews
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
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
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
| Prop | Type | Default | What it does |
|---|---|---|---|
distribution | Record<1 | 2 | 3 | 4 | 5, number> | none | Number of reviews at each star level. The total and the percentages come from it. |
average | number | none | The average shown. Worked out from distribution when left out. |
onFilter | (stars: 1 | 2 | 3 | 4 | 5 | null) => void | none | Makes each row a toggle button. Called with null when the active row is pressed again. |
filter | 1 | 2 | 3 | 4 | 5 | null | none | The active row, controlled. |
defaultFilter | 1 | 2 | 3 | 4 | 5 | null | null | The active row to start with, uncontrolled. |
action | ReactNode | none | A 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. |
locale | string | "en-US" | Formats the average, the counts and the percentages. |
headingLevel | 2 | 3 | 4 | 3 | Level of the section heading. |
labels | Partial<ReviewSummaryLabels> | none | Wording templates: heading, basedOn, average, row, rowOne and distribution, with {count}, {average}, {stars} and {percent}. |
...rest | ComponentProps<"section"> | none | Every native section attribute, including lang and style. |
Anatomy
| root | .eq-review-summary | A section labelled by its heading, and the query container. |
|---|---|---|
| layout | .eq-review-summary-layout | The grid that puts the overview beside the bars when wide. |
| overview | .eq-review-summary-overview | |
| heading | .eq-review-summary-heading | |
| score | .eq-review-summary-score | Average, stars and count. |
| average | .eq-review-summary-average | |
| count | .eq-review-summary-count | |
| action | .eq-review-summary-action | |
| bars | .eq-review-summary-bars | A list, one item per star level. |
| row | .eq-review-summary-row | |
| line | .eq-review-summary-line | A row without onFilter. |
| filter | .eq-review-summary-filter | A row with onFilter: a toggle button. |
| stars | .eq-review-summary-stars | The star level with a small star glyph. |
| track | .eq-review-summary-track | |
| fill | .eq-review-summary-fill | Its width is the share of all reviews. |
| tally | .eq-review-summary-tally | The visible count for the row. |
| percent | .eq-review-summary-percent | |
| hidden | .eq-review-summary-hidden | Visually hidden sentences for screen readers. |
Keyboard
| Tab | With onFilter, moves through the star rows. |
|---|---|
| EnterSpace | Filters 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;
}
}