Video card hero

Product demo clip contained in a card beside the copy, poster first, with one play and pause control and a caption with run time.

blockvideo-cardanycta
$npx shadcn@latest add @storekit/hero-video-card
hero-video-card

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-card
$pnpm dlx shadcn@latest add @storekit/hero-video-card
$yarn dlx shadcn@latest add @storekit/hero-video-card
$bunx --bun shadcn@latest add @storekit/hero-video-card

Source

components/storekit/hero/hero-video-card.tsx
137 lines
"use client"

import { useEffect, useRef, useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { Pause, Play } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Product demo video in a card beside the copy, not behind it. The poster
   paints first. With motion allowed the clip autoplays muted and looped;
   with reduced motion it waits for the play button. One control toggles
   play and pause, and a caption under the card says what the clip shows and
   how long it runs. Stacks with the card first under lg. */

const EASE = [0.23, 1, 0.32, 1] as const

type Cta = { label: string; href: string }

export function HeroVideoCard({
  poster = "/placeholder.webp",
  src,
  caption = "How a batch gets made",
  duration = "0:42",
  eyebrow = "From farm to scoop",
  headline = "Watch a pouch go from the field to your door.",
  subcopy = "Whole-food ingredients, blended and packed in the USA, then third-party tested before it ships.",
  primaryCta = { label: "Shop the range", href: "#" },
  secondaryCta = { label: "See lab results", href: "#" },
  className,
}: {
  poster?: string
  /** Video file. Without it the card shows the poster only. */
  src?: string
  caption?: string
  duration?: string
  eyebrow?: string
  headline?: string
  subcopy?: string
  primaryCta?: Cta
  secondaryCta?: Cta
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("hero", "hero-video-card")
  const video = useRef<HTMLVideoElement>(null)
  const [playing, setPlaying] = useState(false)

  useEffect(() => {
    const v = video.current
    if (!v || !src) return
    if (reduce) {
      v.pause()
      return
    }
    v.play().catch(() => setPlaying(false))
  }, [reduce, src])

  const toggle = () => {
    const v = video.current
    if (!v || !src) return
    if (v.paused) {
      v.play().catch(() => {})
      sk.emit("interact", "video_play")
    } else v.pause()
  }

  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 items-center gap-10 px-4 py-12 sm:px-8 sm:py-16 lg:grid-cols-2 lg:gap-16">
        <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 max-w-xl text-balance font-sk-display font-semibold leading-[1.1] tracking-[-0.02em]"
            style={{ fontSize: "clamp(2rem, 4vw, 3.25rem)" }}
          >
            {headline}
          </h1>
          <p className="mt-5 max-w-md text-[15px] leading-[1.5] text-sk-muted">{subcopy}</p>
          <div className="mt-8 flex flex-col gap-3 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-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper 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-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]"
            >
              {secondaryCta.label}
            </a>
          </div>
        </motion.div>

        <figure className="order-1 lg:order-2">
          <div className="relative aspect-video overflow-hidden rounded-sk-card border border-sk-line bg-sk-surface">
            <video
              ref={video}
              poster={poster}
              muted
              loop
              playsInline
              preload="metadata"
              onPlay={() => setPlaying(true)}
              onPause={() => setPlaying(false)}
              className="absolute inset-0 h-full w-full object-cover"
            >
              {src ? <source src={src} /> : null}
            </video>
            {src ? (
              <button
                type="button"
                onClick={toggle}
                aria-label={playing ? "Pause video" : "Play video"}
                className="absolute bottom-3 left-3 grid h-11 w-11 place-items-center rounded-full bg-[rgb(0_0_0/0.6)] text-[rgb(255_255_255)] outline-none transition-[background-color,transform] duration-200 ease-sk-out hover:bg-[rgb(0_0_0/0.75)] focus-visible:ring-2 focus-visible:ring-[rgb(255_255_255)] active:scale-[0.97]"
              >
                {playing ? <Pause size={16} aria-hidden /> : <Play size={16} aria-hidden className="translate-x-px" />}
              </button>
            ) : null}
          </div>
          <figcaption className="mt-3 flex items-center justify-between gap-4 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
            <span>{caption}</span>
            <span className="tabular-nums">{duration}</span>
          </figcaption>
        </figure>
      </div>
    </section>
  )
}

Usage

Pass `src`; it autoplays muted unless reduced motion is on, where it waits for the play button.

Video card hero, StoreKit