Review summary

Big average, star histogram that grows into view, write a review and most mentioned chips.

blocksummaryanyreviews
$npx shadcn@latest add @storekit/reviews-summary
reviews-summary

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/reviews-summary
$pnpm dlx shadcn@latest add @storekit/reviews-summary
$yarn dlx shadcn@latest add @storekit/reviews-summary
$bunx --bun shadcn@latest add @storekit/reviews-summary

Source

components/storekit/reviews/reviews-summary.tsx
104 lines
"use client"

import { motion, useInView, useReducedMotion } from "motion/react"
import { PenLine } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { Stars } from "@/components/storekit/ui/stars"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Review summary: the average as a big numeral, a five row histogram whose
   bars grow from the left when scrolled into view, and the most mentioned
   topics as chips. */

const EASE = [0.23, 1, 0.32, 1] as const
const RING = "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"

export function ReviewsSummary({
  average = 4.6,
  /** Counts for 5, 4, 3, 2, 1 stars. */
  distribution = [962, 214, 61, 25, 22],
  mentions = [
    { label: "Taste", count: 418 },
    { label: "Shipping", count: 296 },
    { label: "Packaging", count: 187 },
  ],
  onWrite,
  onMention,
  className,
}: {
  average?: number
  distribution?: number[]
  mentions?: { label: string; count: number }[]
  onWrite?: () => void
  onMention?: (label: string) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("reviews", "reviews-summary")
  // One observer for the whole block so every bar grows together.
  const inView = useInView(sk.ref, { once: true, amount: 0.2 })
  const total = distribution.reduce((a, b) => a + b, 0)
  const max = Math.max(1, ...distribution)

  return (
    <section {...sk.attrs} ref={sk.ref as React.RefObject<HTMLElement>} className={cn("grid w-full gap-8 rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6 md:grid-cols-[auto_1fr] md:gap-12", className)} aria-label="Review summary">
      <div className="flex flex-col gap-3">
        <span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Customer reviews</span>
        <span className="font-sk-display text-6xl font-semibold leading-[1.1] tracking-[-0.02em] tabular-nums text-sk-ink">{average.toFixed(1)}</span>
        <Stars value={average} size={16} />
        <span className="text-sm text-sk-muted">
          Based on <span className="tabular-nums">{total.toLocaleString()}</span> reviews
        </span>
        <Button variant="outline" className="mt-2 w-full sm:w-auto" onClick={() => {
            sk.emit("interact", "write_review", undefined)
            onWrite?.()
          }}>
          <PenLine size={15} aria-hidden /> Write a review
        </Button>
      </div>

      <div className="flex flex-col justify-center gap-6">
        <ul className="flex flex-col gap-2">
          {distribution.map((n, i) => {
            const stars = 5 - i
            const pct = total ? Math.round((n / total) * 100) : 0
            return (
              <li key={stars} className="grid grid-cols-[3.5rem_1fr_3rem] items-center gap-3 text-sm" aria-label={`${stars} star: ${pct}%`}>
                <span className="tabular-nums text-sk-muted">{stars} star</span>
                <span className="relative h-2 overflow-hidden rounded-full bg-sk-surface" aria-hidden>
                  <motion.span
                    className="absolute inset-y-0 left-0 origin-left rounded-full bg-sk-accent"
                    style={{ width: `${(n / max) * 100}%` }}
                    initial={{ scaleX: reduce ? 1 : 0 }}
                    animate={{ scaleX: inView || reduce ? 1 : 0 }}
                    transition={{ duration: reduce ? 0 : 0.6, ease: EASE, delay: reduce ? 0 : 0.1 + i * 0.05 }}
                  />
                </span>
                <span className="text-right font-sk-mono text-[12px] tabular-nums text-sk-muted">{pct}%</span>
              </li>
            )
          })}
        </ul>

        <div className="flex flex-col gap-2.5">
          <span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Most mentioned</span>
          <div className="flex flex-wrap gap-2">
            {mentions.map((m) => (
              <button
                key={m.label}
                type="button"
                onClick={() => onMention?.(m.label)}
                className={cn("inline-flex h-11 items-center gap-2 rounded-sk-chip border border-sk-line px-4 text-sm text-sk-ink transition-[background-color,transform] duration-200 hover:bg-sk-ink/[0.04] active:scale-[0.97]", RING)}
              >
                {m.label}
                <span className="font-sk-mono text-[11px] tabular-nums text-sk-muted">{m.count}</span>
              </button>
            ))}
          </div>
        </div>
      </div>
    </section>
  )
}

Usage

Import from "@/components/storekit/reviews/reviews-summary" and pass your product data. The demo above exercises the full prop surface with sample values.