E-commerce

Review Histogram

A five-bar rating breakdown that grows into view and lets you click a bar to filter reviews by star rating.

Install

npx shadcn@latest add @paragon/review-histogram

review-histogram.tsx

"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>
  );
}