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-wallreviews-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-wallSource
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">“{t.quote}”</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.