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

Source

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.

UGC customer hero, StoreKit