UGC customer hero
Customer photos or vertical clips lead: a tall featured post and two supporting posts with handle, verified mark, quote and shop link.
blockugcanycta
$
npx shadcn@latest add @storekit/hero-ugchero-ugc
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-ugc$
pnpm dlx shadcn@latest add @storekit/hero-ugc$
yarn dlx shadcn@latest add @storekit/hero-ugc$
bunx --bun shadcn@latest add @storekit/hero-ugcSource
components/storekit/hero/hero-ugc.tsx
158 lines
"use client"
import { motion, useReducedMotion } from "motion/react"
import { ArrowUpRight, BadgeCheck } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* UGC customer hero. Real customer photos or vertical clips carry the hero:
one tall featured post and two supporting posts, each with the customer's
handle, a verified buyer mark, a short quote and a link to the product in
the shot. Copy stays brief so the posts do the selling. Captions sit on a
fixed dark scrim so they read on any photo. On mobile the featured post
leads, the copy follows and the supporting posts sit side by side. */
const EASE = [0.23, 1, 0.32, 1] as const
type Cta = { label: string; href: string }
export type UgcPost = {
image: string
/** Optional short vertical clip; the image is its poster. Muted, looped, off under reduced motion. */
video?: string
alt: string
handle: string
quote?: string
product?: { title: string; href: string }
verified?: boolean
/** CSS object-position for the crop. */
focus?: string
}
const PRIMARY =
"sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn bg-sk-accent px-7 text-[15px] font-medium text-sk-accent-ink outline-none transition-[filter,transform] duration-200 ease-sk-out hover:brightness-[1.06] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]"
const SECONDARY =
"sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn border border-sk-line px-7 text-[15px] font-medium text-sk-ink outline-none transition-[background-color,transform] duration-200 ease-sk-out hover:bg-sk-ink/[0.04] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]"
const SAMPLE: UgcPost[] = [
{ image: "/placeholder.webp", alt: "Customer photo", handle: "@maya.moves", quote: "Two months in and I actually look forward to my morning glass.", product: { title: "Daily Greens", href: "#" }, verified: true },
{ image: "/placeholder.webp", alt: "Customer photo", handle: "@coachdre", quote: "Packed in my gym bag every day.", product: { title: "Electrolytes", href: "#" }, verified: true },
{ image: "/placeholder.webp", alt: "Customer photo", handle: "@sam.slowmornings", quote: "The whole shelf, honestly.", product: { title: "Shop the set", href: "#" }, verified: true },
]
export function HeroUgc({
eyebrow = "Shot by customers, not studios",
headline = "Real routines. Real kitchens.",
subcopy = "Thousands of customers share how they take theirs. Tag us to be featured.",
primaryCta = { label: "Shop customer favorites", href: "#" },
secondaryCta = { label: "See all posts", href: "#" },
posts = SAMPLE,
stat = { value: "12,400+", label: "customer posts" },
className,
}: {
eyebrow?: string
headline?: string
subcopy?: string
primaryCta?: Cta
secondaryCta?: Cta
/** First post is featured. Up to three are shown. */
posts?: UgcPost[]
stat?: { value: string; label: string } | null
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("hero", "hero-ugc")
const [lead, ...rest] = posts
const side = rest.slice(0, 2)
const tile = (post: UgcPost, i: number, big?: boolean) => (
<motion.figure
initial={{ opacity: 0, y: reduce ? 0 : 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, ease: EASE, delay: reduce ? 0 : 0.08 * i }}
className={cn(
"group relative overflow-hidden rounded-sk-card border border-sk-line bg-sk-surface",
big ? "aspect-[4/5] lg:aspect-auto" : "aspect-[4/5] lg:aspect-auto"
)}
>
{post.video && !reduce ? (
<video src={post.video} poster={post.image} muted loop playsInline autoPlay aria-label={post.alt} className="absolute inset-0 h-full w-full object-cover" style={{ objectPosition: post.focus }} />
) : (
<img src={post.image} alt={post.alt} className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 ease-sk-out group-hover:scale-[1.03]" style={{ objectPosition: post.focus }} />
)}
<div aria-hidden className="pointer-events-none absolute inset-0 bg-[linear-gradient(0deg,rgb(0_0_0/0.72),rgb(0_0_0/0.2)_45%,transparent_70%)]" />
<figcaption className={cn("absolute inset-x-0 bottom-0 text-[rgb(255_255_255)]", big ? "p-4 sm:p-5" : "p-3 sm:p-4")}>
<p className="flex min-w-0 items-center gap-1.5 text-[12px] font-medium sm:text-[13px]">
<span className="truncate">{post.handle}</span>
{post.verified ? (
<>
<BadgeCheck size={14} aria-hidden className="opacity-90" />
<span className="sr-only">Verified buyer</span>
</>
) : null}
</p>
{post.quote ? (
<blockquote className={cn("mt-1.5 text-pretty text-[rgb(255_255_255/0.88)]", big ? "text-[15px] sm:text-base" : "hidden text-[13px] sm:block", "leading-[1.4]")}>
<span className={big ? undefined : "line-clamp-2"}>{post.quote}</span>
</blockquote>
) : null}
{post.product ? (
<a
href={post.product.href}
onClick={() => sk.emit("interact", "ugc_product", i)}
className="mt-3 inline-flex min-h-9 items-center gap-1 rounded-sk-chip bg-[rgb(255_255_255/0.16)] px-3 text-[12px] font-medium text-[rgb(255_255_255)] outline-none backdrop-blur-md transition-[background-color,transform] duration-200 ease-sk-out hover:bg-[rgb(255_255_255/0.26)] focus-visible:ring-2 focus-visible:ring-[rgb(255_255_255)] active:scale-[0.97]"
>
{post.product.title}
<ArrowUpRight size={13} aria-hidden />
</a>
) : null}
</figcaption>
</motion.figure>
)
return (
<section ref={sk.ref} {...sk.attrs} className={cn("bg-sk-paper text-sk-ink", className)}>
<div className="mx-auto grid w-full max-w-6xl gap-8 px-4 py-10 sm:px-8 sm:py-14 lg:grid-cols-[0.8fr_1.2fr] lg:items-center lg:gap-12">
<motion.div
initial={{ opacity: 0, y: reduce ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: EASE }}
className="order-2 lg:order-1"
>
<p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{eyebrow}</p>
<h1 className="mt-4 text-balance font-sk-display font-semibold leading-[1.06] tracking-[-0.02em]" style={{ fontSize: "clamp(2.25rem, 4.4vw, 3.5rem)" }}>
{headline}
</h1>
<p className="mt-5 max-w-md text-[15px] leading-[1.55] text-sk-muted">{subcopy}</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row lg:flex-col xl:flex-row">
<a href={primaryCta.href} onClick={() => sk.emit("interact", "cta")} className={PRIMARY}>
{primaryCta.label}
</a>
<a href={secondaryCta.href} onClick={() => sk.emit("interact", "cta_secondary")} className={SECONDARY}>
{secondaryCta.label}
</a>
</div>
{stat ? (
<p className="mt-8 flex items-baseline gap-2 border-t border-sk-line pt-5">
<span className="font-sk-display text-2xl font-semibold tabular-nums">{stat.value}</span>
<span className="text-sm text-sk-muted">{stat.label}</span>
</p>
) : null}
</motion.div>
<div className="order-1 grid grid-cols-2 gap-3 lg:order-2 lg:h-[560px] lg:grid-rows-2 lg:gap-4">
{lead ? (
<div className="col-span-2 lg:col-span-1 lg:row-span-2 lg:grid">
{tile(lead, 0, true)}
</div>
) : null}
{side.map((p, i) => (
<div key={p.handle + i} className="lg:grid">
{tile(p, i + 1)}
</div>
))}
</div>
</div>
</section>
)
}
Usage
Use posts you have rights to. A post's `video` plays muted and is replaced by its image under reduced motion.


