Patterns
PatternsTier 5 · Blocks
Rating
Stars with a count, and the five-bar breakdown.
bunx --bun shadcn@latest add @woohl/ratingPreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/ratingregistry/tier-5-blocks/rating.tsx
import { cn } from "@/registry/tier-2-primitives/lib/utils";
/**
* Rating.
*
* Five stars in the brand accent, the number beside them, the count in
* brackets.
*
* THE STARS ARE DECORATIVE, THE NUMBER IS THE CONTENT
* The star row is one `role="img"` with a full accessible name — "4.9 out of
* 5, 128 reviews" — rather than five separate images. Five stars announced
* individually is five pieces of noise, and a half-filled star has no
* accessible representation at all.
*
* Stars are filled to the nearest half, which is what the number already says.
* Do not round to whole stars — a 4.4 shown as 4 stars is a visible
* contradiction of the "4.4" printed next to it.
*/
export type RatingProps = {
/** 0 to 5. */
value: number;
/** Number of reviews. Omitted rather than shown as "(0)". */
count?: number;
size?: number;
/** Hide the numeric value — only for a dense grid where space is genuinely
gone. The stars alone are harder to compare at a glance. */
hideValue?: boolean;
className?: string;
};
export function Rating({
value,
count,
size = 15,
hideValue = false,
className,
}: RatingProps) {
const label = count
? `${value.toFixed(1)} out of 5, ${count.toLocaleString("en-IN")} reviews`
: `${value.toFixed(1)} out of 5`;
return (
<span
data-slot="rating"
className={cn("font-ui inline-flex items-center gap-1.5", className)}
>
<span role="img" aria-label={label} className="inline-flex gap-px">
{[1, 2, 3, 4, 5].map((position) => (
<Star key={position} fill={fillFor(value, position)} size={size} />
))}
</span>
{!hideValue && (
<span aria-hidden className="text-label text-text-primary font-bold">
{value.toFixed(1)}
</span>
)}
{count !== undefined && count > 0 && (
<span aria-hidden className="text-caption text-text-muted">
({count.toLocaleString("en-IN")})
</span>
)}
</span>
);
}
function fillFor(value: number, position: number): "full" | "half" | "empty" {
if (value >= position) return "full";
if (value >= position - 0.5) return "half";
return "empty";
}
function Star({
fill,
size,
}: {
fill: "full" | "half" | "empty";
size: number;
}) {
const path =
"m12 4 2.5 5.2 5.5.8-4 4 .9 5.5-4.9-2.7-4.9 2.7.9-5.5-4-4 5.5-.8L12 4Z";
const id = `half-${fill}-${size}`;
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
aria-hidden
className="flex-none"
>
{fill === "half" && (
<defs>
<linearGradient id={id}>
<stop offset="50%" stopColor="var(--surface-accent)" />
<stop offset="50%" stopColor="var(--border-strong)" />
</linearGradient>
</defs>
)}
<path
d={path}
fill={
fill === "full"
? "var(--surface-accent)"
: fill === "half"
? `url(#${id})`
: "var(--border-strong)"
}
/>
</svg>
);
}
/**
* The distribution bars on a product's review section — how many 5-star, how
* many 4-star, and so on.
*
* A single average hides the shape: 4.2 from a hundred 4s and 4.2 from fifty 5s
* and fifty 3s are different products.
*/
export function RatingBreakdown({
distribution,
className,
}: {
/** Counts for 5, 4, 3, 2, 1 stars, in that order. */
distribution: [number, number, number, number, number];
className?: string;
}) {
const total = distribution.reduce((sum, count) => sum + count, 0);
return (
<div
data-slot="rating-breakdown"
className={cn("font-ui flex flex-col gap-1.5", className)}
>
{distribution.map((count, index) => {
const stars = 5 - index;
const percent = total === 0 ? 0 : Math.round((count / total) * 100);
return (
<div key={stars} className="text-caption flex items-center gap-2.5">
<span className="text-text-secondary w-10">{stars} star</span>
<span
role="img"
aria-label={`${percent}% of reviews are ${stars} star`}
className="rounded-pill bg-surface-sunken h-2 flex-1 overflow-hidden"
>
<span
className="rounded-pill bg-surface-accent block h-full"
style={{ width: `${percent}%` }}
/>
</span>
<span className="text-text-muted w-8 text-right">{percent}%</span>
</div>
);
})}
</div>
);
}
RatingProps
| Prop | Type | Default |
|---|---|---|
value*0 to 5. | number | — |
className | string | — |
countNumber of reviews. Omitted rather than shown as "(0)". | number | — |
hideValueHide the numeric value — only for a dense grid where space is genuinely gone. The stars alone are harder to compare at a glance. | boolean | — |
size | number | — |
Registry items
npm packages
None beyond React itself.