The shape of a set of ratings: the average with partly filled stars, the count, and a bar per star size that can filter the reviews beside it.
4.3
2,219 ratings, average 4.3 out of 5
Showing all reviews.
"use client"
import * as React from "react"
import { RatingDistribution } from "@/components/ballmac/rating-distribution"
const counts = { 5: 1284, 4: 612, 3: 188, 2: 74, 1: 61 }
export default function RatingDistributionDemo() {
const [sel, setSel] = React.useState<1 | 2 | 3 | 4 | 5 | null>(null)
return (
<div className="grid w-full max-w-xl gap-3">
<RatingDistribution counts={counts} selected={sel} onSelect={setSel} />
<p aria-live="polite" className="text-sm text-muted-foreground">{sel ? `Showing ${sel}-star reviews only.` : "Showing all reviews."}</p>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/rating-distributionInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/rating-distribution.tsx// Ballmac UI: Rating Distribution. https://ui.ballmac.com/components/rating-distribution "use client" import * as React from "react" import { Star } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type RatingCounts = { 1: number; 2: number; 3: number; 4: number; 5: number } type RatingDistributionProps = Omit<React.ComponentProps<"div">, "onSelect"> & { /** How many ratings of each size. */ counts: RatingCounts /** The selected size, to filter reviews with. Controlled. */ selected?: 1 | 2 | 3 | 4 | 5 | null /** Makes each row a button. Called with the size, or null when it is cleared. */ onSelect?: (rating: 1 | 2 | 3 | 4 | 5 | null) => void } const nf = new Intl.NumberFormat("en-US") const STARS = [5, 4, 3, 2, 1] as const /** * The shape of a set of ratings: the average, the count, and a bar for each star size. Rows can be buttons that filter the reviews beside them. The average is * written as a number and the stars are decoration; a partly filled star shows the decimals. */ function RatingDistribution({ counts, selected, onSelect, className, ...props }: RatingDistributionProps) { const msg = useMessages() const total = STARS.reduce((s, k) => s + counts[k], 0) const avg = total ? STARS.reduce((s, k) => s + k * counts[k], 0) / total : 0 const max = Math.max(1, ...STARS.map((k) => counts[k])) return ( <div data-slot="rating-distribution" className={cn("grid min-w-0 gap-5 sm:grid-cols-[auto_minmax(0,1fr)] sm:gap-8", className)} {...props}> <div className="grid content-center gap-1.5"> <p className="text-5xl font-semibold tracking-[-0.04em] tabular-nums">{avg ? avg.toFixed(1) : "–"}</p> <div aria-hidden="true" className="flex gap-0.5"> {[1, 2, 3, 4, 5].map((n) => { const fill = Math.min(1, Math.max(0, avg - (n - 1))) return ( <span key={n} className="relative size-5"> <Star className="absolute inset-0 size-5 text-border" /> <span className="absolute inset-y-0 start-0 overflow-hidden" style={{ width: `${fill * 100}%` }}> <Star className="size-5 fill-foreground text-foreground" /> </span> </span> ) })} </div> <p className="text-sm text-muted-foreground">{msg("rating-distribution.total", "{count} ratings, average {avg} out of 5", { count: nf.format(total), avg: avg ? avg.toFixed(1) : "0" })}</p> </div> <ul className="grid grid-cols-[minmax(0,1fr)] content-center gap-1"> {STARS.map((k) => { const on = selected === k const n = counts[k] const content = ( <> <span className="flex w-8 shrink-0 items-center gap-1 text-sm font-medium tabular-nums"> {k} <Star aria-hidden="true" className="size-3 fill-current" /> </span> <span aria-hidden="true" className="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-muted"> <span className={cn("block h-full rounded-full transition-[width] duration-500 motion-reduce:transition-none", on || selected == null ? "bg-foreground" : "bg-muted-foreground/40")} style={{ width: `${(n / max) * 100}%` }} /> </span> <span className="w-20 shrink-0 text-end text-sm tabular-nums text-muted-foreground"> {nf.format(n)} <span className="ms-1.5 text-xs">{msg("rating-distribution.share", "{percent}%", { percent: total ? Math.round((n / total) * 100) : 0 })}</span> </span> </> ) return ( <li key={k}> {onSelect ? ( <button type="button" aria-pressed={on} aria-label={msg("rating-distribution.row", "{stars} stars: {count} ratings. Show only these", { stars: k, count: nf.format(n) })} onClick={() => onSelect(on ? null : k)} className="flex min-h-9 w-full items-center gap-3 rounded-md px-2 text-start outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-accent"> {content} </button> ) : ( <div className="flex min-h-9 items-center gap-3 px-2">{content}</div> )} </li> ) })} </ul> </div> ) } export { RatingDistribution, type RatingCounts, type RatingDistributionProps }Update the import paths to match your project setup.
Usage
import { RatingDistribution } from "@/components/ballmac/rating-distribution"The full example is in the Code tab above.
Examples
Few ratings
4.7
16 ratings, average 4.7 out of 5
- 51275%
- 4319%
- 316%
- 200%
- 100%
"use client"
import { RatingDistribution } from "@/components/ballmac/rating-distribution"
export default function RatingDistributionStatic() {
return (
<div className="w-full max-w-md">
<RatingDistribution counts={{ 5: 12, 4: 3, 3: 1, 2: 0, 1: 0 }} />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
counts*How many ratings of each size. | RatingCounts | — |
selectedThe selected size, to filter reviews with. Controlled. | 1 | 2 | 3 | 4 | 5 | null | — |
onSelectMakes each row a button. Called with the size, or null when it is cleared. | (rating: 1 | 2 | 3 | 4 | 5 | null) => void | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Summary | The average and total are text; the stars are decoration |
| Rows | Buttons named '4 stars: 2 ratings. Show only these' with pressed state |
| Counts | Shown as numbers and percents beside each bar |
Use with AI
counts is { 1, 2, 3, 4, 5 } (how many of each). The average and total are computed and written as text. onSelect(rating | null) with selected makes each row a toggle button for filtering a review list. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Rating Distribution (@ballmac/rating-distribution) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Above a list of product, app or course reviews
- A feedback summary
Not for
- Collecting a rating; use rating
- Satisfaction over time; use chart-line
Registry JSON: https://ui.ballmac.com/r/rating-distribution.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Rating
A native radio-group star rating with keyboard arrows, value text, and a clear action.
Bar List
A ranked list of bars with the name and number on top of the bar, so both stay readable at any width. Rows can be links or buttons, a long list folds to its top few, and every value is real text.
Split Bar
One bar cut into shares with a legend that says the same in numbers. Pointing at a legend row lights its slice and dims the rest, and choosing it keeps it lit.
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.