Best-seller shelf hero

Store rating headline over the top products with rank badges, stars, price and quick add; scrolls sideways with a peek on mobile.

blockbestseller-shelfanyatc
$npx shadcn@latest add @storekit/hero-bestseller-shelf
hero-bestseller-shelf

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

Source

components/storekit/hero/hero-bestseller-shelf.tsx
160 lines
"use client"

import { useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Plus } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
import { Price } from "@/components/storekit/ui/price"
import { Stars } from "@/components/storekit/ui/stars"
import { Badge } from "@/components/storekit/ui/badge"

/* Best-seller shelf as the hero. A short headline with store-wide rating,
   then the top products above the fold with image, rating, price and a
   quick add that confirms in place. Four cards on desktop; on mobile the
   shelf scrolls sideways with the next card peeking so it reads as more.
   Rank badges call out the top sellers. */

const EASE = [0.23, 1, 0.32, 1] as const

export type ShelfProduct = {
  id: string
  title: string
  href: string
  image: string
  price: number
  compareAt?: number
  rating?: number
  reviews?: number
  blurb?: string
}

const SAMPLE: ShelfProduct[] = [
  { id: "1", title: "Daily Greens", href: "#", image: "/placeholder.webp", price: 4900, compareAt: 5900, rating: 4.8, reviews: 2140 },
  { id: "2", title: "Electrolytes", href: "#", image: "/placeholder.webp", price: 3200, rating: 4.7, reviews: 980 },
  { id: "3", title: "Magnesium Night", href: "#", image: "/placeholder.webp", price: 2900, rating: 4.9, reviews: 1630 },
  { id: "4", title: "Protein", href: "#", image: "/placeholder.webp", price: 5400, rating: 4.6, reviews: 720 },
]

export function HeroBestsellerShelf({
  eyebrow = "Best sellers",
  headline = "What everyone is restocking.",
  rating = { value: 4.8, count: 12400 },
  link = { label: "Shop all best sellers", href: "#" },
  products = SAMPLE,
  ranked = true,
  onAdd,
  className,
}: {
  eyebrow?: string
  headline?: string
  rating?: { value: number; count: number } | null
  link?: { label: string; href: string } | null
  products?: ShelfProduct[]
  /** Show "#1, #2..." badges. */
  ranked?: boolean
  onAdd?: (id: string) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("hero", "hero-bestseller-shelf")
  const [added, setAdded] = useState<Record<string, boolean>>({})

  const add = (p: ShelfProduct, i: number) => {
    onAdd?.(p.id)
    setAdded((a) => ({ ...a, [p.id]: true }))
    sk.emit("convert", "add_to_cart", i)
  }

  return (
    <section ref={sk.ref} {...sk.attrs} className={cn("bg-sk-paper text-sk-ink", className)}>
      <div className="mx-auto w-full max-w-6xl py-10 sm:py-14">
        <div className="flex flex-col gap-3 px-4 sm:flex-row sm:items-end sm:justify-between sm:px-8">
          <div>
            <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{eyebrow}</p>
            <h1 className="mt-3 text-balance font-sk-display font-semibold leading-[1.08] tracking-[-0.02em]" style={{ fontSize: "clamp(1.85rem, 3.6vw, 2.75rem)" }}>
              {headline}
            </h1>
            {rating ? (
              <p className="mt-3 flex items-center gap-2 text-sm text-sk-muted">
                <Stars value={rating.value} size={14} />
                <span>
                  <span className="font-medium text-sk-ink tabular-nums">{rating.value.toFixed(1)}</span> from {rating.count.toLocaleString("en-US")} reviews
                </span>
              </p>
            ) : null}
          </div>
          {link ? (
            <a
              href={link.href}
              onClick={() => sk.emit("interact", "shop_all")}
              className="inline-flex min-h-11 shrink-0 items-center self-start text-sm font-medium underline decoration-sk-line underline-offset-4 outline-none transition-[text-decoration-color] hover:decoration-sk-ink focus-visible:ring-2 focus-visible:ring-sk-accent sm:self-auto"
            >
              {link.label}
            </a>
          ) : null}
        </div>

        <ul className="mt-6 flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-px-4 px-4 pb-2 [scrollbar-width:none] sm:scroll-px-8 sm:px-8 lg:grid lg:grid-cols-4 lg:gap-4 lg:overflow-visible">
          {products.map((p, i) => {
            const done = added[p.id]
            return (
              <motion.li
                key={p.id}
                initial={{ opacity: 0, y: reduce ? 0 : 12 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ duration: 0.5, ease: EASE, delay: reduce ? 0 : i * 0.06 }}
                className="w-[68%] shrink-0 snap-start sm:w-[40%] lg:w-auto"
              >
                <article className="group flex h-full flex-col overflow-hidden rounded-sk-card border border-sk-line bg-sk-card">
                  <a href={p.href} onClick={() => sk.emit("interact", "product", i)} className="relative block aspect-square overflow-hidden bg-sk-surface outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sk-accent">
                    <img src={p.image} alt={p.title} className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 ease-sk-out group-hover:scale-[1.04]" />
                    {ranked ? (
                      <span className="absolute left-2.5 top-2.5">
                        <Badge tone={i === 0 ? "accent" : "neutral"}>#{i + 1}</Badge>
                      </span>
                    ) : null}
                  </a>
                  <div className="flex flex-1 flex-col p-3 sm:p-4">
                    <h2 className="text-[15px] font-semibold leading-tight">
                      <a href={p.href} className="outline-none hover:underline focus-visible:underline">
                        {p.title}
                      </a>
                    </h2>
                    {typeof p.rating === "number" ? <Stars value={p.rating} count={p.reviews} size={12} className="mt-1.5 [&>span]:text-[12px]" /> : null}
                    <div className="mt-auto flex items-center justify-between gap-2 pt-3">
                      <Price amount={p.price} compareAt={p.compareAt} size="sm" />
                      <button
                        type="button"
                        onClick={() => add(p, i)}
                        aria-label={done ? `${p.title} added to cart` : `Add ${p.title} to cart`}
                        className={cn(
                          "sk-btn relative grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-full outline-none transition-[background-color,color,transform] duration-200 ease-sk-out focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-card active:scale-[0.92]",
                          done ? "bg-sk-savings text-sk-paper" : "bg-sk-accent text-sk-accent-ink hover:brightness-[1.06]"
                        )}
                      >
                        <AnimatePresence initial={false} mode="popLayout">
                          <motion.span
                            key={done ? "done" : "add"}
                            initial={{ y: reduce ? 0 : 14, opacity: 0 }}
                            animate={{ y: 0, opacity: 1 }}
                            exit={{ y: reduce ? 0 : -14, opacity: 0 }}
                            transition={{ type: "spring", stiffness: 520, damping: 32 }}
                            className="grid place-items-center"
                          >
                            {done ? <Check size={16} aria-hidden /> : <Plus size={16} aria-hidden />}
                          </motion.span>
                        </AnimatePresence>
                      </button>
                    </div>
                  </div>
                </article>
              </motion.li>
            )
          })}
        </ul>
      </div>
    </section>
  )
}

Usage

Wire `onAdd` to your cart; each add fires a convert event.

Best-seller shelf hero, StoreKit