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-cardhero-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-cardSource
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.