UGC wall

Even grid of customer photos and pull quotes with a Shop this chip on hover.

blockugcanyreviews
$npx shadcn@latest add @storekit/reviews-ugc-wall
reviews-ugc-wall

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

Source

components/storekit/reviews/reviews-ugc-wall.tsx
96 lines
"use client"

import { motion, useReducedMotion } from "motion/react"
import { ArrowUpRight } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"

/* UGC wall: an even grid of customer photos and pull quotes. Every tile is
   square so rows line up at any width (`tall` is accepted but no longer used).
   Tiles rise in on scroll; hovering or focusing a tile reveals "Shop this". */

type Tile =
  | { kind: "image"; src: string; alt: string; handle: string; href?: string; tall?: boolean }
  | { kind: "quote"; quote: string; handle: string; href?: string }

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_TILES: Tile[] = [
  { kind: "image", src: "/brands/morrow/greens.webp", alt: "Greens pouch on a kitchen counter", handle: "@slowmornings", tall: true },
  { kind: "quote", quote: "The only greens my kids will steal from me. Tastes like pineapple juice.", handle: "@jen.cooks" },
  { kind: "image", src: "/brands/morrow/magnesium.webp", alt: "Gummy jar on a nightstand", handle: "@morningstack" },
  { kind: "quote", quote: "Electrolytes in my bottle before every run. No more 3pm slump.", handle: "@miles.with.mo" },
  { kind: "image", src: "/brands/morrow/sparkling.webp", alt: "Sparkling cans in the fridge", handle: "@fridgegoals", tall: true },
  { kind: "image", src: "/brands/morrow/hero.webp", alt: "Product lineup", handle: "@dailyritual" },
]

function ShopChip() {
  return (
    <span className="pointer-events-none absolute bottom-3 right-3 inline-flex translate-y-1 scale-95 items-center gap-1 rounded-sk-chip bg-sk-ink px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.06em] text-sk-paper opacity-0 transition-[opacity,transform] duration-200 ease-sk-out group-hover:translate-y-0 group-hover:scale-100 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:scale-100 group-focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:translate-y-0 [@media(hover:none)]:scale-100 [@media(hover:none)]:opacity-100">
      Shop this <ArrowUpRight size={12} aria-hidden />
    </span>
  )
}

export function ReviewsUgcWall({
  tiles = DEFAULT_TILES,
  title = "In your kitchens",
  onShop,
  className,
}: {
  tiles?: Tile[]
  title?: string
  onShop?: (index: number) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  return (
    <section data-sk-section="reviews" data-sk-variant="reviews-ugc-wall" className={cn("w-full", className)} aria-label={title}>
      <div className="mb-6 flex flex-col gap-2">
        <span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Tagged by customers</span>
        <h2 className="font-sk-display text-2xl font-semibold leading-[1.1] tracking-[-0.02em] text-sk-ink sm:text-3xl">{title}</h2>
      </div>
      <ul className="grid grid-cols-2 gap-3 md:grid-cols-3">
        {tiles.map((t, i) => {
          const Tag = t.href ? "a" : "button"
          const tagProps = t.href ? { href: t.href } : { type: "button" as const, onClick: () => onShop?.(i) }
          return (
            <motion.li
              key={i}
              className="aspect-square"
              initial={{ opacity: 0, y: reduce ? 0 : 8 }}
              whileInView={{ opacity: 1, y: 0 }}
              viewport={{ once: true, margin: "-40px" }}
              transition={{ duration: reduce ? 0 : 0.45, ease: EASE, delay: reduce ? 0 : i * 0.05 }}
            >
              <Tag
                {...tagProps}
                aria-label={`Shop this, shared by ${t.handle}`}
                className={cn("group relative block h-full w-full overflow-hidden rounded-sk-card border border-sk-line text-left transition-transform duration-200 active:scale-[0.97]", RING)}
              >
                {t.kind === "image" ? (
                  <span className="relative block h-full bg-sk-surface">
                    <img
                      src={t.src}
                      alt={t.alt}
                      loading="lazy"
                      className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 ease-sk-out group-hover:scale-[1.03] motion-reduce:transition-none"
                    />
                    <span className="absolute left-3 top-3 rounded-sk-chip border border-sk-line bg-sk-card px-2 py-0.5 font-sk-mono text-[11px] text-sk-ink">{t.handle}</span>
                  </span>
                ) : (
                  <span className="flex h-full flex-col justify-between gap-3 bg-sk-surface p-4 sm:p-5">
                    <span className="line-clamp-6 font-sk-display text-[15px] font-semibold leading-[1.3] tracking-[-0.02em] text-sk-ink sm:text-lg">&ldquo;{t.quote}&rdquo;</span>
                    <span className="font-sk-mono text-[11px] text-sk-muted">{t.handle}</span>
                  </span>
                )}
                <ShopChip />
              </Tag>
            </motion.li>
          )
        })}
      </ul>
    </section>
  )
}

Usage

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

UGC wall, StoreKit