Category tiles hero
Compact headline over three to six category, goal or audience tiles with counts; an optional feature tile spans two columns.
blockcategory-tilesanynavigation
$
npx shadcn@latest add @storekit/hero-category-tileshero-category-tiles
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-category-tiles$
pnpm dlx shadcn@latest add @storekit/hero-category-tiles$
yarn dlx shadcn@latest add @storekit/hero-category-tiles$
bunx --bun shadcn@latest add @storekit/hero-category-tilesSource
components/storekit/hero/hero-category-tiles.tsx
113 lines
"use client"
import { motion, useReducedMotion } from "motion/react"
import { ArrowUpRight } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Category tiles as the hero. For catalogs where the fastest path is "pick
your aisle": a compact headline row, then three to six large tiles for
top categories, goals or audiences. Every tile is one link with an image
on the surface token, a label and an item count. The first tile can be
marked `feature` to span two columns on desktop. Two columns on mobile,
three on desktop. */
const EASE = [0.23, 1, 0.32, 1] as const
export type CategoryTile = { label: string; href: string; image: string; count?: number; note?: string; feature?: boolean; focus?: string }
const SAMPLE: CategoryTile[] = [
{ label: "Powders", href: "#", image: "/placeholder.webp", count: 3, feature: true, note: "Greens, protein, electrolytes" },
{ label: "Capsules and gummies", href: "#", image: "/placeholder.webp", count: 2 },
{ label: "Drinks", href: "#", image: "/placeholder.webp", count: 1 },
{ label: "Bundles", href: "#", image: "/placeholder.webp", count: 4 },
{ label: "Best sellers", href: "#", image: "/placeholder.webp", count: 6 },
]
export function HeroCategoryTiles({
eyebrow = "Shop by category",
headline = "Find what fits your day.",
link = { label: "Shop all", href: "#" },
tiles = SAMPLE,
className,
}: {
eyebrow?: string
headline?: string
link?: { label: string; href: string } | null
tiles?: CategoryTile[]
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("hero", "hero-category-tiles")
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 px-4 py-10 sm:px-8 sm:py-14">
<div className="flex items-end justify-between gap-6">
<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>
</div>
{link ? (
<a
href={link.href}
onClick={() => sk.emit("interact", "shop_all")}
className="hidden shrink-0 items-center gap-1 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:inline-flex"
>
{link.label}
<ArrowUpRight size={15} aria-hidden />
</a>
) : null}
</div>
<ul className="mt-7 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3">
{tiles.map((t, i) => (
<motion.li
key={t.label}
initial={{ opacity: 0, y: reduce ? 0 : 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: EASE, delay: reduce ? 0 : i * 0.05 }}
className={cn(t.feature && "col-span-2 lg:row-span-2 lg:col-span-1")}
>
<a
href={t.href}
onClick={() => sk.emit("interact", "category", i)}
className="group flex h-full flex-col overflow-hidden rounded-sk-card border border-sk-line bg-sk-card outline-none transition-[border-color,transform] duration-300 ease-sk-out hover:-translate-y-0.5 hover:border-sk-ink/25 focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
>
<div className={cn("relative overflow-hidden bg-sk-surface", t.feature ? "aspect-[16/10] lg:aspect-auto lg:flex-1" : "aspect-[4/3]")}>
<img
src={t.image}
alt=""
className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 ease-sk-out group-hover:scale-[1.04]"
style={{ objectPosition: t.focus }}
/>
</div>
<div className={cn("flex items-center justify-between gap-2 px-3.5 py-3 sm:gap-3 sm:px-4", !t.feature && "flex-1")}>
<div className="min-w-0">
<h2 className="line-clamp-2 text-[13px] font-semibold leading-tight sm:truncate sm:text-[15px] sm:leading-normal">{t.label}</h2>
{t.note ? <p className="mt-0.5 hidden truncate text-[12px] text-sk-muted sm:block">{t.note}</p> : null}
</div>
<span className="flex shrink-0 items-center gap-2">
{typeof t.count === "number" ? <span className={cn("font-sk-mono text-[11px] tabular-nums text-sk-muted", !t.feature && "hidden sm:inline")}>{t.count}</span> : null}
<span className={cn("h-7 w-7 place-items-center rounded-full bg-sk-ink/[0.05] text-sk-ink transition-[background-color,color,transform] duration-300 ease-sk-out group-hover:rotate-45 group-hover:bg-sk-accent group-hover:text-sk-accent-ink", t.feature ? "grid" : "hidden sm:grid")}>
<ArrowUpRight size={14} aria-hidden />
</span>
</span>
</div>
</a>
</motion.li>
))}
</ul>
{link ? (
<a href={link.href} className="mt-5 inline-flex min-h-11 items-center gap-1 text-sm font-medium underline decoration-sk-line underline-offset-4 sm:hidden">
{link.label}
<ArrowUpRight size={15} aria-hidden />
</a>
) : null}
</div>
</section>
)
}
Usage
Import from "@/components/storekit/hero/hero-category-tiles" and pass your product data. The demo above exercises the full prop surface with sample values.




