Web InnoventixFreeCode

Image Marquee

Original · free

image marquee

byWeb InnoventixReact + Tailwind
mqximagemarquees
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/mqx-image.json
mqx-image.tsx
"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { useReducedMotion } from "motion/react";

type Photo = {
  id: string;
  src: string;
  alt: string;
  title: string;
  place: string;
  year: string;
};

type Entry = { photo: Photo; index: number };

type StyleVars = CSSProperties & Record<string, string | number>;

type SpeedId = "slow" | "normal" | "fast";

const PHOTOS: Photo[] = [
  { id: "g01", src: "/img/gallery/g01.webp", alt: "Low tide exposing black volcanic sand at first light, Jokulsarlon, Iceland", title: "Low tide at dawn", place: "Jokulsarlon, Iceland", year: "2021" },
  { id: "g02", src: "/img/gallery/g02.webp", alt: "Silk lanterns strung above a market lane before the stalls open, Hoi An, Vietnam", title: "Lanterns before opening", place: "Hoi An, Vietnam", year: "2019" },
  { id: "g03", src: "/img/gallery/g03.webp", alt: "A yellow funicular tram climbing a fog-filled hillside street, Lisbon, Portugal", title: "Tram into the fog", place: "Lisbon, Portugal", year: "2022" },
  { id: "g04", src: "/img/gallery/g04.webp", alt: "Flooded salt flat mirroring a pale sky at the horizon, Uyuni, Bolivia", title: "The mirrored flats", place: "Uyuni, Bolivia", year: "2018" },
  { id: "g05", src: "/img/gallery/g05.webp", alt: "Red sandstone switchbacks carved into a desert canyon wall, Moab, Utah", title: "Red rock switchbacks", place: "Moab, Utah", year: "2020" },
  { id: "g06", src: "/img/gallery/g06.webp", alt: "Laundry lines strung between old buildings over a narrow alley, Naples, Italy", title: "Rooftop laundry lines", place: "Naples, Italy", year: "2019" },
  { id: "g07", src: "/img/gallery/g07.webp", alt: "First snow dusting a hairpin mountain pass at daybreak, Furka, Switzerland", title: "First snow on the pass", place: "Furka, Switzerland", year: "2023" },
  { id: "g08", src: "/img/gallery/g08.webp", alt: "Blue fishing boats resting on still water at slack tide, Essaouira, Morocco", title: "Boats at slack water", place: "Essaouira, Morocco", year: "2017" },
  { id: "g09", src: "/img/gallery/g09.webp", alt: "Terraced rice paddies catching low golden light on a hillside, Bali, Indonesia", title: "Terraces at golden hour", place: "Bali, Indonesia", year: "2018" },
  { id: "g10", src: "/img/gallery/g10.webp", alt: "An empty subway platform lit by fluorescent light near the last train, Seoul, South Korea", title: "Last train home", place: "Seoul, South Korea", year: "2022" },
  { id: "g11", src: "/img/gallery/g11.webp", alt: "Wind-sculpted dunes with fresh ripples after a sandstorm, Merzouga, Sahara", title: "After the sandstorm", place: "Merzouga, Sahara", year: "2019" },
  { id: "g12", src: "/img/gallery/g12.webp", alt: "Silhouetted harbor cranes standing against a deep blue-hour sky, Rotterdam, Netherlands", title: "Cranes at blue hour", place: "Rotterdam, Netherlands", year: "2021" },
  { id: "g13", src: "/img/gallery/g13.webp", alt: "Stone steps climbing to a cliffside monastery above the valley, Meteora, Greece", title: "Steps to the monastery", place: "Meteora, Greece", year: "2016" },
  { id: "g14", src: "/img/gallery/g14.webp", alt: "Wildflowers crowding a weathered fence line under rolling hills, Palouse, Washington", title: "Along the fence line", place: "Palouse, Washington", year: "2020" },
  { id: "g15", src: "/img/gallery/g15.webp", alt: "Rain falling under a medieval arcade in the old town center, Bologna, Italy", title: "Arcade in the rain", place: "Bologna, Italy", year: "2019" },
  { id: "g16", src: "/img/gallery/g16.webp", alt: "A ferry cutting a bright wake across calm water at sunrise, Bergen, Norway", title: "Ferry wake at sunrise", place: "Bergen, Norway", year: "2023" },
  { id: "g17", src: "/img/gallery/g17.webp", alt: "Steam rising from a roadside tea stall wedged between train tracks, Varanasi, India", title: "Tea between trains", place: "Varanasi, India", year: "2017" },
  { id: "g18", src: "/img/gallery/g18.webp", alt: "A braided glacial river fanning across a grey gravel plain, Southern Alps, New Zealand", title: "Braided glacier melt", place: "Southern Alps, NZ", year: "2018" },
  { id: "g19", src: "/img/gallery/g19.webp", alt: "A narrow neon-lit alley reflected in wet pavement after midnight, Osaka, Japan", title: "Neon alley, midnight", place: "Osaka, Japan", year: "2022" },
  { id: "g20", src: "/img/gallery/g20.webp", alt: "A cypress-lined dirt road curving through a summer haze, Val d'Orcia, Italy", title: "Cypress road", place: "Val d'Orcia, Italy", year: "2019" },
  { id: "g21", src: "/img/gallery/g21.webp", alt: "Two ice climbers roped to a towering blue frozen waterfall, Ouray, Colorado", title: "The blue wall", place: "Ouray, Colorado", year: "2021" },
  { id: "g22", src: "/img/gallery/g22.webp", alt: "Brightly painted houses stacked up a steep hillside, Valparaiso, Chile", title: "Houses on the slope", place: "Valparaiso, Chile", year: "2016" },
  { id: "g23", src: "/img/gallery/g23.webp", alt: "An empty desert highway running straight toward distant dunes, Namib, Namibia", title: "No traffic for miles", place: "Namib, Namibia", year: "2018" },
  { id: "g24", src: "/img/gallery/g24.webp", alt: "Fog rolling beneath the deck of a long suspension bridge, San Francisco, California", title: "Fog under the bridge", place: "San Francisco, CA", year: "2020" },
];

