Cinemagraph hero
Poster-first muted loop with a centered headline, rotating proof chips and a fade into the page.
blockvideoanycta
$
npx shadcn@latest add @storekit/hero-videohero-video
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-video$
pnpm dlx shadcn@latest add @storekit/hero-video$
yarn dlx shadcn@latest add @storekit/hero-video$
bunx --bun shadcn@latest add @storekit/hero-videoSource
components/storekit/hero/hero-video.tsx
137 lines
"use client"
import { useEffect, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Poster-first cinemagraph hero. The poster paints immediately; a muted,
looping video takes over when `src` is set. A centered headline sits on a
fixed dark scrim, three proof chips rotate their copy every 2.4s, and the
bottom edge fades into the page color. */
const EASE = [0.23, 1, 0.32, 1] as const
type Cta = { label: string; href: string }
export function HeroVideo({
poster = "/placeholder.webp",
src,
eyebrow = "Made in small batches",
headline = "Feel better by breakfast.",
subcopy = "Clean daily essentials, third-party tested and made in the USA. Delivered on your schedule.",
primaryCta = { label: "Shop now", href: "#" },
secondaryCta = { label: "See the process", href: "#" },
chips = ["Third-party tested", "Ships same day", "30-day guarantee"],
interval = 2400,
className,
}: {
poster?: string
src?: string
eyebrow?: string
headline?: string
subcopy?: string
primaryCta?: Cta
secondaryCta?: Cta
chips?: string[]
interval?: number
className?: string
}) {
const reduce = useReducedMotion()
const [tick, setTick] = useState(0)
const sk = useSkBlock("hero", "hero-video")
useEffect(() => {
if (reduce || chips.length < 2) return
const id = setInterval(() => setTick((t) => t + 1), interval)
return () => clearInterval(id)
}, [reduce, chips.length, interval])
const rise = (i: number) => ({
initial: { opacity: 0, y: reduce ? 0 : 8 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.45, ease: EASE, delay: reduce ? 0 : 0.1 + i * 0.06 },
})
return (
<section ref={sk.ref} {...sk.attrs} className={cn("relative isolate flex min-h-[540px] items-center overflow-hidden bg-[rgb(0_0_0)] lg:min-h-[620px]", className)}>
<video
key={src ?? poster}
poster={poster}
autoPlay={!reduce}
muted
loop
playsInline
preload="metadata"
aria-hidden
className="absolute inset-0 -z-10 h-full w-full object-cover"
>
{src ? <source src={src} /> : null}
</video>
<div aria-hidden className="absolute inset-0 -z-10 bg-[rgb(0_0_0/0.5)]" />
<div aria-hidden className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_70%_60%_at_50%_45%,rgb(0_0_0/0.4),transparent)]" />
<div
aria-hidden
className="absolute inset-x-0 bottom-0 -z-10 h-32 bg-[linear-gradient(to_bottom,transparent,rgb(var(--sk-paper)))] sm:h-40"
/>
<div className="mx-auto flex w-full max-w-3xl flex-col items-center px-4 pb-32 pt-16 text-center text-[rgb(255_255_255)] [text-shadow:0_1px_16px_rgb(0_0_0/0.35)] sm:px-8 sm:pb-40">
<motion.p {...rise(0)} className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-[rgb(255_255_255/0.72)]">
{eyebrow}
</motion.p>
<motion.h1
{...rise(1)}
className="mt-4 text-balance font-sk-display font-semibold leading-[1.1] tracking-[-0.02em]"
style={{ fontSize: "clamp(2.25rem, 5.4vw, 4.25rem)" }}
>
{headline}
</motion.h1>
<motion.p {...rise(2)} className="mt-4 max-w-md text-[15px] leading-[1.5] text-[rgb(255_255_255/0.8)]">
{subcopy}
</motion.p>
<motion.div {...rise(3)} className="mt-8 flex w-full flex-col justify-center gap-3 sm:w-auto sm:flex-row">
<a
href={primaryCta.href}
onClick={() => sk.emit("interact", "cta")}
className="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-[rgb(255_255_255)] focus-visible:ring-offset-2 focus-visible:ring-offset-[rgb(0_0_0)] active:scale-[0.97]"
>
{primaryCta.label}
</a>
<a
href={secondaryCta.href}
onClick={() => sk.emit("interact", "cta_secondary")}
className="sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn border border-[rgb(255_255_255/0.4)] px-7 text-[15px] font-medium text-[rgb(255_255_255)] outline-none transition-[background-color,transform] duration-200 ease-sk-out hover:bg-[rgb(255_255_255/0.1)] focus-visible:ring-2 focus-visible:ring-[rgb(255_255_255)] active:scale-[0.97]"
>
{secondaryCta.label}
</a>
</motion.div>
<motion.ul {...rise(4)} className="mt-8 flex flex-wrap justify-center gap-2" aria-label="Why it matters">
{chips.slice(0, 3).map((_, i) => {
const label = chips[(i + tick) % chips.length]
return (
<li
key={i}
className="relative grid h-9 min-w-[11.5rem] place-items-center overflow-hidden rounded-sk-chip border border-[rgb(255_255_255/0.28)] bg-[rgb(0_0_0/0.45)] px-3"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={label}
initial={{ opacity: 0, y: reduce ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -8 }}
transition={{ duration: 0.35, ease: EASE, delay: i * 0.08 }}
className="font-sk-mono text-[11px] uppercase tracking-[0.14em]"
>
{label}
</motion.span>
</AnimatePresence>
</li>
)
})}
</motion.ul>
</div>
</section>
)
}
Usage
Pass `src` for the loop; the poster shows until it can play and stays for reduced motion.