Media

Media Hero Carousel

A featured-content hero carousel with auto-advance, crossfading gradient backdrops, progress dots, and play/info actions.

Install

npx shadcn@latest add @paragon/media-hero-carousel

media-hero-carousel.tsx

"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>
  );
}