const SPEEDS: { id: SpeedId; label: string; factor: number }[] = [
  { id: "slow", label: "Slow", factor: 0.6 },
  { id: "normal", label: "Normal", factor: 1 },
  { id: "fast", label: "Fast", factor: 1.6 },
];

const MASK_STYLE: CSSProperties = {
  WebkitMaskImage:
    "linear-gradient(to right, transparent 0, #000 7%, #000 93%, transparent 100%)",
  maskImage:
    "linear-gradient(to right, transparent 0, #000 7%, #000 93%, transparent 100%)",
};

function PlayIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
      <path d="M6 4.5v11l9-5.5-9-5.5Z" fill="currentColor" />
    </svg>
  );
}

function PauseIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
      <path d="M6 4.5h2.5v11H6zM11.5 4.5H14v11h-2.5z" fill="currentColor" />
    </svg>
  );
}

function CloseIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
      <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
    </svg>
  );
}

function ChevronIcon({ dir }: { dir: "left" | "right" }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
      <path
        d={dir === "left" ? "M15 5l-7 7 7 7" : "M9 5l7 7-7 7"}
        stroke="currentColor"
        strokeWidth="1.8"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export default function MqxImage() {
  const reduce = useReducedMotion();

  const [playing, setPlaying] = useState(true);
  const [speed, setSpeed] = useState<SpeedId>("normal");
  const [hoverPause, setHoverPause] = useState(true);
  const [openIndex, setOpenIndex] = useState<number | null>(null);

  const dialogRef = useRef<HTMLDivElement | null>(null);
  const closeBtnRef = useRef<HTMLButtonElement | null>(null);
  const triggerRef = useRef<HTMLButtonElement | null>(null);

  const speedFactor = useMemo(
    () => SPEEDS.find((s) => s.id === speed)?.factor ?? 1,
    [speed],
  );

  const rows = useMemo(() => {
    const withIndex: Entry[] = PHOTOS.map((photo, index) => ({ photo, index }));
    return [
      { key: "a", dir: "left" as const, dur: "42s", items: withIndex.slice(0, 8) },
      { key: "b", dir: "right" as const, dur: "54s", items: withIndex.slice(8, 16) },
      { key: "c", dir: "left" as const, dur: "48s", items: withIndex.slice(16, 24) },
    ];
  }, []);

  const openAt = useCallback((index: number, el: HTMLButtonElement) => {
    triggerRef.current = el;
    setOpenIndex(index);
  }, []);

  const close = useCallback(() => {
    setOpenIndex(null);
    triggerRef.current?.focus();
  }, []);

  const step = useCallback((delta: number) => {
    setOpenIndex((cur) => {
      if (cur === null) return cur;
      return (cur + delta + PHOTOS.length) % PHOTOS.length;
    });
  }, []);

  const isOpen = openIndex !== null;

  useEffect(() => {
    if (!isOpen) return;
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    const raf = window.requestAnimationFrame(() => closeBtnRef.current?.focus());
    return () => {
      document.body.style.overflow = prevOverflow;
      window.cancelAnimationFrame(raf);
    };
  }, [isOpen]);

  const onDialogKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLDivElement>) => {
      if (e.key === "Escape") {
        e.preventDefault();
        close();
        return;
      }
      if (e.key === "ArrowRight") {
        e.preventDefault();
        step(1);
        return;
      }
      if (e.key === "ArrowLeft") {
        e.preventDefault();
        step(-1);
        return;
      }
      if (e.key === "Tab") {
        const root = dialogRef.current;
        if (!root) return;
        const focusables = Array.from(
          root.querySelectorAll<HTMLElement>(
            'button, [href], [tabindex]:not([tabindex="-1"])',
          ),
        ).filter((el) => !el.hasAttribute("disabled"));
        if (focusables.length === 0) return;
        const first = focusables[0];
        const last = focusables[focusables.length - 1];
        const active = document.activeElement;
        if (e.shiftKey && active === first) {
          e.preventDefault();
          last.focus();
        } else if (!e.shiftKey && active === last) {
          e.preventDefault();
          first.focus();
        }
      }
    },
    [close, step],
  );

  const current = openIndex === null ? null : PHOTOS[openIndex];

  const sectionStyle: StyleVars = { "--mqximg-speed": speedFactor };

  return (
    <section
      className="relative w-full overflow-hidden bg-neutral-50 py-20 text-neutral-900 sm:py-24 dark:bg-neutral-950 dark:text-neutral-100"
      style={sectionStyle}
      data-playing={reduce ? "false" : playing ? "true" : "false"}
      data-hoverpause={hoverPause ? "true" : "false"}
      aria-labelledby="mqximg-heading"
    >
      <style>{`
        .mqximg-track {
          animation-timing-function: linear;
          animation-iteration-count: infinite;
          animation-play-state: running;
          will-change: transform;
        }
        [data-playing="false"] .mqximg-track { animation-play-state: paused; }
        [data-hoverpause="true"] .mqximg-row:hover .mqximg-track,
        [data-hoverpause="true"] .mqximg-row:focus-within .mqximg-track {
          animation-play-state: paused;
        }
        @keyframes mqximg-scroll-left {
          from { transform: translate3d(0, 0, 0); }
          to { transform: translate3d(-50%, 0, 0); }
        }
        @keyframes mqximg-scroll-right {
          from { transform: translate3d(-50%, 0, 0); }
          to { transform: translate3d(0, 0, 0); }
        }
        @media (prefers-reduced-motion: reduce) {
          .mqximg-track {
            animation: none !important;
            transform: none !important;
          }
        }
      `}</style>

      {/* Ambient backdrop */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-70"
        style={{
          backgroundImage:
            "radial-gradient(60% 55% at 15% 0%, rgba(99,102,241,0.14), transparent 60%), radial-gradient(50% 50% at 90% 10%, rgba(16,185,129,0.12), transparent 60%)",
        }}
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-[0.035] dark:opacity-[0.06]"
        style={{
          backgroundImage:
            "linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)",
          backgroundSize: "44px 44px",
        }}
      />

      <div className="relative mx-auto max-w-7xl px-4 sm:px-6">
        {/* Header */}
        <div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
          <div className="max-w-2xl">
            <span className="inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white/60 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-neutral-600 dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-400">
              <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true" />
              Field Archive
            </span>
            <h2
              id="mqximg-heading"
              className="mt-5 text-4xl font-semibold tracking-tight text-neutral-900 sm:text-5xl dark:text-neutral-50"
            >
              Frames from the road
            </h2>
            <p className="mt-4 text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
              Twenty-four stills pulled from six years of carrying a single fixed
              lens across four continents. Let the reel run, or tap any frame to
              read where and when it was made.
            </p>
          </div>

          {/* Controls */}
          <div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center lg:flex-col lg:items-end">
            <div className="flex flex-wrap items-center gap-3">
              <button
                type="button"
                onClick={() => setPlaying((p) => !p)}
                disabled={Boolean(reduce)}
                aria-pressed={playing}
                aria-label={playing ? "Pause the marquee" : "Play the marquee"}
                className="inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200 dark:focus-visible:ring-offset-neutral-950"
              >
                {playing ? <PauseIcon /> : <PlayIcon />}
                {playing ? "Pause" : "Play"}
              </button>

              <div
                role="group"
                aria-label="Marquee speed"
                className="inline-flex items-center rounded-full border border-neutral-300 bg-white/70 p-1 dark:border-neutral-700 dark:bg-neutral-900/70"
              >
                {SPEEDS.map((s) => {
                  const active = s.id === speed;
                  return (
                    <button
                      key={s.id}
                      type="button"
                      onClick={() => setSpeed(s.id)}
                      disabled={Boolean(reduce)}
                      aria-pressed={active}
                      className={`rounded-full px-3 py-1 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:focus-visible:ring-offset-neutral-900 ${
                        active
                          ? "bg-neutral-900 text-white dark:bg-white dark:text-neutral-900"
                          : "text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
                      }`}
                    >
                      {s.label}
                    </button>
                  );
                })}
              </div>
            </div>

            <button
              type="button"
              role="switch"
              aria-checked={hoverPause}
              onClick={() => setHoverPause((v) => !v)}
              disabled={Boolean(reduce)}
              className="inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition-colors hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:text-neutral-400 dark:hover:text-neutral-100 dark:focus-visible:ring-offset-neutral-950"
            >
              <span
                aria-hidden="true"
                className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
                  hoverPause
                    ? "bg-emerald-500"
                    : "bg-neutral-300 dark:bg-neutral-700"
                }`}
              >
                <span
                  className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
                    hoverPause ? "translate-x-4" : "translate-x-0.5"
                  }`}
                />
              </span>
              Pause on hover
            </button>
          </div>
        </div>

        {reduce ? (
          <p className="mt-6 text-sm text-neutral-500 dark:text-neutral-500">
            Motion is reduced on your device, so the reel is paused. Scroll each
            row sideways to browse, or select a frame to view it full size.
          </p>
        ) : null}
      </div>

      {/* Marquee rows */}
      <div className="relative mt-12 flex flex-col gap-6">
        {rows.map((row) => {
          const items = reduce ? row.items : [...row.items, ...row.items];
          const trackStyle: CSSProperties = reduce
            ? {}
            : {
                animationName:
                  row.dir === "left"
                    ? "mqximg-scroll-left"
                    : "mqximg-scroll-right",
                animationDuration: `calc(${row.dur} / var(--mqximg-speed))`,
              };
          return (
            <div
              key={row.key}
              className={`mqximg-row group relative ${
                reduce ? "overflow-x-auto" : "overflow-hidden"
              }`}
              style={reduce ? undefined : MASK_STYLE}
            >
              <ul
                className="mqximg-track flex w-max items-stretch gap-5 px-2"
                style={trackStyle}
              >
                {items.map((entry, i) => {
                  const dup = !reduce && i >= row.items.length;
                  const p = entry.photo;
                  return (
                    <li
                      key={`${row.key}-${p.id}-${i}`}
                      aria-hidden={dup || undefined}
                      className="shrink-0"
                    >
                      <button
                        type="button"
                        tabIndex={dup ? -1 : 0}
                        onClick={(e) => openAt(entry.index, e.currentTarget)}
                        className="group/card relative block h-44 w-64 overflow-hidden rounded-2xl border border-neutral-200/80 bg-neutral-200 text-left shadow-sm transition-[transform,box-shadow] duration-300 hover:-translate-y-1 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 sm:h-52 sm:w-72 dark:border-neutral-800 dark:bg-neutral-800 dark:focus-visible:ring-offset-neutral-950"
                        aria-label={`View ${p.title}, ${p.place}, ${p.year}`}
                      >
                        {/* eslint-disable-next-line @next/next/no-img-element */}
                        <img
                          src={p.src}
                          alt={p.alt}
                          loading="lazy"
                          draggable={false}
                          className="h-full w-full object-cover transition-transform duration-500 group-hover/card:scale-105"
                        />
                        <span
                          aria-hidden="true"
                          className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/5 to-transparent opacity-80"
                        />
                        <span className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 p-3">
                          <span className="min-w-0">
                            <span className="block truncate text-sm font-semibold text-white">
                              {p.title}
                            </span>
                            <span className="block truncate text-xs text-white/70">
                              {p.place}
                            </span>
                          </span>
                          <span className="rounded-full bg-white/15 px-2 py-0.5 text-[10px] font-medium tracking-wide text-white/90 backdrop-blur-sm">
                            {p.year}
                          </span>
                        </span>
                      </button>
                    </li>
                  );
                })}
              </ul>
            </div>
          );
        })}
      </div>

      {/* Lightbox */}
      {current ? (
        <div
          className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
          role="presentation"
          onClick={close}
        >
          <div
            aria-hidden="true"
            className="absolute inset-0 bg-neutral-950/80 backdrop-blur-sm"
          />
          <div
            ref={dialogRef}
            role="dialog"
            aria-modal="true"
            aria-labelledby="mqximg-lightbox-title"
            aria-describedby="mqximg-lightbox-desc"
            onKeyDown={onDialogKeyDown}
            onClick={(e) => e.stopPropagation()}
            className="relative z-10 w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-neutral-900 shadow-2xl"
          >
            <div className="relative aspect-[3/2] w-full bg-neutral-800">
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={current.src}
                alt={current.alt}
                loading="lazy"
                draggable={false}
                className="h-full w-full object-cover"
              />

              <button
                ref={closeBtnRef}
                type="button"
                onClick={close}
                aria-label="Close viewer"
                className="absolute right-3 top-3 inline-flex h-10 w-10 items-center justify-center rounded-full bg-neutral-950/60 text-white transition-colors hover:bg-neutral-950/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900"
              >
                <CloseIcon />
              </button>

              <button
                type="button"
                onClick={() => step(-1)}
                aria-label="Previous photo"
                className="absolute left-3 top-1/2 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-neutral-950/60 text-white transition-colors hover:bg-neutral-950/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900"
              >
                <ChevronIcon dir="left" />
              </button>
              <button
                type="button"
                onClick={() => step(1)}
                aria-label="Next photo"
                className="absolute right-3 top-1/2 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-neutral-950/60 text-white transition-colors hover:bg-neutral-950/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900"
              >
                <ChevronIcon dir="right" />
              </button>
            </div>

            <div className="flex items-start justify-between gap-4 p-5">
              <div className="min-w-0">
                <h3
                  id="mqximg-lightbox-title"
                  className="text-lg font-semibold text-white"
                >
                  {current.title}
                </h3>
                <p
                  id="mqximg-lightbox-desc"
                  className="mt-1 text-sm text-neutral-400"
                >
                  {current.place} &middot; {current.year}
                </p>
              </div>
              <span className="shrink-0 rounded-full border border-white/15 px-3 py-1 text-xs font-medium tabular-nums text-neutral-300">
                {(openIndex ?? 0) + 1} / {PHOTOS.length}
              </span>
            </div>
          </div>
        </div>
      ) : null}
    </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 →