Review list
Filter chips, sort, review cards with verified badge, photo thumb and a springy helpful count.
npx shadcn@latest add @storekit/reviews-listLive 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-listpnpm dlx shadcn@latest add @storekit/reviews-listyarn dlx shadcn@latest add @storekit/reviews-listbunx --bun shadcn@latest add @storekit/reviews-listSource
"use client"
import { useMemo, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { BadgeCheck, ChevronDown, ThumbsUp } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { Stars } from "@/components/storekit/ui/stars"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Review list with single-select filter chips and a sort. Cards re-flow with
motion layout when the filter changes; "Helpful" counts tick up with a
small spring. */
type Review = {
id: string
rating: number
title: string
body: string
author: string
verified?: boolean
date: string
photo?: string
helpful: number
}
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"
const DEFAULT_REVIEWS: Review[] = [
{ id: "r1", rating: 5, title: "Finally a greens I like", body: "Tastes bright, not grassy, and mixes clean in cold water with no clumps. I have more steady energy by mid-morning.", author: "Dana R.", verified: true, date: "2026-09-21", photo: "/brands/morrow/greens.webp", helpful: 24 },
{ id: "r2", rating: 5, title: "Sleeping through the night", body: "Two Magnesium Night gummies after dinner and I wake up less. They taste like real berries, not candy.", author: "Marcus T.", verified: true, date: "2026-09-12", helpful: 11 },
{ id: "r3", rating: 4, title: "Great taste, scoop could be bigger", body: "The vanilla protein is smooth and not chalky. The scoop is a little small, so I use one and a half.", author: "Priya K.", date: "2026-08-30", photo: "/brands/morrow/protein.webp", helpful: 6 },
{ id: "r4", rating: 5, title: "Subscription is set and forget", body: "Third month on subscribe and save. It shows up before I run out, and skipping a month takes two taps.", author: "Ellis W.", verified: true, date: "2026-08-14", helpful: 17 },
]
const FILTERS = ["All", "5 star", "With photos", "Verified"] as const
type Filter = (typeof FILTERS)[number]
type Sort = "recent" | "helpful" | "rating"
function fmtDate(iso: string) {
return new Date(iso + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
function Helpful({ count, onVote }: { count: number; onVote: () => void }) {
const reduce = useReducedMotion()
const [voted, setVoted] = useState(false)
return (
<button
type="button"
aria-pressed={voted}
onClick={() => {
if (voted) return
setVoted(true)
onVote()
}}
className={cn(
"inline-flex h-11 items-center gap-2 rounded-sk-btn px-3 text-[13px] transition-[background-color,color,transform] duration-200 active:scale-[0.97]",
voted ? "text-sk-accent" : "text-sk-muted hover:bg-sk-ink/[0.04] hover:text-sk-ink",
RING
)}
>
<ThumbsUp size={14} aria-hidden className={voted ? "fill-current" : undefined} />
Helpful
<span className="relative inline-grid min-w-[2ch] overflow-hidden tabular-nums">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={count}
initial={{ y: reduce ? 0 : 10, opacity: 0, scale: reduce ? 1 : 0.8 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: reduce ? 0 : -10, opacity: 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 26 }}
>
{count}
</motion.span>
</AnimatePresence>
</span>
</button>
)
}
export function ReviewsList({ reviews = DEFAULT_REVIEWS, className }: { reviews?: Review[]; className?: string }) {
const reduce = useReducedMotion()
const sk = useSkBlock("reviews", "reviews-list")
const [filter, setFilter] = useState<Filter>("All")
const [sort, setSort] = useState<Sort>("recent")
const [votes, setVotes] = useState<Record<string, number>>({})
const chipRefs = useRef<(HTMLButtonElement | null)[]>([])
const shown = useMemo(() => {
const list = reviews.filter((r) =>
filter === "5 star" ? r.rating === 5 : filter === "With photos" ? !!r.photo : filter === "Verified" ? !!r.verified : true
)
const helpful = (r: Review) => r.helpful + (votes[r.id] ?? 0)
return [...list].sort((a, b) =>
sort === "helpful" ? helpful(b) - helpful(a) : sort === "rating" ? b.rating - a.rating : b.date.localeCompare(a.date)
)
}, [reviews, filter, sort, votes])
const current = FILTERS.indexOf(filter)
const onKeyDown = (e: React.KeyboardEvent) => {
const n = FILTERS.length
let next = -1
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (current + 1) % n
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (current - 1 + n) % n
if (next < 0) return
e.preventDefault()
setFilter(FILTERS[next])
chipRefs.current[next]?.focus()
}
return (
<section {...sk.attrs} ref={sk.ref as React.RefObject<HTMLElement>} className={cn("w-full", className)} aria-label="Reviews">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 border-b border-sk-line pb-4">
<div role="radiogroup" aria-label="Filter reviews" className="-mx-1 flex min-w-0 max-w-[calc(100%+0.5rem)] gap-2 overflow-x-auto px-1 py-1 [scrollbar-width:none]">
{FILTERS.map((f, i) => {
const on = f === filter
return (
<button
key={f}
ref={(el) => {
chipRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => setFilter(f)}
onKeyDown={onKeyDown}
className={cn(
"h-11 shrink-0 rounded-sk-chip border px-4 text-sm font-medium transition-[background-color,border-color,color,transform] duration-200 active:scale-[0.97]",
on ? "border-sk-ink bg-sk-ink text-sk-paper" : "border-sk-line text-sk-ink hover:bg-sk-ink/[0.04]",
RING
)}
>
{f}
</button>
)
})}
</div>
<label className="ml-auto flex shrink-0 items-center gap-2 text-[13px] text-sk-muted">
Sort
<span className="relative">
<select
value={sort}
onChange={(e) => setSort(e.target.value as Sort)}
className={cn("h-11 appearance-none rounded-sk-btn border border-sk-line bg-sk-card pl-3 pr-8 text-sm text-sk-ink", RING)}
>
<option value="recent">Most recent</option>
<option value="helpful">Most helpful</option>
<option value="rating">Highest rated</option>
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2" aria-hidden />
</span>
</label>
</div>
<motion.ul layout={!reduce} className="flex flex-col" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
{shown.map((r) => (
<motion.li
key={r.id}
layout={!reduce}
initial={{ opacity: 0, y: reduce ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.98 }}
transition={{ duration: reduce ? 0 : 0.35, ease: EASE }}
className="flex flex-col gap-2 border-b border-sk-line py-5"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<Stars value={r.rating} size={13} />
<time dateTime={r.date} className="font-sk-mono text-[11px] uppercase tracking-[0.08em] text-sk-muted">
{fmtDate(r.date)}
</time>
</div>
<div className="flex gap-4">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<h4 className="text-[15px] font-semibold text-sk-ink">{r.title}</h4>
<p className="text-sm leading-[1.5] text-sk-muted">{r.body}</p>
</div>
{r.photo ? (
<span className="relative h-16 w-16 shrink-0 overflow-hidden rounded-sk-btn border border-sk-line bg-sk-surface sm:h-20 sm:w-20">
<img src={r.photo} alt={`Photo from ${r.author}`} loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
</span>
) : null}
</div>
<div className="-mb-2 flex flex-wrap items-center justify-between gap-x-2">
<span className="flex flex-wrap items-center gap-2 text-[13px] text-sk-ink">
{r.author}
{r.verified ? (
<span className="inline-flex items-center gap-1 text-[12px] text-sk-savings">
<BadgeCheck size={14} aria-hidden /> Verified buyer
</span>
) : null}
</span>
<Helpful
count={r.helpful + (votes[r.id] ?? 0)}
onVote={() => {
sk.emit("interact", "helpful", undefined)
setVotes((v) => ({ ...v, [r.id]: (v[r.id] ?? 0) + 1 }))
}}
/>
</div>
</motion.li>
))}
</AnimatePresence>
{shown.length === 0 ? <li className="py-10 text-center text-sm text-sk-muted">No reviews match this filter yet.</li> : null}
</motion.ul>
</section>
)
}
Usage
Import from "@/components/storekit/reviews/reviews-list" and pass your product data. The demo above exercises the full prop surface with sample values.

