Filterable collection grid

Responsive product grid with result count, sort select and form filter chips; tiles reflow with a layout animation.

blockgridanyatcfilterssort
$npx shadcn@latest add @storekit/collection-grid
collection-grid

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

Source

components/storekit/collection/collection-grid.tsx
127 lines
"use client"

import { useId, useMemo, useState, type RefObject } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { ProductCardMinimal, type MinimalProduct } from "@/components/storekit/product-card/product-card-minimal"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Collection grid with a toolbar: live result count, sort select and filter
   chips that filter by category (or by form, if a filter sets one). Tiles reflow with a layout animation
   when the filter or sort changes. 2, 3 then 4 columns. */

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

export type GridProduct = MinimalProduct & { id: string; form?: string }
type Filter = { label: string; category?: string; form?: string }
type Sort = "featured" | "price-asc" | "price-desc"

const SORTS: { value: Sort; label: string }[] = [
  { value: "featured", label: "Featured" },
  { value: "price-asc", label: "Price, low to high" },
  { value: "price-desc", label: "Price, high to low" },
]

export function CollectionGrid({
  products = [],
  filters = [
    { label: "All" },
    { label: "Powders", category: "Powders" },
    { label: "Capsules & gummies", category: "Capsules & gummies" },
    { label: "Drinks", category: "Drinks" },
  ],
  onAdd,
  className,
}: {
  products?: GridProduct[]
  filters?: Filter[]
  onAdd?: (product: MinimalProduct) => void | Promise<void>
  className?: string
}) {
  const reduce = useReducedMotion()
  const sortId = useId()
  const sk = useSkBlock("collection", "collection-grid")
  const [active, setActive] = useState(0)
  const [sort, setSort] = useState<Sort>("featured")

  const visible = useMemo(() => {
    const f = filters[active]
    const list = products.filter((p) => (!f?.category || p.category === f.category) && (!f?.form || p.form === f.form))
    if (sort === "price-asc") list.sort((a, b) => a.price - b.price)
    if (sort === "price-desc") list.sort((a, b) => b.price - a.price)
    return list
  }, [products, filters, active, sort])

  return (
    <div ref={sk.ref as RefObject<HTMLDivElement>} {...sk.attrs} className={cn("flex flex-col gap-6", className)}>
      <div className="flex flex-col gap-4 border-b border-sk-line pb-4 sm:flex-row sm:items-center sm:justify-between">
        <div className="-mx-1 flex gap-2 overflow-x-auto px-1 [scrollbar-width:none]" role="group" aria-label="Filter by type">
          {filters.map((f, i) => (
            <button
              key={f.label}
              type="button"
              aria-pressed={i === active}
              onClick={() => {
                if (i !== active) sk.emit("interact", `filter:${f.label}`)
                setActive(i)
              }}
              className={cn(
                "h-11 shrink-0 rounded-sk-chip border px-4 text-[13px] font-medium outline-none transition-[transform,background-color,color,border-color] duration-200 ease-sk-out active:scale-[0.97] sm:h-9",
                "focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper",
                i === active ? "border-sk-ink bg-sk-ink text-sk-paper" : "border-sk-line text-sk-ink hover:border-sk-ink/40"
              )}
            >
              {f.label}
            </button>
          ))}
        </div>
        <div className="flex items-center justify-between gap-4 sm:justify-end">
          <p className="shrink-0 whitespace-nowrap font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted tabular-nums" aria-live="polite">
            {visible.length} {visible.length === 1 ? "product" : "products"}
          </p>
          <label htmlFor={sortId} className="sr-only">
            Sort by
          </label>
          <div className="relative min-w-0">
            <select
              id={sortId}
              value={sort}
              onChange={(e) => {
                sk.emit("interact", `sort:${e.target.value}`)
                setSort(e.target.value as Sort)
              }}
              className="h-11 w-full max-w-full truncate appearance-none rounded-sk-btn border border-sk-line bg-sk-card pl-3.5 pr-10 text-base text-sk-ink outline-none sm:text-[13px] transition-[border-color] duration-200 hover:border-sk-ink/30 focus-visible:border-sk-accent focus-visible:ring-[3px] focus-visible:ring-sk-accent/15 sm:h-9"
            >
              {SORTS.map((s) => (
                <option key={s.value} value={s.value}>
                  {s.label}
                </option>
              ))}
            </select>
            <ChevronDown size={14} aria-hidden className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-sk-muted" />
          </div>
        </div>
      </div>

      <motion.ul layout={!reduce} className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
        <AnimatePresence mode="popLayout" initial={false}>
          {visible.map((p) => (
            <motion.li
              key={p.id}
              layout={!reduce}
              initial={{ opacity: 0, scale: reduce ? 1 : 0.95 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: reduce ? 1 : 0.95 }}
              transition={{ duration: 0.35, ease: EASE }}
            >
              <ProductCardMinimal product={p} onAdd={onAdd} />
            </motion.li>
          ))}
        </AnimatePresence>
      </motion.ul>
      {visible.length === 0 ? <p className="py-12 text-center text-sm text-sk-muted">Nothing in this filter yet.</p> : null}
    </div>
  )
}

Usage

Import from "@/components/storekit/collection/collection-grid" and pass your product data. The demo above exercises the full prop surface with sample values.