Shows how many reviews stand behind a star rating and how they split, so a 5.0 from three reviews doesn't look as solid as a 4.6 from 900.
StopsTrusted a 5.0 from three reviews
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { bayesianScore, HonestRating, ratingStats, type RatingDistribution } from "@/components/ui/honest-rating"
const products: { name: string; distribution: RatingDistribution }[] = [
{ name: "Brass diya set", distribution: [0, 0, 0, 0, 3] },
{ name: "Clay diya pack of 12", distribution: [14, 6, 21, 180, 679] },
{ name: "LED string lights", distribution: [96, 10, 8, 22, 114] },
]
export function Demo() {
const [sort, setSort] = React.useState<"adjusted" | "average">("average")
const [picked, setPicked] = React.useState(0)
const [filter, setFilter] = React.useState<number>()
const order = products
.map((p, i) => ({ ...p, i, mean: ratingStats(p.distribution).mean, score: bayesianScore(p.distribution) }))
.sort((a, b) => (sort === "average" ? b.mean - a.mean : b.score - a.score))
return (
<div className="grid w-full max-w-xs gap-3">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted-foreground">Sort by</span>
<div role="radiogroup" aria-label="Sort by" className="flex rounded-md border p-0.5">
{(["average", "adjusted"] as const).map((s) => (
<button key={s} type="button" role="radio" aria-checked={sort === s} onClick={() => setSort(s)} className="h-7 rounded-sm px-2.5 font-medium text-muted-foreground transition-colors aria-checked:bg-accent aria-checked:text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
{s === "average" ? "Star average" : "Honest score"}
</button>
))}
</div>
</div>
<ol aria-label="Products" className="grid divide-y rounded-md border">
{order.map((p, rank) => (
<li key={p.name}>
<button
type="button"
aria-pressed={picked === p.i}
onClick={() => { setPicked(p.i); setFilter(undefined) }}
className={cn("flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors outline-none hover:bg-accent/60 focus-visible:ring-[3px] focus-visible:ring-ring/50", picked === p.i && "bg-accent")}
>
<span className="w-4 text-xs text-muted-foreground tabular-nums">{rank + 1}</span>
<span className="min-w-0 flex-1 truncate">{p.name}</span>
<span className="text-xs tabular-nums text-muted-foreground">
{(sort === "average" ? p.mean : p.score).toFixed(sort === "average" ? 1 : 2)} · {p.distribution.reduce((a, b) => a + b, 0)}
</span>
</button>
</li>
))}
</ol>
<HonestRating key={picked} distribution={products[picked].distribution} onSelectStars={setFilter} />
<p role="status" className="min-h-4 text-center text-xs text-muted-foreground">{filter ? `Showing ${filter}★ reviews` : ""}</p>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uihonest-rating.tsx196 lines"use client" // Bollard UI — honest-rating · MIT licence · https://bollard-ui.vercel.app/components/honest-rating import * as React from "react" import { Scale, Split, Star, TriangleAlert } from "lucide-react" import { cn } from "@/lib/utils" /** Review counts for 1★ to 5★, in that order. */ export type RatingDistribution = readonly [number, number, number, number, number] export interface RatingPrior { /** Rating new items are pulled toward: your catalogue's mean. */ mean: number /** How many reviews it takes to outweigh the prior. */ weight: number } export const DEFAULT_PRIOR: RatingPrior = { mean: 3.5, weight: 10 } /** * Bayesian average for sorting (the IMDb Top 250 formula): (C·m + Σ stars) / (C + n). * A 5.0 from 2 reviews no longer outranks a 4.6 from 900. */ export function bayesianScore(distribution: RatingDistribution, { mean, weight }: RatingPrior = DEFAULT_PRIOR) { const n = distribution.reduce((a, b) => a + b, 0) const stars = distribution.reduce((sum, count, i) => sum + count * (i + 1), 0) return (weight * mean + stars) / (weight + n) } /** * Count, mean, shares, a likely range for the true average (Evan Miller's Dirichlet approximation with a * light prior of one pseudo-review spread across the stars, 90%) and whether opinions split into love-it and hate-it camps. */ export function ratingStats(distribution: RatingDistribution) { const count = distribution.reduce((a, b) => a + b, 0) const mean = count ? distribution.reduce((s, c, i) => s + c * (i + 1), 0) / count : 0 const shares = distribution.map((c) => (count ? c / count : 0)) const k = distribution.length const a = 1 / k const m1 = distribution.reduce((s, c, i) => s + (i + 1) * (c + a), 0) / (count + 1) const m2 = distribution.reduce((s, c, i) => s + (i + 1) ** 2 * (c + a), 0) / (count + 1) const sd = Math.sqrt((m2 - m1 * m1) / (count + 2)) const range: [number, number] = [Math.max(1, m1 - 1.65 * sd), Math.min(5, m1 + 1.65 * sd)] const middle = shares[1] + shares[2] + shares[3] const polarized = count >= 10 && shares[0] >= 0.2 && shares[4] >= 0.3 && middle <= 0.35 return { count, mean, shares, range, polarized } } const one = (x: number) => (Math.round(x * 10) / 10).toFixed(1) const pct = (x: number) => `${Math.round(x * 100)}%` const fill = (t: string, v: Record<string, string>) => t.replace(/\{(\w+)\}/g, (m, key: string) => v[key] ?? m) const fixClass = "inline-flex h-6 items-center gap-1 rounded-md border border-amber-500/40 px-2 font-medium text-amber-900 transition-colors hover:bg-amber-500/10 dark:text-amber-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" function Stars({ value }: { value: number }) { return ( <span aria-hidden className="relative inline-flex text-muted-foreground/40"> {[0, 1, 2, 3, 4].map((i) => <Star key={i} className="size-4" />)} <span className="absolute inset-0 inline-flex overflow-hidden text-amber-500 dark:text-amber-400" style={{ width: `${(value / 5) * 100}%` }}> {[0, 1, 2, 3, 4].map((i) => <Star key={i} className="size-4 shrink-0 fill-current" />)} </span> </span> ) } export interface HonestRatingProps extends Omit<React.ComponentProps<"div">, "children"> { /** Review counts for 1★ to 5★. */ distribution: RatingDistribution /** Below this many reviews the score is flagged as a tiny sample. */ minReviews?: number /** Prior for the sorting score; pass your catalogue's mean. */ prior?: RatingPrior /** Called when a star row (or "Read the 1★ reviews") is chosen, to filter the reviews below. */ onSelectStars?: (stars: number) => void /** Templates: `{count}`, `{low}`, `{high}`, `{one}`, `{five}`, `{score}`, `{mean}`. */ countText?: string /** Shown when the review count is below minReviews. */ smallSampleText?: string /** Shown for a love-it-or-hate-it split. */ polarizedText?: string /** Explains the Bayesian sorting score. */ adjustedText?: string /** Button text to read the 1-star reviews. */ readLowLabel?: string /** Shown when there are no reviews. */ emptyText?: string } /** * Stops a "4.8" being trusted on its own. It shows how many reviews stand behind the score and how * they split, flags tiny samples with the range the true score could really be in, calls out * love-it-or-hate-it splits the average hides, and discloses the Bayesian score used for sorting. */ export function HonestRating({ distribution, minReviews = 10, prior = DEFAULT_PRIOR, onSelectStars, countText = "from {count}", smallSampleText = "Only {count}: the real score could be anywhere from {low} to {high}.", polarizedText = "Split opinions: {one} gave 1★ and {five} gave 5★. The average hides both.", adjustedText = "Ranked as {score} when sorting, pulled toward {mean} until it has more reviews.", readLowLabel = "Read the 1★ reviews", emptyText = "No reviews yet", className, ...props }: HonestRatingProps) { const stats = ratingStats(distribution) const score = bayesianScore(distribution, prior) const reviews = `${stats.count.toLocaleString("en-IN")} ${stats.count === 1 ? "review" : "reviews"}` const small = stats.count > 0 && stats.count < minReviews const max = Math.max(...distribution, 1) const [low, high] = stats.range return ( <div data-slot="honest-rating" data-state={!stats.count ? "empty" : small ? "small-sample" : stats.polarized ? "polarized" : "ok"} className={cn("grid w-full max-w-xs gap-3 rounded-lg border bg-card p-3 text-card-foreground", className)} {...props}> <style>{"@keyframes cui-honest-rating-grow{from{transform:scaleX(0)}}"}</style> <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <span className={cn("text-2xl font-semibold tabular-nums", small && "text-muted-foreground")}>{stats.count ? one(stats.mean) : "–"}</span> <Stars value={stats.mean} /> <span className={cn("text-sm tabular-nums", small ? "font-medium text-amber-700 dark:text-amber-400" : "text-muted-foreground")}> {stats.count ? fill(countText, { count: reviews }) : emptyText} </span> </div> {stats.count > 0 && ( <div role="img" aria-label={`Likely true score ${one(low)} to ${one(high)}`} className="relative h-1.5 rounded-full bg-muted" title={`Likely ${one(low)}–${one(high)}`}> <span className={cn("absolute inset-y-0 origin-left rounded-full motion-safe:animate-[cui-honest-rating-grow_400ms_ease-out]", small ? "bg-amber-500/40" : "bg-foreground/20")} style={{ left: `${((low - 1) / 4) * 100}%`, width: `${((high - low) / 4) * 100}%` }} /> <span className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card bg-foreground" style={{ left: `${((stats.mean - 1) / 4) * 100}%` }} /> </div> )} <ul aria-label="Rating breakdown" className="grid gap-0.5"> {[5, 4, 3, 2, 1].map((stars) => { const count = distribution[stars - 1] const label = `${stars} star: ${count} ${count === 1 ? "review" : "reviews"}, ${pct(stats.shares[stars - 1])}` const content = ( <> <span className="sr-only">{label}</span> <span aria-hidden className="w-6 shrink-0 text-muted-foreground">{stars}★</span> <span aria-hidden className="relative h-2 flex-1 overflow-hidden rounded-full bg-muted"> <span className={cn("absolute inset-y-0 left-0 origin-left rounded-full motion-safe:animate-[cui-honest-rating-grow_400ms_ease-out]", stats.polarized && (stars === 1 || stars === 5) ? "bg-amber-500" : "bg-foreground/70")} style={{ width: `${(count / max) * 100}%` }} /> </span> <span aria-hidden className="w-16 shrink-0 text-right tabular-nums text-muted-foreground"> {count.toLocaleString("en-IN")} <span className="text-muted-foreground/70">· {pct(stats.shares[stars - 1])}</span> </span> </> ) return ( <li key={stars}> {onSelectStars ? ( <button type="button" disabled={!count} onClick={() => onSelectStars(stars)} className="flex h-6 w-full items-center gap-2 rounded-sm px-1 text-xs transition-colors outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none"> {content} </button> ) : ( <div className="flex h-6 items-center gap-2 px-1 text-xs">{content}</div> )} </li> ) })} </ul> <div aria-live="polite" className="grid gap-1.5 text-xs"> {small && ( <p className="flex gap-1.5 text-amber-700 dark:text-amber-400"> <TriangleAlert aria-hidden className="mt-0.5 size-3.5 shrink-0" /> <span>{fill(smallSampleText, { count: reviews, low: one(low), high: one(high) })}</span> </p> )} {stats.polarized && ( <p className="flex gap-1.5 text-amber-700 dark:text-amber-400"> <span className="flex h-6 shrink-0 items-center"><Split aria-hidden className="size-3.5" /></span> <span className="flex min-w-0 flex-wrap items-center gap-x-2"> <span className="py-1">{fill(polarizedText, { one: pct(stats.shares[0]), five: pct(stats.shares[4]) })}</span> {onSelectStars && <button type="button" onClick={() => onSelectStars(1)} className={fixClass}>{readLowLabel}</button>} </span> </p> )} {stats.count > 0 && ( <p className="flex gap-1.5 text-muted-foreground"> <Scale aria-hidden className="mt-0.5 size-3.5 shrink-0" /> <span className="tabular-nums">{fill(adjustedText, { score: score.toFixed(2), mean: one(prior.mean) })}</span> </p> )} </div> </div> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { bayesianScore, HonestRating, ratingStats, type RatingDistribution } from "@/components/ui/honest-rating"
<HonestRating key={picked} distribution={products[picked].distribution} onSelectStars={setFilter} />What it catches
- Ratings backed by very few reviews, with a likely range for the true score
- Polarised splits hidden by an average
- Sorting by raw average: a Bayesian score (IMDb-style) is offered instead
- Optional link to read the low reviews first
Props
| Prop | Type | Default |
|---|---|---|
distribution* (required)Review counts for 1★ to 5★. | RatingDistribution | — |
minReviewsBelow this many reviews the score is flagged as a tiny sample. | number | 10 |
priorPrior for the sorting score; pass your catalogue's mean. | RatingPrior | DEFAULT_PRIOR |
onSelectStarsCalled when a star row (or "Read the 1★ reviews") is chosen, to filter the reviews below. | (stars: number) => void | — |
countTextTemplates: `{count}`, `{low}`, `{high}`, `{one}`, `{five}`, `{score}`, `{mean}`. | string | "from {count}" |
smallSampleTextShown when the review count is below minReviews. | string | "Only {count}: the real score could be anywhere from {low… |
polarizedTextShown for a love-it-or-hate-it split. | string | "Split opinions: {one} gave 1★ and {five} gave 5★. The av… |
adjustedTextExplains the Bayesian sorting score. | string | "Ranked as {score} when sorting, pulled toward {mean} unt… |
readLowLabelButton text to read the 1-star reviews. | string | "Read the 1★ reviews" |
emptyTextShown when there are no reviews. | string | "No reviews yet" |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- Breakdown is a labelled list with text labels for each star level
- The likely-range bar is role=img with an aria-label stating the range
- Warnings are in a polite live region with text, not only icons
- Star filters are real buttons, disabled when a level has no reviews
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Statistics assume reviews are genuine; it does not detect fake or incentivised reviews
- The prior and weight you pass change the Bayesian score; defaults may not suit your catalogue
- The likely range is an approximation (Dirichlet, 90%), not an exact interval
What it stops
Trusted a 5.0 from three reviews
How people describe it
Also known as
star rating · reviews · ratings · bayesian average · product page · flipkart · amazon · sort by rating
Field type