A five-bar rating breakdown that grows into view and lets you click a bar to filter reviews by star rating.
npx shadcn@latest add @paragon/review-histogram"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ReviewHistogramProps
extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
/** Counts per star rating, index 0 = 1★ … index 4 = 5★. */
counts: [number, number, number, number, number];
/** Selected star filter (1–5), or null for all. */
value?: number | null;
defaultValue?: number | null;
onValueChange?: (star: number | null) => void;
static?: boolean;
}
/**
* A five-bar rating breakdown. Bars scale up from the left when scrolled into
* view (staggered top-down), figures roll to their share. Clicking a row
* filters to that rating; clicking the active row clears it. Reduced motion
* renders bars at their final width without the grow.
*/
export function ReviewHistogram({
counts,
value: valueProp,
defaultValue = null,
onValueChange,
static: isStatic = false,
className,
...props
}: ReviewHistogramProps) {
const reduced = useReducedMotion() ?? false;
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.4 });
const animate = inView && !isStatic && !reduced;
const [uncontrolled, setUncontrolled] = React.useState<number | null>(
defaultValue,
);
const selected = valueProp !== undefined ? valueProp : uncontrolled;
const total = counts.reduce((a, b) => a + b, 0);
const average =
total === 0
? 0
: counts.reduce((sum, c, i) => sum + c * (i + 1), 0) / total;
const toggle = (star: number) => {
const next = selected === star ? null : star;
if (valueProp === undefined) setUncontrolled(next);
onValueChange?.(next);
};
return (
<div
ref={ref}
className={cn("flex w-full flex-col gap-4", className)}
{...props}
>
<div className="flex items-center gap-3">
<span className="text-3xl font-semibold tabular-nums leading-none">
{average.toFixed(1)}
</span>
<div>
<div className="flex" aria-hidden>
{Array.from({ length: 5 }, (_, i) => {
const fill = Math.max(0, Math.min(1, average - i));
return (
<span key={i} className="relative inline-flex">
<Star className="size-4 fill-transparent text-muted-foreground/30" />
{fill > 0 && (
<Star
className="absolute inset-0 size-4 fill-warning text-warning"
style={
fill < 1
? { clipPath: `inset(0 ${(1 - fill) * 100}% 0 0)` }
: undefined
}
/>
)}
</span>
);
})}
</div>
<p className="mt-0.5 text-xs text-muted-foreground tabular-nums">
{total.toLocaleString()} reviews
</p>
</div>
</div>
<div
role="group"
aria-label="Filter reviews by rating"
className="flex flex-col gap-1"
>
{[5, 4, 3, 2, 1].map((star, row) => {
const count = counts[star - 1];
const share = total === 0 ? 0 : count / total;
const isSelected = selected === star;
const dimmed = selected !== null && !isSelected;
return (
<button
key={star}
type="button"
aria-pressed={isSelected}
aria-label={`${star} star, ${count} reviews`}
onClick={() => toggle(star)}
className={cn(
"group flex items-center gap-2 rounded-md px-1 py-0.5 text-left outline-none transition-[background-color,opacity] duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
dimmed && "opacity-45",
)}
>
<span className="flex w-8 items-center gap-0.5 text-xs text-muted-foreground tabular-nums">
{star}
<Star className="size-3 fill-muted-foreground/40 text-transparent" />
</span>
<span className="relative h-2 flex-1 overflow-hidden rounded-full bg-secondary">
<motion.span
aria-hidden
className={cn(
"absolute inset-y-0 left-0 origin-left rounded-full",
isSelected ? "bg-primary" : "bg-warning",
)}
style={{ width: `${share * 100}%` }}
initial={
animate ? { scaleX: 0 } : { scaleX: 1 }
}
animate={{ scaleX: 1 }}
transition={{
duration: 0.5,
ease: [0.22, 1, 0.36, 1],
delay: animate ? row * 0.06 : 0,
}}
/>
</span>
<span className="w-9 text-right text-xs text-muted-foreground tabular-nums">
{total === 0 ? 0 : Math.round(share * 100)}%
</span>
</button>
);
})}
</div>
</div>
);
}