Review list

Filter chips, sort, review cards with verified badge, photo thumb and a springy helpful count.

blocklistanyreviews
$npx shadcn@latest add @storekit/reviews-list
reviews-list

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-list
$pnpm dlx shadcn@latest add @storekit/reviews-list
$yarn dlx shadcn@latest add @storekit/reviews-list
$bunx --bun shadcn@latest add @storekit/reviews-list

Source

components/storekit/reviews/reviews-list.tsx
211 lines
"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.

Review list, StoreKit