Display

Rating Summary

A review digest: average score, fractional star row and per-bucket distribution bars that double as filter toggles.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Bars grow in with a hoisted <style> (React 19 dedupes by href) instead of a
 * Tailwind keyframe config edit. Decorative only — `motion-reduce:animate-none`
 * drops it and the bar is still drawn at its final width.
 */
const KEYFRAMES = `@keyframes zrs-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}`

const MAX_STARS = 5

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/rating-summary.json

Prompt

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; activeFilter flows 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".

On This Page