Web InnoventixFreeCode

Expanding Strip Gallery

Original · free

A horizontal strip of image panels; hovering a panel expands it and shrinks the others to reveal its caption.

byWeb InnoventixReact + Tailwind
expandingstripgalleryimages
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/gallery-expanding-strip.json
gallery-expanding-strip.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { motion } from "motion/react";

type Panel = {
  src: string;
  category: string;
  title: string;
  location: string;
  alt: string;
};

const PANELS: Panel[] = [
  {
    src: "/img/gallery/g03.webp",
    category: "Coastal",
    title: "Salt & Low Tide",
    location: "Cape Perpetua, OR",
    alt: "Rocky shoreline washed in pale morning light as the tide draws back",
  },
  {
    src: "/img/gallery/g11.webp",
    category: "Studio",
    title: "Studio 04",
    location: "Analog session",
    alt: "Portrait lit with a single soft key against a deep shadowed backdrop",
  },
  {
    src: "/img/gallery/g18.webp",
    category: "Nature",
    title: "Understory",
    location: "Olympic Forest",
    alt: "Ferns and moss glowing in a shaft of forest light",
  },
  {
    src: "/img/gallery/g22.webp",
    category: "Urban",
    title: "Concrete Hours",
    location: "District 7",
    alt: "Geometric facade of a brutalist building at golden hour",
  },
  {
    src: "/img/gallery/g07.webp",
    category: "Coastal",
    title: "Blue Hour Pier",
    location: "North Jetty",
    alt: "A wooden pier stretching into calm water under a fading blue sky",
  },
  {
    src: "/img/gallery/g29.webp",
    category: "Still Life",
    title: "Table No. 9",
    location: "Kitchen window",
    alt: "Ceramic vessels and fruit arranged in warm window light",
  },
  {
    src: "/img/gallery/g14.webp",
    category: "Nature",
    title: "Cloud Ridge",
    location: "Cascade Pass",
    alt: "Layered mountain ridges disappearing into a bank of soft clouds",
  },
];

