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

Source

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.

Cinemagraph hero, StoreKit