A featured-content hero carousel with auto-advance, crossfading gradient backdrops, progress dots, and play/info actions.
npx shadcn@latest add @paragon/media-hero-carousel"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronLeft, ChevronRight, Info, Play } from "lucide-react";
import { cn } from "@/lib/utils";
export interface HeroSlide {
id: string;
kicker: string;
title: string;
description: string;
meta: string;
colors: [string, string];
}
export interface MediaHeroCarouselProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
slides?: HeroSlide[];
/** Auto-advance interval in seconds. 0 disables. */
interval?: number;
/** Start on auto-play. */
autoPlay?: boolean;
}
const DEFAULT_SLIDES: HeroSlide[] = [
{
id: "1",
kicker: "New Series",
title: "The Ledger",
description:
"A forensic accountant races to expose a fraud before the quarter closes.",
meta: "2026 · Drama · 8 episodes",
colors: ["#4f46e5", "#0ea5e9"],
},
{
id: "2",
kicker: "Now Streaming",
title: "Runway",
description:
"Inside the founders betting everything on ninety days of cash.",
meta: "2026 · Documentary · Limited",
colors: ["#db2777", "#f97316"],
},
{
id: "3",
kicker: "Top 10 Today",
title: "Material Weakness",
description:
"When one number is wrong, an entire empire starts to wobble.",
meta: "2025 · Thriller · 2 seasons",
colors: ["#059669", "#0d9488"],
},
];
/**
* A featured-content hero carousel with auto-advance, crossfading gradient
* backdrops, and progress dots. Slides crossfade with a subtle rise; the timer
* pauses on hover/focus and when the tab is hidden, and auto-advance is off
* under reduced motion (manual controls always work). Prev/next and dots are
* real buttons.
*/
export function MediaHeroCarousel({
slides = DEFAULT_SLIDES,
interval = 5,
autoPlay = true,
className,
...props
}: MediaHeroCarouselProps) {
const [index, setIndex] = React.useState(0);
const [paused, setPaused] = React.useState(false);
const reduced = useReducedMotion();
const count = slides.length;
const go = React.useCallback(
(dir: number) => setIndex((i) => (i + dir + count) % count),
[count],
);
React.useEffect(() => {
if (!autoPlay || reduced || paused || interval <= 0) return;
const id = window.setInterval(() => {
if (document.hidden) return;
setIndex((i) => (i + 1) % count);
}, interval * 1000);
return () => window.clearInterval(id);
}, [autoPlay, reduced, paused, interval, count]);
const slide = slides[index];
return (
<div
data-slot="media-hero-carousel"
className={cn(
"relative aspect-[16/9] w-full max-w-2xl overflow-hidden rounded-xl text-white shadow-border",
className,
)}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
{...props}
>
<AnimatePresence initial={false}>
<motion.div
key={slide.id}
aria-hidden
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 1.03 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduced ? 0.2 : 0.5, ease: [0.22, 1, 0.36, 1] }}
className="absolute inset-0"
style={{
background: `linear-gradient(120deg, ${slide.colors[0]}, ${slide.colors[1]})`,
}}
/>
</AnimatePresence>
<div
aria-hidden
className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent"
/>
<div className="absolute inset-0 flex flex-col justify-end p-5 sm:p-7">
<AnimatePresence mode="wait">
<motion.div
key={slide.id}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 12, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
className="max-w-md"
>
<span className="text-xs font-semibold tracking-wide uppercase text-white/80">
{slide.kicker}
</span>
<h2 className="mt-1 text-2xl font-bold tracking-tight text-balance sm:text-3xl">
{slide.title}
</h2>
<p className="mt-1.5 text-sm text-white/85 text-pretty">
{slide.description}
</p>
<p className="mt-1 text-xs tabular-nums text-white/70">
{slide.meta}
</p>
<div className="mt-3 flex gap-2">
<button
type="button"
className="pressable inline-flex h-9 items-center gap-1.5 rounded-lg bg-white px-4 text-sm font-semibold text-black"
>
<Play className="size-4 fill-current" /> Play
</button>
<button
type="button"
className="pressable inline-flex h-9 items-center gap-1.5 rounded-lg bg-white/20 px-4 text-sm font-semibold text-white backdrop-blur-sm transition-colors duration-150 hover:bg-white/30"
>
<Info className="size-4" /> More info
</button>
</div>
</motion.div>
</AnimatePresence>
</div>
<button
type="button"
aria-label="Previous slide"
onClick={() => go(-1)}
className="pressable absolute top-1/2 left-2 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-black/30 text-white backdrop-blur-sm transition-colors duration-150 hover:bg-black/50"
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
aria-label="Next slide"
onClick={() => go(1)}
className="pressable absolute top-1/2 right-2 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-black/30 text-white backdrop-blur-sm transition-colors duration-150 hover:bg-black/50"
>
<ChevronRight className="size-5" />
</button>
<div className="absolute right-0 bottom-3 left-0 flex justify-center gap-1.5">
{slides.map((s, i) => (
<button
key={s.id}
type="button"
aria-label={`Go to slide ${i + 1}`}
aria-current={i === index ? "true" : undefined}
onClick={() => setIndex(i)}
className={cn(
"h-1.5 rounded-full transition-colors duration-200 ease-out",
i === index ? "w-5 bg-white" : "w-1.5 bg-white/50 hover:bg-white/80",
)}
/>
))}
</div>
</div>
);
}