Rating Summary
A review digest: average score, fractional star row and per-bucket distribution bars that double as filter toggles.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/rating-summary.jsonPrompt
Build a React + TypeScript + Tailwind "RatingSummary" component (lucide-react Star).
Contract
- Export a forwardRef<HTMLElement> <figure> extending React.HTMLAttributes<HTMLElement>;
spread the remaining props onto the root and merge className with cn().
- Props: average (number), total (number),
distribution (Partial<Record<1|2|3|4|5, number>>),
onFilterChange? ((star: number | null) => void),
activeFilter? (number | null, default null),
showBars? (default true), recommendPercent? (0-100),
breakdown? ({ label: string; value: number }[]),
size? ("sm" | "md", default "md"), locale? (default "en-US").
- It is an aggregate *display*, not an input: it never asks the user for a score.
- Fully controlled and stateless: `activeFilter` is owned by the host, and clicking a
bucket only reports intent. The component holds no useState/useEffect at all, so it
can render on the server and never fights the host's list state.
- Mode is derived, not configured: passing onFilterChange makes every bar a toggle
button; omitting it renders a read-only digest with identical layout.
Behavior
- Clamp every numeric prop at the edge instead of trusting the caller: average to
0-5, total and each bucket count to >= 0 and floored to integers, recommendPercent
to 0-100, each breakdown value to 0-5. Non-finite input (NaN/undefined) becomes 0.
- total === 0 is a first-class branch, not an `&&`: a zero-filled star row, a
"No reviews yet" line and one sentence saying the numbers appear after the first
review. It returns before any percentage is computed, so total is never a divisor
of zero — the histogram simply does not exist yet.
- The histogram always renders all five buckets in 5 -> 1 order. A missing key in
`distribution` counts as 0 rather than dropping a row, so the block keeps a stable
height and "nobody gave 2 stars" stays visible instead of silently vanishing.
- Each bar's width is count / total clamped to [0, 1] — a share of `total`, never of
the summed buckets. A distribution that does not add up to total therefore
under-fills the bars instead of overflowing past 100%.
- Fractional stars come from two stacked layers per star: a muted outline plus a
filled copy cut with clip-path: inset(0 (1 - fraction) * 100% 0 0). That makes
4.1 / 4.5 / 4.9 three visibly different fifth stars rather than three identical
half-star icons.
- Bars grow once on mount (scaleX 0 -> 1 from origin-left) via a keyframe in a
hoisted <style href precedence> tag — no Tailwind config edit. It is decorative:
motion-reduce:animate-none drops it and the bar is still drawn at its final width.
- Every number goes through Intl.NumberFormat built from the explicit `locale`
(integer counts, one-decimal score, percent), memoised per locale. Never
Intl.*(undefined), so the server and the client format identically. Counts and
scores use tabular-nums so a re-render cannot make digits wobble.
- Singular vs plural is real text, not "(s)": "1 review" / "N reviews",
"1 star" / "N stars".
- Toggle semantics: clicking the already-active bucket calls onFilterChange(null).
The button keeps a stable aria-label and reports state only through aria-pressed —
a toggle that renames itself gets its state announced twice.
- Buckets with zero reviews stay clickable: filtering to them is a legitimate action
that yields an empty list. Disable them only if the host list cannot render empty.
- The optional footer (recommend share, sub-scores) sits behind a top border and
disappears entirely when neither prop is passed — no empty divider.
Rendering & styling
- Semantic tokens only: text-foreground / text-muted-foreground (with /40 and /60 for
the empty star outline and the row glyph), bg-muted for the track, bg-primary for
the active bar and filled stars, bg-primary/45 for idle bars, bg-primary/10 for the
active row tint, border for the footer rule, ring for focus. No hex/rgb/oklch, and
no chart tokens on text — a monochrome palette would make those invisible.
- Layout responds to the component's own width with container queries
(@container + @md:), not viewport breakpoints: the score column moves beside the
histogram only when the component itself is wide enough, so the same markup works
in a 280px sidebar card and in a full-width panel.
- Accessibility: the star graphics are aria-hidden and the scale exists as sr-only
text ("out of 5") next to the score and next to each sub-score; the figure carries
an aria-label summarising average + review count.
- The bars are a <ul role="list"> whose <li> are direct children (no unrole'd
wrapper in between, or screen readers announce an empty list). Inside a row, the
numerals and the bar are one aria-hidden picture and the meaning is carried exactly
once: sr-only text in read-only mode, the button's aria-label in interactive mode.
Nothing gets read as a stray "5 ... 96" pair, and no bar is an unnamed progressbar.
- Filter buttons are the only interactive elements: focus-visible:ring-2 ring-ring,
hover tint, cursor-pointer, and transition-colors that motion-reduce turns off.
Customization levers
- Sub-blocks are independent switches: drop showBars, recommendPercent and breakdown
to get a bare "4.6 ***** 128 reviews" line for a product card; keep only the bars
for a reviews sidebar.
- Density: one SIZES map holds every per-size class (score type scale, star size,
track height, row text). Add an "lg" key for a hero placement instead of sprinkling
conditionals through the JSX.
- Bar tone: idle vs active is bg-primary/45 vs bg-primary. Move both to
var(--chart-1) as a fill when primary is reserved for CTAs, or make the active row
a tinted pill with color-mix(in oklab, var(--primary) 12%, transparent).
- Row order: ROW_ORDER is [5,4,3,2,1] — reverse it for a chart-style ascending axis.
- Scale: MAX_STARS drives the star row, the clamp and the "out of N" text; a 10-point
score works by widening the distribution key type and that one constant.
- Motion: the entrance is one 600ms ease-out keyframe; shorten it, stagger it per row
with an animation-delay of index * 40ms, or delete the animate-[] class entirely.
- Interaction: swap onFilterChange for a navigating handler (push
/reviews?stars=5) and keep activeFilter derived from the URL — the component does
not care where the state lives.
- Locale: pass locale="de-DE" (or the user's negotiated locale) and every count,
score and percentage follows; nothing else needs translating except the labels you
own.Concepts
- Bucket as filter entry point — the histogram is not just a picture: each row is the affordance for "show me only the 2-star reviews", which is why the same component covers a static product card and an interactive reviews panel.
- Share of total, not of the sum — bar width divides by
total, so a distribution that arrives incomplete under-fills honestly instead of stretching to a fake 100%. - Controlled highlight — the component keeps no state;
activeFilterflows in from whoever owns the review list, so URL-driven, reducer-driven and local filtering all work without a second source of truth. - Aggregate, not input — there is no hover preview and no keyboard value change here: the score is a fact being reported, which is what separates it from a star input.
- Fractional clip beats the half-star icon — clipping a filled star by
(1 - fraction)renders 4.1, 4.5 and 4.9 as three distinguishable fills with one code path. - One announcement per row — the numerals plus the bar are hidden as a single picture and the row's full meaning ("4 stars: 22 reviews, 17% of total") is spoken once, so nothing is read as a bare "4 ... 22".
Profile Card
One person card in three densities (vertical, horizontal, compact) with presence dot, badges, stats, a stretched-link name and initials fallback.
Product Card
A storefront product card — one stretched link, variants that swap the photo, a discount derived from the was-price, a sold-out veil and a four-phase Add to cart that never fakes success.