Web InnoventixFreeCode

Media Gallery Player

Original · free

media gallery with a lightbox player

byWeb InnoventixReact + Tailwind
mediagalleryplayer
Open

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
media-gallery-player.tsx
"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

motion

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 quote

Similar components

Browse all →