export default function GalleryExpandingStrip() {
  const [active, setActive] = useState(0);
  const panelRefs = useRef<(HTMLButtonElement | null)[]>([]);

  useEffect(() => {
    panelRefs.current = panelRefs.current.slice(0, PANELS.length);
  }, []);

  const focusPanel = (index: number) => {
    const clamped = (index + PANELS.length) % PANELS.length;
    setActive(clamped);
    panelRefs.current[clamped]?.focus();
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
    if (e.key === "ArrowRight") {
      e.preventDefault();
      focusPanel(index + 1);
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      focusPanel(index - 1);
    } else if (e.key === "Home") {
      e.preventDefault();
      focusPanel(0);
    } else if (e.key === "End") {
      e.preventDefault();
      focusPanel(PANELS.length - 1);
    }
  };

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-4 py-20 text-neutral-900 sm:px-6 lg:px-10 lg:py-28 dark:bg-neutral-950 dark:text-neutral-50">
      <style>{`
        @keyframes ges-caption-rise {
          from { opacity: 0; transform: translateY(14px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        @keyframes ges-sheen {
          from { transform: translateX(-120%) skewX(-12deg); }
          to   { transform: translateX(260%) skewX(-12deg); }
        }
        .ges-active .ges-caption { animation: ges-caption-rise 620ms cubic-bezier(0.16, 1, 0.3, 1) both; }
        .ges-active .ges-sheen { animation: ges-sheen 1400ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both; }
        @media (prefers-reduced-motion: reduce) {
          .ges-active .ges-caption,
          .ges-active .ges-sheen { animation: none !important; }
          .ges-panel { transition: none !important; }
          .ges-img { transition: none !important; }
        }
      `}</style>

      <div className="pointer-events-none absolute inset-0 -z-10 opacity-[0.5] [mask-image:radial-gradient(70%_60%_at_50%_0%,black,transparent)]">
        <div className="absolute inset-0 bg-[radial-gradient(60%_50%_at_20%_0%,theme(colors.indigo.200),transparent),radial-gradient(50%_50%_at_85%_10%,theme(colors.emerald.200),transparent)] dark:bg-[radial-gradient(60%_50%_at_20%_0%,theme(colors.indigo.900),transparent),radial-gradient(50%_50%_at_85%_10%,theme(colors.emerald.900),transparent)]" />
      </div>

      <div className="mx-auto max-w-7xl">
        <div className="mb-10 flex flex-col gap-5 sm:mb-14 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-xl">
            <motion.span
              initial={{ opacity: 0, y: 12 }}
              whileInView={{ opacity: 1, y: 0 }}
              viewport={{ once: true, margin: "-80px" }}
              transition={{ duration: 0.5 }}
              className="inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.2em] text-neutral-600 backdrop-blur dark:border-neutral-700 dark:bg-white/5 dark:text-neutral-300"
            >
              <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
              Selected Frames
            </motion.span>
            <motion.h2
              initial={{ opacity: 0, y: 16 }}
              whileInView={{ opacity: 1, y: 0 }}
              viewport={{ once: true, margin: "-80px" }}
              transition={{ duration: 0.55, delay: 0.05 }}
              className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl lg:text-5xl"
            >
              A strip of light,
              <span className="text-neutral-400 dark:text-neutral-500"> expanded.</span>
            </motion.h2>
          </div>
          <motion.p
            initial={{ opacity: 0, y: 16 }}
            whileInView={{ opacity: 1, y: 0 }}
            viewport={{ once: true, margin: "-80px" }}
            transition={{ duration: 0.55, delay: 0.1 }}
            className="max-w-sm text-sm leading-relaxed text-neutral-600 dark:text-neutral-400"
          >
            Hover, tap, or arrow through the panels. Each frame opens to reveal
            where it was made and the story it holds.
          </motion.p>
        </div>

        <motion.div
          initial={{ opacity: 0, y: 24 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-60px" }}
          transition={{ duration: 0.6 }}
          role="group"
          aria-label="Expanding image gallery. Use left and right arrow keys to move between frames."
          className="flex h-[460px] w-full flex-col gap-2 sm:h-[520px] sm:flex-row lg:h-[560px]"
        >
          {PANELS.map((panel, i) => {
            const isActive = active === i;
            return (
              <button
                key={panel.src}
                ref={(el) => {
                  panelRefs.current[i] = el;
                }}
                type="button"
                aria-label={`${panel.title}, ${panel.category}. ${panel.location}.`}
                aria-pressed={isActive}
                onMouseEnter={() => setActive(i)}
                onFocus={() => setActive(i)}
                onClick={() => setActive(i)}
                onKeyDown={(e) => onKeyDown(e, i)}
                style={{ flexGrow: isActive ? 7 : 1 }}
                className={[
                  "ges-panel group relative min-h-[64px] min-w-0 flex-shrink basis-0 overflow-hidden rounded-2xl outline-none",
                  "ring-1 ring-black/5 transition-[flex-grow] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] dark:ring-white/10",
                  "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950",
                  isActive ? "ges-active" : "",
                ].join(" ")}
              >
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={panel.src}
                  alt={panel.alt}
                  loading="lazy"
                  draggable={false}
                  className={[
                    "ges-img absolute inset-0 h-full w-full object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.16,1,0.3,1)] will-change-transform",
                    isActive ? "scale-100" : "scale-[1.18]",
                  ].join(" ")}
                />

                {/* readability gradient */}
                <div
                  className={[
                    "absolute inset-0 bg-gradient-to-t transition-opacity duration-500",
                    isActive
                      ? "from-black/75 via-black/20 to-black/10 opacity-100"
                      : "from-black/60 via-black/25 to-black/30 opacity-90",
                  ].join(" ")}
                />

                {/* sheen sweep on activate */}
                <span className="pointer-events-none absolute inset-y-0 left-0 w-1/2 overflow-hidden">
                  <span className="ges-sheen absolute inset-y-0 -left-1/2 block w-1/3 bg-gradient-to-r from-transparent via-white/25 to-transparent opacity-0 [.ges-active_&]:opacity-100" />
                </span>

                {/* collapsed vertical label */}
                <div
                  className={[
                    "absolute inset-0 flex items-end justify-center pb-6 transition-opacity duration-300",
                    isActive ? "pointer-events-none opacity-0" : "opacity-100",
                  ].join(" ")}
                >
                  <span className="[writing-mode:vertical-rl] rotate-180 text-[11px] font-semibold uppercase tracking-[0.24em] text-white/85 drop-shadow">
                    {panel.category}
                  </span>
                </div>

                {/* index badge */}
                <span
                  className={[
                    "absolute left-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold tabular-nums backdrop-blur transition-colors duration-300",
                    isActive
                      ? "bg-white text-neutral-900"
                      : "bg-black/30 text-white/90",
                  ].join(" ")}
                  aria-hidden="true"
                >
                  {String(i + 1).padStart(2, "0")}
                </span>

                {/* expanded caption */}
                <div
                  className={[
                    "ges-caption absolute inset-x-0 bottom-0 flex flex-col gap-1 p-5 text-left transition-opacity duration-300 sm:p-6",
                    isActive ? "opacity-100" : "pointer-events-none opacity-0",
                  ].join(" ")}
                >
                  <span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-indigo-300">
                    {panel.category}
                  </span>
                  <span className="text-xl font-semibold leading-tight text-white sm:text-2xl">
                    {panel.title}
                  </span>
                  <span className="flex items-center gap-1.5 text-xs text-white/70">
                    <svg
                      viewBox="0 0 24 24"
                      className="h-3.5 w-3.5"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
                      <circle cx="12" cy="10" r="3" />
                    </svg>
                    {panel.location}
                  </span>
                </div>
              </button>
            );
          })}
        </motion.div>

        <div className="mt-6 flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-500">
          <span aria-live="polite">
            {String(active + 1).padStart(2, "0")}
            <span className="mx-1 text-neutral-300 dark:text-neutral-700">/</span>
            {String(PANELS.length).padStart(2, "0")}
          </span>
          <span className="hidden items-center gap-2 sm:flex">
            <kbd className="rounded border border-neutral-300 bg-white px-1.5 py-0.5 font-sans text-[10px] dark:border-neutral-700 dark:bg-neutral-900">
              ←
            </kbd>
            <kbd className="rounded border border-neutral-300 bg-white px-1.5 py-0.5 font-sans text-[10px] dark:border-neutral-700 dark:bg-neutral-900">
              →
            </kbd>
            to explore
          </span>
        </div>
      </div>
    </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 →