Review summary
Big average, star histogram that grows into view, write a review and most mentioned chips.
blocksummaryanyreviews
$
npx shadcn@latest add @storekit/reviews-summaryreviews-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-summarySource
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.