Rating Summary
A reviews summary: average with partial stars, a 5-to-1 histogram, and bars you click to filter reviews by score.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/rating-summary.json'use client'
import { useState } from 'react'
import './rating-summary.css'
export interface RatingSummaryProps {
counts: [number, number, number, number, number]
onFilter?: (stars: number | null) => void
showPercent?: boolean
fill?: 'dark' | 'light'
className?: string
}
const Star = () => <svg viewBox="0 0 16 16" aria-hidden><path d="M8 1.8l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.6l-3.8 2 .7-4.3-3.1-3 4.3-.6z" /></svg>
/** Average rating, histogram and score filter. */
export function RatingSummary({ counts, onFilter, showPercent = false, fill = 'dark', className }: RatingSummaryProps) {
const [f, setF] = useState<number | null>(null)
const total = counts.reduce((a, b) => a + b, 0) || 1
const avg = counts.reduce((a, c, i) => a + c * (5 - i), 0) / total
const max = Math.max(...counts)
const set = (n: number | null) => { setF(n); onFilter?.(n) }
return (
<div className={['sd-rsm', className].filter(Boolean).join(' ')} data-fill={fill} data-active={f !== null || undefined}>
<div className="sd-rsm__top">
<span className="sd-rsm__avg">{avg.toFixed(1)}</span>
<div>
<span className="sd-rsm__stars" role="img" aria-label={`${avg.toFixed(1)} out of 5 stars`}>
<span className="e">{[0, 1, 2, 3, 4].map((i) => <Star key={i} />)}</span>
<span className="f" style={{ width: `${(avg / 5) * 100}%` }}>{[0, 1, 2, 3, 4].map((i) => <Star key={i} />)}</span>
</span>
<div className="sd-rsm__n">{total.toLocaleString()} reviews</div>
</div>
</div>
<div className="sd-rsm__bars">
{counts.map((c, i) => {
const s = 5 - i
return (
<button key={s} type="button" className="sd-rsm__b" aria-pressed={f === s} aria-label={`${s} stars, ${c} reviews, ${Math.round((c / total) * 100)}%`} onClick={() => set(f === s ? null : s)}>
<span>{s} ★</span>
<span className="sd-rsm__track"><i style={{ width: `${(c / max) * 100}%`, animationDelay: `${i * 60}ms` }} /></span>
<span className="sd-rsm__c">{showPercent ? `${Math.round((c / total) * 100)}%` : c.toLocaleString()}</span>
</button>
)
})}
</div>
<div className="sd-rsm__st" aria-live="polite">{f !== null && <>Showing {counts[5 - f].toLocaleString()} {f}-star reviews<button type="button" onClick={() => set(null)}>Clear</button></>}</div>
</div>
)
}.sd-rsm { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-rsm[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-rsm button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-rsm :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-rsm .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-rsm { width: min(100%, 320px); padding: 16px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-rsm__top { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
.sd-rsm__avg { font-size: 40px; font-weight: 650; letter-spacing: -0.03em; line-height: 1; }
.sd-rsm__stars { position: relative; display: inline-flex; gap: 2px; }
.sd-rsm__stars svg { width: 16px; height: 16px; }
.sd-rsm__stars .e path { fill: var(--soft); stroke: var(--line); }
.sd-rsm__stars .f { position: absolute; inset: 0; display: inline-flex; gap: 2px; overflow: hidden; white-space: nowrap; }
.sd-rsm__stars .f path { fill: var(--fg); }
.sd-rsm__n { font-size: 12.5px; color: var(--mute); margin-top: 4px; }
.sd-rsm__bars { display: grid; gap: 3px; }
.sd-rsm__b { display: grid; grid-template-columns: 26px 1fr 40px; gap: 10px; align-items: center; padding: 4px 6px; border-radius: 8px; font-size: 12.5px; text-align: left; transition: opacity 200ms, background-color 150ms; }
.sd-rsm__b:hover { background: var(--soft) !important; }
.sd-rsm[data-active] .sd-rsm__b:not([aria-pressed='true']) { opacity: 0.35; }
.sd-rsm__b[aria-pressed='true'] { background: var(--soft) !important; }
.sd-rsm__track { height: 8px; border-radius: 99px; background: var(--soft); overflow: hidden; }
.sd-rsm__track i { display: block; height: 100%; border-radius: 99px; background: var(--fg); transform-origin: left; animation: sd-rsm-grow 700ms cubic-bezier(0.3, 1, 0.4, 1) both; }
@keyframes sd-rsm-grow { from { transform: scaleX(0); } }
.sd-rsm__c { text-align: right; color: var(--mute); }
.sd-rsm__st { margin-top: 10px; min-height: 18px; font-size: 12.5px; color: var(--mute); }
.sd-rsm__st button { text-decoration: underline; text-underline-offset: 3px; color: var(--fg); margin-left: 6px; }
@media (prefers-reduced-motion: reduce) { .sd-rsm *, .sd-rsm *::before, .sd-rsm *::after { animation: none !important; transition: none !important; } }Overview
The average shows with stars filled to the exact fraction. Each histogram bar is a button; clicking one filters to that score and the others dim, clicking again clears it.
Bars grow in on mount and counts are shown beside them.
When to use
- Product pages, app listings, course pages.
When not to use
- Fewer than ~10 reviews: show the reviews directly.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/rating-summary.json$
npx shadcn@latest add https://sundaeui.dev/r/rating-summary.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/rating-summary.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/rating-summary.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy rating-summary.tsx and rating-summary.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { RatingSummary } from '@/components/sundae/rating-summary'
<RatingSummary />Props
| Prop | Type | Default |
|---|---|---|
counts*Review counts for 5 → 1 stars. | [number, number, number, number, number] | — |
onFilterCalled when a bar is toggled. | (stars: number | null) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Stars are an image with “4.6 out of 5”; bars are toggle buttons like “4 stars, 212 reviews, 31%”.
- The active filter is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-rsm; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Rating Summary free?
- Yes. Rating Summary is free for personal and commercial projects, with full source on this page.
- How do I install Rating Summary?
- Run npx shadcn@latest add https://sundaeui.dev/r/rating-summary.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Rating Summary support keyboard and screen readers?
- Stars are an image with “4.6 out of 5”; bars are toggle buttons like “4 stars, 212 reviews, 31%”. The active filter is announced.
- Does Rating Summary respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →- Pro
Histogram
Data · Refined Stacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined Countdown Timer
Data · Refined- Pro
Donut Breakdown
Data · Refined