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-tiles
hero-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-tiles

Source

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.

Category tiles hero, StoreKit