Media Video Card
Original · freevideo thumbnail card with play overlay
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/media-video-card.json"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Chapter = { at: number; label: string };
type Video = {
id: string;
title: string;
episode: string;
posted: string;
views: string;
duration: number;
watched: number;
blurb: string;
thumb: string;
alt: string;
chapters: Chapter[];
};
const VIDEOS: Video[] = [
{
id: "kb-11",
title: "The last hand-cut dovetail shop in Rotherham",
episode: "Episode 11",
posted: "4 Jun 2026",
views: "182K views",
duration: 862,
watched: 0,
blurb:
"Alan Petrie has cut every joint in this workshop by hand since 1978. No router jigs, no CNC. We spent three days watching him build a tool chest and asking the question his apprentices ask: why not just buy the machine?",
thumb: "/img/gallery/g07.webp",
alt: "A woodworker's bench lit from a side window, hand tools laid out across the surface",
chapters: [
{ at: 0, label: "The shop at 6am" },
{ at: 148, label: "Why the machine never arrived" },
{ at: 361, label: "Cutting the tails" },
{ at: 604, label: "What his apprentices get wrong" },
{ at: 790, label: "Closing the chest" },
],
},
{
id: "kb-10",
title: "Why this potter fires at 1280°C and not a degree less",
episode: "Episode 10",
posted: "21 May 2026",
views: "97K views",
duration: 587,
watched: 247,
blurb:
"Meera Sandhu lost an entire kiln load in February to a thermocouple that drifted by eleven degrees. She walks us through the firing curve she rebuilt from scratch, and the two hours where nothing can go wrong.",
thumb: "/img/gallery/g19.webp",
alt: "Rows of unglazed ceramic vessels waiting beside a kiln",
chapters: [
{ at: 0, label: "Loading the kiln" },
{ at: 96, label: "The February failure" },
{ at: 254, label: "Reading the cones" },
{ at: 430, label: "Opening day" },
],
},
{
id: "kb-09",
title: "Rebuilding a 1953 Wurlitzer, one felt at a time",
episode: "Episode 09",
posted: "8 May 2026",
views: "241K views",
duration: 1266,
watched: 1089,
blurb:
"Sixty-one hammers, every one of them hardened past use. Tomas Beltrán has restored 340 pianos and still refuses to voice a hammer before the action is regulated. This is the long version, uncut.",
thumb: "/img/gallery/g28.webp",
alt: "The exposed action of a vintage electric piano with hammers and dampers visible",
chapters: [
{ at: 0, label: "What arrived on the truck" },
{ at: 212, label: "Pulling the action" },
{ at: 545, label: "Regulate first, voice second" },
{ at: 918, label: "First chord in forty years" },
],
},
{
id: "kb-08",
title: "The blacksmith who only works in winter",
episode: "Episode 08",
posted: "23 Apr 2026",
views: "64K views",
duration: 758,
watched: 0,
blurb:
"Ingrid Halvorsen shuts the forge from May to September. Not for holiday — because she reads colour off the steel by eye, and summer light through the door lies to her about temperature.",
thumb: "/img/gallery/g12.webp",
alt: "A dim forge interior with a glowing bar of steel resting on an anvil",
chapters: [
{ at: 0, label: "Six months of dark" },
{ at: 175, label: "Reading heat by colour" },
{ at: 402, label: "Drawing out the taper" },
{ at: 640, label: "Why summer lies" },
],
},
];
function formatTime(seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function spokenTime(seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m} minute${m === 1 ? "" : "s"} ${s} second${s === 1 ? "" : "s"}`;
}
function PlayGlyph({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className={className}>
<path d="M8 5.14v13.72a.6.6 0 0 0 .92.5l10.7-6.86a.6.6 0 0 0 0-1l-10.7-6.86a.6.6 0 0 0-.92.5Z" fill="currentColor" />
</svg>
);
}
function PauseGlyph({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className={className}>
<rect x="6.5" y="5" width="3.6" height="14" rx="1.2" fill="currentColor" />
<rect x="13.9" y="5" width="3.6" height="14" rx="1.2" fill="currentColor" />
</svg>
);
}
function SoundOnGlyph({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={className}>
<path d="M11 5 6.5 8.8H3.4v6.4h3.1L11 19Z" />
<path d="M15.2 9.3a3.7 3.7 0 0 1 0 5.4" />
<path d="M17.9 6.5a7.5 7.5 0 0 1 0 11" />
</svg>
);
}
function SoundOffGlyph({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={className}>
<path d="M11 5 6.5 8.8H3.4v6.4h3.1L11 19Z" />
<path d="m15.4 9.6 5 4.8" />
<path d="m20.4 9.6-5 4.8" />
</svg>
);
}
function BookmarkGlyph({ filled, className }: { filled: boolean; className?: string }) {
return (
<svg viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={className}>
<path d="M6.5 4.6h11a1 1 0 0 1 1 1v13.6a.5.5 0 0 1-.78.41L12 15.6l-5.72 4.01a.5.5 0 0 1-.78-.41V5.6a1 1 0 0 1 1-1Z" />
</svg>
);
}
export default function MediaVideoCard() {
const reduce = useReducedMotion();
const seekId = useId();
const listId = useId();
const [activeIndex, setActiveIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0);
const [muted, setMuted] = useState(false);
const [saved, setSaved] = useState<string[]>(["kb-09"]);
const [scrubbing, setScrubbing] = useState(false);
const barRef = useRef<HTMLDivElement | null>(null);
const timeRef = useRef(0);
const active = VIDEOS[activeIndex];
const ratio = active.duration === 0 ? 0 : time / active.duration;
const isSaved = saved.includes(active.id);
const commitTime = useCallback((next: number) => {
timeRef.current = next;
setTime(next);
}, []);
useEffect(() => {
if (!playing) return;
const duration = active.duration;
const id = window.setInterval(() => {
const next = Math.min(duration, timeRef.current + 0.25);
timeRef.current = next;
setTime(next);
if (next >= duration) setPlaying(false);
}, 250);
return () => window.clearInterval(id);
}, [playing, active.duration]);
const selectVideo = useCallback(
(index: number) => {
setActiveIndex(index);
commitTime(VIDEOS[index].watched);
setPlaying(false);
},
[commitTime],
);
const seekToClientX = useCallback(
(clientX: number) => {
const el = barRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.width === 0) return;
const next = (clientX - rect.left) / rect.width;
commitTime(Math.min(1, Math.max(0, next)) * active.duration);
},
[active.duration, commitTime],
);
const onSeekKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const step =
event.key === "ArrowLeft" || event.key === "ArrowRight" ? 5 : 15;
let next: number | null = null;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = time - step;
else if (event.key === "ArrowRight" || event.key === "ArrowUp") next = time + step;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = active.duration;
if (next === null) return;
event.preventDefault();
commitTime(Math.min(active.duration, Math.max(0, next)));
},
[time, active.duration, commitTime],
);
const toggleSaved = useCallback(() => {
setSaved((list) =>
list.includes(active.id)
? list.filter((id) => id !== active.id)
: [...list, active.id],
);
}, [active.id]);
const fade = reduce
? { duration: 0 }
: { duration: 0.45, ease: [0.22, 1, 0.36, 1] as const };
return (
<section className="relative w-full bg-slate-50 px-4 py-20 sm:px-6 sm:py-28 dark:bg-slate-950">
<style>{`
@keyframes mvc-ring {
0% { transform: scale(1); opacity: 0.55; }
70% { transform: scale(1.7); opacity: 0; }
100% { transform: scale(1.7); opacity: 0; }
}
@keyframes mvc-eq {
0%, 100% { transform: scaleY(0.28); }
50% { transform: scaleY(1); }
}
.mvc-ring { animation: mvc-ring 2.6s cubic-bezier(0.22, 1, 0.36, 1) infinite; }
.mvc-eq-bar { animation: mvc-eq 900ms ease-in-out infinite; transform-origin: bottom; }
@media (prefers-reduced-motion: reduce) {
.mvc-ring, .mvc-eq-bar { animation: none !important; }
.mvc-ring { opacity: 0; }
.mvc-eq-bar { transform: scaleY(0.6); }
}
`}</style>
<div className="mx-auto w-full max-w-6xl">
<header className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Kiln & Bench — Season 2
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Short films about people who still make things by hand
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Four episodes, filmed on location across two winters. No sponsors, no
b-roll library — just long takes of work that takes a long time.
</p>
</header>
<div className="mt-12 grid gap-6 lg:grid-cols-[1.6fr_1fr]">
<article className="overflow-hidden rounded-3xl bg-white shadow-sm ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10">
<div className="group relative aspect-video overflow-hidden bg-slate-900">
<AnimatePresence initial={false}>
<motion.div
key={active.id}
initial={{ opacity: 0, scale: reduce ? 1 : 1.04 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={fade}
className="absolute inset-0"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.thumb}
alt={active.alt}
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
</motion.div>
</AnimatePresence>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/85 via-slate-950/10 to-slate-950/35" />
<div className="pointer-events-none absolute left-4 top-4 flex items-center gap-2">
<span className="rounded-full bg-slate-950/70 px-2.5 py-1 text-[11px] font-medium uppercase tracking-wider text-white/90 backdrop-blur-sm">
{active.episode}
</span>
{playing ? (
<span className="flex items-center gap-1.5 rounded-full bg-rose-600/90 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-white backdrop-blur-sm">
<svg viewBox="0 0 12 12" className="h-3 w-3" aria-hidden="true">
<rect className="mvc-eq-bar" x="1" y="2" width="2" height="8" rx="1" fill="currentColor" style={{ animationDelay: "0ms" }} />
<rect className="mvc-eq-bar" x="5" y="2" width="2" height="8" rx="1" fill="currentColor" style={{ animationDelay: "160ms" }} />
<rect className="mvc-eq-bar" x="9" y="2" width="2" height="8" rx="1" fill="currentColor" style={{ animationDelay: "320ms" }} />
</svg>
Playing
</span>
) : null}
</div>
<span className="pointer-events-none absolute right-4 top-4 rounded-md bg-slate-950/75 px-2 py-1 font-mono text-xs font-medium text-white/95 tabular-nums backdrop-blur-sm">
{formatTime(active.duration)}
</span>
<div className="absolute inset-0 flex items-center justify-center">
<span className="relative flex h-20 w-20 items-center justify-center">
{!playing && !reduce ? (
<span className="mvc-ring pointer-events-none absolute inset-0 rounded-full bg-white/70" />
) : null}
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-label={
playing
? `Pause “${active.title}”`
: `Play “${active.title}”, ${spokenTime(active.duration)}`
}
className={[
"relative flex h-16 w-16 items-center justify-center rounded-full text-slate-900 shadow-xl shadow-slate-950/40 outline-none ring-1 ring-white/60 backdrop-blur transition duration-300",
"bg-white/95 hover:scale-105 hover:bg-white active:scale-95",
"focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900",
"motion-reduce:transition-none motion-reduce:hover:scale-100 motion-reduce:active:scale-100",
playing
? "opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
: "opacity-100",
].join(" ")}
>
{playing ? (
<PauseGlyph className="h-7 w-7" />
) : (
<PlayGlyph className="ml-0.5 h-7 w-7" />
)}
</button>
</span>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 px-5 pb-4">
<h3 className="text-lg font-semibold leading-snug text-white drop-shadow sm:text-xl">
{active.title}
</h3>
<p className="mt-1 text-xs text-white/70">
{active.views} · {active.posted}
</p>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 bg-white/20">
<div
className="h-full bg-rose-500"
style={{ width: `${ratio * 100}%` }}
/>
</div>
</div>
<div className="border-t border-slate-200 px-5 py-4 dark:border-white/10">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? "Pause" : "Play"}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-900 text-white outline-none transition hover:bg-slate-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-reduce:transition-none dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
>
{playing ? (
<PauseGlyph className="h-4 w-4" />
) : (
<PlayGlyph className="ml-0.5 h-4 w-4" />
)}
</button>
<span className="w-11 shrink-0 font-mono text-xs text-slate-500 tabular-nums dark:text-slate-400">
{formatTime(time)}
</span>
<div
ref={barRef}
id={seekId}
role="slider"
tabIndex={0}
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={active.duration}
aria-valuenow={Math.floor(time)}
aria-valuetext={`${spokenTime(time)} of ${spokenTime(active.duration)}`}
onKeyDown={onSeekKeyDown}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
setScrubbing(true);
seekToClientX(event.clientX);
}}
onPointerMove={(event) => {
if (scrubbing) seekToClientX(event.clientX);
}}
onPointerUp={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId);
setScrubbing(false);
}}
onPointerCancel={() => setScrubbing(false)}
className="group relative h-8 flex-1 cursor-pointer touch-none select-none rounded-md outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
>
<span className="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-slate-200 dark:bg-white/15">
<span
className="absolute inset-y-0 left-0 rounded-full bg-rose-500"
style={{ width: `${ratio * 100}%` }}
/>
</span>
{active.chapters.slice(1).map((chapter) => (
<span
key={chapter.at}
className="pointer-events-none absolute top-1/2 h-1.5 w-0.5 -translate-y-1/2 bg-white/90 dark:bg-slate-900/80"
style={{ left: `${(chapter.at / active.duration) * 100}%` }}
/>
))}
<span
className="pointer-events-none absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-rose-500 shadow ring-2 ring-white transition-transform group-hover:scale-125 motion-reduce:transition-none dark:ring-slate-900"
style={{ left: `${ratio * 100}%` }}
/>
</div>
<span className="w-11 shrink-0 text-right font-mono text-xs text-slate-500 tabular-nums dark:text-slate-400">
−{formatTime(active.duration - time)}
</span>
<button
type="button"
onClick={() => setMuted((m) => !m)}
aria-pressed={muted}
aria-label="Mute audio"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-500 outline-none transition hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-reduce:transition-none dark:text-slate-400 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
>
{muted ? (
<SoundOffGlyph className="h-4.5 w-4.5" />
) : (
<SoundOnGlyph className="h-4.5 w-4.5" />
)}
</button>
<button
type="button"
onClick={toggleSaved}
aria-pressed={isSaved}
aria-label={`Save “${active.title}” to watch later`}
className={[
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full outline-none transition hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-reduce:transition-none dark:hover:bg-white/10 dark:focus-visible:ring-offset-slate-900",
isSaved
? "text-indigo-600 dark:text-indigo-400"
: "text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white",
].join(" ")}
>
<BookmarkGlyph filled={isSaved} className="h-4.5 w-4.5" />
</button>
</div>
<div className="mt-4 flex flex-wrap gap-1.5">
{active.chapters.map((chapter) => {
const current =
time >= chapter.at &&
time <
(active.chapters.find((c) => c.at > chapter.at)?.at ??
active.duration + 1);
return (
<button
key={chapter.at}
type="button"
onClick={() => commitTime(chapter.at)}
aria-current={current ? "true" : undefined}
className={[
"rounded-full px-2.5 py-1 text-xs outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-reduce:transition-none dark:focus-visible:ring-offset-slate-900",
current
? "bg-indigo-600 font-medium text-white"
: "bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900 dark:bg-white/10 dark:text-slate-300 dark:hover:bg-white/20 dark:hover:text-white",
].join(" ")}
>
<span className="font-mono tabular-nums opacity-60">
{formatTime(chapter.at)}
</span>{" "}
{chapter.label}
</button>
);
})}
</div>
<p className="mt-4 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{active.blurb}
</p>
</div>
</article>
<div className="flex flex-col">
<h3
id={listId}
className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400"
>
Up next
</h3>
<ul aria-labelledby={listId} className="mt-3 flex flex-col gap-3">
{VIDEOS.map((video, index) => {
const isActive = index === activeIndex;
const progress =
isActive && time > 0
? time / video.duration
: video.watched / video.duration;
return (
<li key={video.id}>
<button
type="button"
onClick={() => selectVideo(index)}
aria-current={isActive ? "true" : undefined}
className={[
"group flex w-full gap-3 rounded-2xl p-2 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 motion-reduce:transition-none dark:focus-visible:ring-offset-slate-950",
isActive
? "bg-white shadow-sm ring-1 ring-indigo-500/40 dark:bg-slate-900 dark:ring-indigo-400/40"
: "hover:bg-white/70 dark:hover:bg-white/5",
].join(" ")}
>
<span className="relative aspect-video w-32 shrink-0 overflow-hidden rounded-xl bg-slate-900 sm:w-36">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={video.thumb}
alt={video.alt}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition duration-500 group-hover:scale-105 motion-reduce:transition-none motion-reduce:group-hover:scale-100"
/>
<span className="absolute inset-0 bg-slate-950/30 transition group-hover:bg-slate-950/10 motion-reduce:transition-none" />
<span className="absolute inset-0 flex items-center justify-center">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-slate-900 opacity-0 shadow transition duration-300 group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none">
<PlayGlyph className="ml-0.5 h-3.5 w-3.5" />
</span>
</span>
<span className="absolute bottom-1 right-1 rounded bg-slate-950/80 px-1.5 py-0.5 font-mono text-[10px] font-medium text-white tabular-nums">
{formatTime(video.duration)}
</span>
{progress > 0 ? (
<span className="absolute inset-x-0 bottom-0 h-1 bg-white/25">
<span
className="block h-full bg-rose-500"
style={{ width: `${Math.min(1, progress) * 100}%` }}
/>
</span>
) : null}
</span>
<span className="min-w-0 flex-1 py-0.5">
<span className="flex items-center gap-1.5">
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
{video.episode}
</span>
{saved.includes(video.id) ? (
<BookmarkGlyph
filled
className="h-3 w-3 text-indigo-500 dark:text-indigo-400"
/>
) : null}
</span>
<span
className={[
"mt-0.5 block text-sm font-medium leading-snug",
isActive
? "text-indigo-700 dark:text-indigo-300"
: "text-slate-900 dark:text-slate-100",
].join(" ")}
>
{video.title}
</span>
<span className="mt-1 block text-xs text-slate-500 dark:text-slate-400">
{video.views} · {video.posted}
</span>
</span>
</button>
</li>
);
})}
</ul>
<p className="mt-4 rounded-2xl bg-white/60 p-3 text-xs leading-relaxed text-slate-500 ring-1 ring-slate-200 dark:bg-white/5 dark:text-slate-400 dark:ring-white/10">
Episodes 1–7 are archived on the Season 1 page. New films land the
second Thursday of every month.
</p>
</div>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Media Video Player
Originalcustom video player controls UI

Media Audio Player
Originalaudio player with progress and volume

Media Podcast
Originalpodcast episode player card

Media Playlist
Originalmusic playlist with now-playing

Media Mini Player
Originalcompact sticky mini player

Media Waveform
Originalaudio waveform scrubber

Media Video Hero
Originalhero with a video-style backdrop

Media Gallery Player
Originalmedia gallery with a lightbox player

Media Live Badge
Originallive stream card with viewer count

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

