Media Gallery Player
Original · freemedia gallery with a lightbox player
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-gallery-player.json"use client";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Category = "Landscape" | "Street" | "Portrait" | "Craft";
type Shot = {
id: string;
src: string;
alt: string;
title: string;
category: Category;
place: string;
year: string;
caption: string;
exif: string;
};
const SHOTS: Shot[] = [
{
id: "s01",
src: "/img/gallery/g03.webp",
alt: "Stepped rice terraces catching low gold light in a wide valley",
title: "Terraces at First Light",
category: "Landscape",
place: "Sidemen, Bali",
year: "2024",
caption:
"The valley holds its shadow until about 6:40am, then the whole staircase lights up at once. I waited four mornings for a sky clear enough to let it happen.",
exif: "35mm · f/8 · 1/250s · ISO 100",
},
{
id: "s02",
src: "/img/gallery/g07.webp",
alt: "A ceramicist's wet hands pulling the wall of a bowl on a spinning wheel",
title: "The Potter's Hands",
category: "Craft",
place: "Bat Trang, Vietnam",
year: "2023",
caption:
"Hien has thrown roughly forty bowls a day since 1998. She pulls a wall in one motion and never looks at it — she reads the clay entirely through her fingertips.",
exif: "50mm · f/2.0 · 1/160s · ISO 800",
},
{
id: "s03",
src: "/img/gallery/g12.webp",
alt: "Neon signage reflected in a rain-slicked street after dark",
title: "After the Rain",
category: "Street",
place: "Shinjuku, Tokyo",
year: "2024",
caption:
"Twenty minutes of rain buys about an hour of reflections. The street becomes a second sky and everyone walks straight through it without looking down.",
exif: "28mm · f/1.8 · 1/60s · ISO 1600",
},
{
id: "s04",
src: "/img/gallery/g21.webp",
alt: "Close portrait of an elderly woman with deep laugh lines and bright eyes",
title: "Ninety Winters",
category: "Portrait",
place: "Chefchaouen, Morocco",
year: "2023",
caption:
"Fatima counted her age in winters, not years. She agreed to the photograph on the condition that I send a print, which I did, and which she framed in her doorway.",
exif: "85mm · f/1.4 · 1/400s · ISO 200",
},
{
id: "s05",
src: "/img/gallery/g18.webp",
alt: "Snow-dusted pine forest dissolving into pale winter fog",
title: "Silence, Measured",
category: "Landscape",
place: "Hokkaidō Highlands",
year: "2022",
caption:
"Minus eleven and no wind at all. Fog swallows the horizon line so completely that the trees read as brush strokes on wet paper.",
exif: "70mm · f/5.6 · 1/125s · ISO 400",
},
{
id: "s06",
src: "/img/gallery/g09.webp",
alt: "A blacksmith striking orange-hot steel, sparks arcing off the anvil",
title: "Twelve Strikes",
category: "Craft",
place: "Sheffield, England",
year: "2024",
caption:
"Steel gives you about twelve useful strikes before it drops below working heat. Everything decisive in this trade happens in under thirty seconds.",
exif: "35mm · f/2.8 · 1/1000s · ISO 640",
},
{
id: "s07",
src: "/img/gallery/g15.webp",
alt: "Commuters crossing a wide intersection seen from above at dusk",
title: "Crossing, 18:04",
category: "Street",
place: "Seoul, South Korea",
year: "2023",
caption:
"Every ninety seconds the intersection empties and refills with a completely different crowd. From six floors up it stops looking like people and starts looking like tide.",
exif: "135mm · f/4 · 1/500s · ISO 320",
},
{
id: "s08",
src: "/img/gallery/g26.webp",
alt: "Fisherman mending a green net on a harbour wall at sunrise",
title: "Mending Season",
category: "Portrait",
place: "Essaouira, Morocco",
year: "2022",
caption:
"The boats stay in for six weeks each spring and the whole harbour turns into a repair shop. Rachid says a net outlives the man who ties it if you keep after the holes.",
exif: "50mm · f/2.8 · 1/500s · ISO 100",
},
{
id: "s09",
src: "/img/gallery/g30.webp",
alt: "Turquoise shallows meeting dark volcanic rock, photographed from the air",
title: "Where Two Blues Meet",
category: "Landscape",
place: "Reynisfjara, Iceland",
year: "2024",
caption:
"Basalt on one side, glacial melt on the other, and a seam between them that moves a few metres every tide. Shot at 90 metres in a twelve-knot crosswind.",
exif: "24mm · f/5.6 · 1/800s · ISO 100",
},
{
id: "s10",
src: "/img/gallery/g05.webp",
alt: "A weaver's loom strung with indigo thread in a dim workshop",
title: "Four Hundred Threads",
category: "Craft",
place: "Oaxaca, Mexico",
year: "2023",
caption:
"Warping the loom takes two days and produces nothing you can sell. Nearly all of the craft is in the part nobody photographs.",
exif: "35mm · f/2.0 · 1/80s · ISO 1250",
},
{
id: "s11",
src: "/img/gallery/g33.webp",
alt: "A lone figure under a striped awning on an empty midday street",
title: "Shade Economy",
category: "Street",
place: "Cádiz, Spain",
year: "2022",
caption:
"Between two and five the town belongs to whoever owns an awning. This one had held the same square metre of shade for three hours.",
exif: "40mm · f/8 · 1/640s · ISO 200",
},
{
id: "s12",
src: "/img/gallery/g24.webp",
alt: "A young violinist mid-rehearsal, eyes closed under warm stage light",
title: "Bar Ninety-One",
category: "Portrait",
place: "Kraków, Poland",
year: "2024",
caption:
"She had played the passage wrong twice and stopped apologising by the third attempt. This is the take where she got it, and she didn't notice me at all.",
exif: "85mm · f/1.8 · 1/250s · ISO 1000",
},
];
const FILTERS: readonly ("All" | Category)[] = [
"All",
"Landscape",
"Street",
"Portrait",
"Craft",
];
const SLIDE_MS = 5200;
function PlayIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4 fill-current">
<path d="M8 5.14v13.72a1 1 0 0 0 1.53.85l10.4-6.86a1 1 0 0 0 0-1.7L9.53 4.29A1 1 0 0 0 8 5.14Z" />
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4 fill-current">
<path d="M7 4h3.2v16H7zM13.8 4H17v16h-3.2z" />
</svg>
);
}
function ChevronIcon({ dir }: { dir: "left" | "right" }) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ transform: dir === "left" ? "rotate(180deg)" : undefined }}
>
<path d="m9 5 7 7-7 7" />
</svg>
);
}
function CloseIcon() {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
}
function ExpandIcon() {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 4H4v5M15 4h5v5M15 20h5v-5M9 20H4v-5" />
</svg>
);
}
export default function MediaGalleryPlayer() {
const reduce = useReducedMotion();
const headingId = useId();
const dialogTitleId = useId();
const dialogDescId = useId();
const [filter, setFilter] = useState<"All" | Category>("All");
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const panelRef = useRef<HTMLDivElement | null>(null);
const stripRef = useRef<HTMLDivElement | null>(null);
const returnRef = useRef<HTMLButtonElement | null>(null);
const tileRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const visible = useMemo(
() => (filter === "All" ? SHOTS : SHOTS.filter((s) => s.category === filter)),
[filter],
);
const count = visible.length;
const active = visible[Math.min(index, count - 1)];
const go = useCallback(
(dir: number) => {
setProgress(0);
setIndex((i) => (i + dir + count) % count);
},
[count],
);
const openAt = useCallback((i: number, shotId: string) => {
const el = tileRefs.current.get(shotId);
returnRef.current = el ?? null;
setIndex(i);
setProgress(0);
setPlaying(false);
setOpen(true);
}, []);
const close = useCallback(() => {
setOpen(false);
setPlaying(false);
setProgress(0);
window.setTimeout(() => returnRef.current?.focus(), 0);
}, []);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
useEffect(() => {
if (!open) return;
const id = window.setTimeout(() => panelRef.current?.focus(), 0);
return () => window.clearTimeout(id);
}, [open]);
useEffect(() => {
if (!open || !playing) return;
let raf = 0;
let cancelled = false;
const start = performance.now();
const tick = (now: number) => {
if (cancelled) return;
const p = Math.min(1, (now - start) / SLIDE_MS);
setProgress(p);
if (p >= 1) {
go(1);
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelled = true;
cancelAnimationFrame(raf);
};
}, [open, playing, index, go]);
useEffect(() => {
if (!open) return;
const strip = stripRef.current;
if (!strip) return;
const target = strip.querySelector<HTMLElement>(`[data-strip-index="${index}"]`);
target?.scrollIntoView({
behavior: reduce ? "auto" : "smooth",
inline: "center",
block: "nearest",
});
}, [index, open, reduce]);
const onDialogKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.preventDefault();
close();
return;
}
if (e.key === "ArrowRight") {
e.preventDefault();
go(1);
return;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
go(-1);
return;
}
if (e.key === "Home") {
e.preventDefault();
setProgress(0);
setIndex(0);
return;
}
if (e.key === "End") {
e.preventDefault();
setProgress(0);
setIndex(count - 1);
return;
}
const target = e.target as HTMLElement;
const onControl = target.tagName === "BUTTON";
if ((e.key === " " || e.key === "k") && !onControl) {
e.preventDefault();
setPlaying((p) => !p);
return;
}
if (e.key !== "Tab") return;
const nodes = panelRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
);
if (!nodes || nodes.length === 0) return;
const list = Array.from(nodes);
const first = list[0];
const last = list[list.length - 1];
const activeEl = document.activeElement;
if (e.shiftKey && (activeEl === first || activeEl === panelRef.current)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && activeEl === last) {
e.preventDefault();
first.focus();
}
};
const onBackdrop = (e: ReactMouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) close();
};
const ring =
"focus-visible: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-950";
const ringDark =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900";
return (
<section
aria-labelledby={headingId}
className="relative w-full bg-white px-5 py-20 sm:px-8 sm:py-28 dark:bg-slate-950"
>
<style>{`
@keyframes mgp-ken {
0% { transform: scale(1.02) translate3d(0, 0, 0); }
100% { transform: scale(1.11) translate3d(-1.4%, -1.2%, 0); }
}
@keyframes mgp-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.mgp-ken-active { animation: mgp-ken 12s ease-out forwards; }
.mgp-live-dot { animation: mgp-pulse 1.6s ease-in-out infinite; }
.mgp-strip::-webkit-scrollbar { height: 6px; }
.mgp-strip::-webkit-scrollbar-thumb { background: rgba(148,163,184,.45); border-radius: 9999px; }
.mgp-strip::-webkit-scrollbar-track { background: transparent; }
@media (prefers-reduced-motion: reduce) {
.mgp-ken-active, .mgp-live-dot { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-6xl">
<div className="flex flex-col gap-6 border-b border-slate-200 pb-8 sm:flex-row sm:items-end sm:justify-between dark:border-slate-800">
<div className="max-w-xl">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Field Notes — Volume III
</p>
<h2
id={headingId}
className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-slate-50"
>
Twelve frames from four years of walking
</h2>
<p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Open any frame for the full story, or press play and let the reel run
itself. Arrow keys move between shots; Escape closes the viewer.
</p>
</div>
<div
role="group"
aria-label="Filter frames by category"
className="flex flex-wrap gap-2"
>
{FILTERS.map((f) => {
const on = filter === f;
return (
<button
key={f}
type="button"
aria-pressed={on}
onClick={() => {
setFilter(f);
setIndex(0);
}}
className={[
"rounded-full border px-3.5 py-1.5 text-xs font-medium transition-colors",
ring,
on
? "border-slate-900 bg-slate-900 text-white dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900"
: "border-slate-300 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:border-slate-500 dark:hover:text-slate-100",
].join(" ")}
>
{f}
<span className="ml-1.5 tabular-nums opacity-50">
{f === "All"
? SHOTS.length
: SHOTS.filter((s) => s.category === f).length}
</span>
</button>
);
})}
</div>
</div>
<ul className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<AnimatePresence mode="popLayout" initial={false}>
{visible.map((shot, i) => (
<motion.li
key={shot.id}
layout={!reduce}
initial={reduce ? false : { opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}
transition={{ duration: reduce ? 0 : 0.28, ease: [0.22, 1, 0.36, 1] }}
>
<button
type="button"
ref={(el) => {
if (el) tileRefs.current.set(shot.id, el);
else tileRefs.current.delete(shot.id);
}}
onClick={() => openAt(i, shot.id)}
aria-label={`Open ${shot.title}, ${shot.place}, ${shot.year}, in the viewer`}
className={[
"group relative block w-full overflow-hidden rounded-xl border border-slate-200 bg-slate-100 text-left transition-shadow hover:shadow-lg dark:border-slate-800 dark:bg-slate-900",
ring,
].join(" ")}
>
<div className="relative aspect-[4/3] w-full overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt={shot.alt}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.05] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
/>
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-t from-slate-950/75 via-slate-950/10 to-transparent"
/>
<span
aria-hidden="true"
className="absolute right-3 top-3 grid h-8 w-8 place-items-center rounded-full bg-white/85 text-slate-900 opacity-0 backdrop-blur transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none"
>
<ExpandIcon />
</span>
<div className="absolute inset-x-0 bottom-0 p-3.5">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-white/70">
{shot.category} · {shot.year}
</p>
<p className="mt-1 text-sm font-semibold text-white">
{shot.title}
</p>
</div>
</div>
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
</div>
<AnimatePresence>
{open && active ? (
<motion.div
key="mgp-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.18 }}
onMouseDown={onBackdrop}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/85 p-3 backdrop-blur-sm sm:p-6"
>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={dialogTitleId}
aria-describedby={dialogDescId}
tabIndex={-1}
onKeyDown={onDialogKeyDown}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.99 }}
transition={{ duration: reduce ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] }}
className="relative flex max-h-[94vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-700/60 bg-slate-900 shadow-2xl focus:outline-none"
>
<div className="flex items-center justify-between gap-3 border-b border-slate-800 px-4 py-3">
<div className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden="true"
className={[
"h-1.5 w-1.5 shrink-0 rounded-full",
playing ? "mgp-live-dot bg-emerald-400" : "bg-slate-600",
].join(" ")}
/>
<p className="truncate text-xs font-medium uppercase tracking-[0.16em] text-slate-400">
{playing ? "Reel playing" : "Viewer"} ·{" "}
<span className="tabular-nums">
{index + 1} / {count}
</span>
</p>
</div>
<button
type="button"
onClick={close}
aria-label="Close viewer"
className={[
"grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-slate-700 text-slate-300 transition-colors hover:bg-slate-800 hover:text-white",
ringDark,
].join(" ")}
>
<CloseIcon />
</button>
</div>
<div className="relative bg-slate-950">
<div
className="relative aspect-[16/10] w-full overflow-hidden sm:aspect-[16/9]"
aria-live="polite"
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={active.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.34 }}
className="absolute inset-0"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.src}
alt={active.alt}
loading="lazy"
draggable={false}
className={[
"h-full w-full object-cover",
playing && !reduce ? "mgp-ken-active" : "",
].join(" ")}
/>
</motion.div>
</AnimatePresence>
<button
type="button"
onClick={() => go(-1)}
aria-label="Previous frame"
className={[
"absolute left-3 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full border border-white/20 bg-slate-950/60 text-white backdrop-blur transition-colors hover:bg-slate-950/85",
ringDark,
].join(" ")}
>
<ChevronIcon dir="left" />
</button>
<button
type="button"
onClick={() => go(1)}
aria-label="Next frame"
className={[
"absolute right-3 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full border border-white/20 bg-slate-950/60 text-white backdrop-blur transition-colors hover:bg-slate-950/85",
ringDark,
].join(" ")}
>
<ChevronIcon dir="right" />
</button>
</div>
<div
className="h-1 w-full bg-slate-800"
role="progressbar"
aria-label="Time until the next frame"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)}
>
<div
className="h-full bg-indigo-500"
style={{ width: `${progress * 100}%` }}
/>
</div>
</div>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-5">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-pressed={playing}
className={[
"inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-3.5 py-2 text-xs font-semibold text-white transition-colors hover:bg-indigo-400",
ringDark,
].join(" ")}
>
{playing ? <PauseIcon /> : <PlayIcon />}
{playing ? "Pause reel" : "Play reel"}
</button>
<span className="rounded-lg border border-slate-700 px-2.5 py-1.5 text-[11px] font-medium text-slate-400">
{active.category}
</span>
<span className="rounded-lg border border-slate-700 px-2.5 py-1.5 text-[11px] font-medium tabular-nums text-slate-400">
{active.exif}
</span>
</div>
<div>
<h3
id={dialogTitleId}
className="text-lg font-semibold tracking-tight text-slate-50"
>
{active.title}
</h3>
<p className="mt-0.5 text-xs font-medium uppercase tracking-[0.14em] text-slate-500">
{active.place} · {active.year}
</p>
<p
id={dialogDescId}
className="mt-2.5 max-w-2xl text-sm leading-relaxed text-slate-300"
>
{active.caption}
</p>
</div>
<div
ref={stripRef}
className="mgp-strip -mx-1 flex gap-2 overflow-x-auto px-1 pb-2"
>
{visible.map((s, i) => {
const on = i === index;
return (
<button
key={s.id}
type="button"
data-strip-index={i}
aria-label={`Frame ${i + 1}: ${s.title}`}
aria-current={on ? "true" : undefined}
onClick={() => {
setProgress(0);
setIndex(i);
}}
className={[
"relative h-14 w-20 shrink-0 overflow-hidden rounded-md border-2 transition-opacity",
ringDark,
on
? "border-indigo-400 opacity-100"
: "border-transparent opacity-45 hover:opacity-80",
].join(" ")}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={s.src}
alt=""
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
</button>
);
})}
</div>
<p className="text-[11px] text-slate-500">
Keyboard: <kbd className="font-sans font-semibold">←</kbd>{" "}
<kbd className="font-sans font-semibold">→</kbd> to move ·{" "}
<kbd className="font-sans font-semibold">Space</kbd> to play or pause ·{" "}
<kbd className="font-sans font-semibold">Esc</kbd> to close
</p>
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</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 Video Card
Originalvideo thumbnail card with play overlay

Media Waveform
Originalaudio waveform scrubber

Media Video Hero
Originalhero with a video-style backdrop

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.

