Web InnoventixFreeCode

Accordion Vertical Gallery

Original · free

Vertical stacked image panels where the active one expands to show its photo and caption.

byWeb InnoventixReact + Tailwind
accordionverticalgalleryimages
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-accordion-vertical.json
gallery-accordion-vertical.tsx
"use client";

import { useState, useRef, useCallback } from "react";
import {
  motion,
  AnimatePresence,
  useInView,
  type Variants,
} from "motion/react";

type Panel = {
  src: string;
  alt: string;
  title: string;
  category: string;
  place: string;
  year: string;
  caption: string;
};

const PANELS: Panel[] = [
  {
    src: "/img/gallery/g04.webp",
    alt: "Fog rolling between dark evergreen ridgelines at first light",
    title: "Timberline at Dawn",
    category: "Landscape",
    place: "Cascade Range, Oregon",
    year: "2024",
    caption:
      "Shot at 5:41 a.m. as the valley inversion burned off. The cold air held the fog just below the ridge, so the tree line looked like it was floating on a sea of cloud.",
  },
  {
    src: "/img/gallery/g11.webp",
    alt: "Neon signage reflected in wet pavement on a narrow city lane",
    title: "After the Rain, Nakameguro",
    category: "Street",
    place: "Tokyo, Japan",
    year: "2023",
    caption:
      "A ten-minute downpour turned the whole alley into a mirror. I waited for a single cyclist to cut through the reflection and pressed once.",
  },
  {
    src: "/img/gallery/g19.webp",
    alt: "Sunlit interior courtyard with a spiral staircase and warm terracotta walls",
    title: "The Spiral, Casa Fontana",
    category: "Architecture",
    place: "Seville, Spain",
    year: "2024",
    caption:
      "Midday light does something remarkable to terracotta. The staircase becomes a sundial, and every hour the shadow lands on a different step.",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "Close portrait of a weathered fisherman mending a net by the harbour",
    title: "Anders, Harbour Mender",
    category: "Portrait",
    place: "Lofoten, Norway",
    year: "2022",
    caption:
      "He mended nets for forty-one winters. We spoke for an hour before he let me raise the camera, and the trust is the only reason this frame exists.",
  },
  {
    src: "/img/gallery/g23.webp",
    alt: "Wind-carved orange sand dunes under a deep cobalt sky",
    title: "Dune 45, Long Shadow",
    category: "Landscape",
    place: "Sossusvlei, Namibia",
    year: "2023",
    caption:
      "Climbing the ridgeline in the dark to reach the crest before sunrise. The wind resets these edges every night, so no two mornings look the same.",
  },
  {
    src: "/img/gallery/g30.webp",
    alt: "Steam rising from a ceramic bowl in a minimalist kitchen with morning light",
    title: "First Pour",
    category: "Still Life",
    place: "Studio, Copenhagen",
    year: "2025",
    caption:
      "A single window, a black backdrop, and the steam doing all the work. Natural light only, no diffusion; the contrast is the whole point.",
  },
  {
    src: "/img/gallery/g15.webp",
    alt: "Long-exposure river of car light trails winding through a mountain pass at dusk",
    title: "Pass Road, Blue Hour",
    category: "Long Exposure",
    place: "Dolomites, Italy",
    year: "2024",
    caption:
      "Thirty-second exposure stacked four times to fill the switchbacks with light. The blue hour gives you maybe eleven usable minutes.",
  },
  {
    src: "/img/gallery/g28.webp",
    alt: "Two figures walking a vast salt flat that mirrors the pastel sky perfectly",
    title: "Mirror Season",
    category: "Landscape",
    place: "Salar de Uyuni, Bolivia",
    year: "2023",
    caption:
      "A thin film of water over the salt turns the ground into a perfect mirror. We walked out a kilometre to lose every horizon line.",
  },
];

const PREFIX = "gav";

const captionVariants: Variants = {
  hidden: { opacity: 0, y: 14 },
  show: (i: number) => ({
    opacity: 1,
    y: 0,
    transition: { delay: 0.12 + i * 0.06, duration: 0.45, ease: [0.22, 1, 0.36, 1] },
  }),
};

export default function GalleryAccordionVertical() {
  const [active, setActive] = useState<number>(0);
  const sectionRef = useRef<HTMLDivElement>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const inView = useInView(sectionRef, { once: true, margin: "-80px" });

  const onKeyNav = useCallback(
    (e: React.KeyboardEvent, index: number) => {
      let next: number | null = null;
      if (e.key === "ArrowDown" || e.key === "ArrowRight") {
        next = Math.min(PANELS.length - 1, index + 1);
      } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
        next = Math.max(0, index - 1);
      } else if (e.key === "Home") {
        next = 0;
      } else if (e.key === "End") {
        next = PANELS.length - 1;
      }
      if (next !== null) {
        e.preventDefault();
        setActive(next);
        tabRefs.current[next]?.focus();
      }
    },
    []
  );

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-white via-slate-50 to-slate-100 py-20 dark:from-neutral-950 dark:via-neutral-950 dark:to-black sm:py-28">
      <style>{`
        @keyframes ${PREFIX}-rise {
          from { opacity: 0; transform: translateY(28px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        @keyframes ${PREFIX}-glow {
          0%, 100% { opacity: 0.35; }
          50%      { opacity: 0.7; }
        }
        .${PREFIX}-head { animation: ${PREFIX}-rise 0.7s cubic-bezier(0.22,1,0.36,1) both; }
        .${PREFIX}-orb  { animation: ${PREFIX}-glow 7s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .${PREFIX}-head, .${PREFIX}-orb { animation: none !important; }
        }
      `}</style>

      {/* ambient background accents */}
      <div
        aria-hidden
        className={`${PREFIX}-orb pointer-events-none absolute -left-24 top-24 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-600/20`}
      />
      <div
        aria-hidden
        className={`${PREFIX}-orb pointer-events-none absolute -right-16 bottom-16 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-700/20`}
        style={{ animationDelay: "1.4s" }}
      />

      <div className="relative mx-auto w-full max-w-6xl px-5 sm:px-8">
        {/* header */}
        <div className={`${PREFIX}-head mb-10 max-w-2xl sm:mb-14`}>
          <div className="mb-4 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3.5 py-1.5 text-xs font-medium uppercase tracking-[0.18em] text-indigo-600 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/70 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
            Field Notes
          </div>
          <h2 className="text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl md:text-5xl">
            Eight frames, eight mornings
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-neutral-400 sm:text-lg">
            A vertical field journal. Open a panel to expand the photograph and read
            the note behind the shot. Use the arrow keys to move between frames.
          </p>
        </div>

        {/* accordion */}
        <div
          ref={sectionRef}
          role="tablist"
          aria-label="Photograph gallery"
          aria-orientation="vertical"
          className="flex flex-col gap-2.5"
        >
          {PANELS.map((panel, i) => {
            const isActive = i === active;
            return (
              <motion.div
                key={panel.src}
                role="presentation"
                initial={{ opacity: 0, y: 20 }}
                animate={inView ? { opacity: 1, y: 0 } : {}}
                transition={{
                  delay: 0.15 + i * 0.05,
                  duration: 0.5,
                  ease: [0.22, 1, 0.36, 1],
                }}
                className={`group relative overflow-hidden rounded-2xl border transition-colors duration-500 ${
                  isActive
                    ? "border-indigo-300 bg-white shadow-xl shadow-indigo-500/10 dark:border-indigo-500/40 dark:bg-neutral-900 dark:shadow-black/40"
                    : "border-slate-200 bg-white/60 hover:border-slate-300 dark:border-neutral-800 dark:bg-neutral-900/50 dark:hover:border-neutral-700"
                }`}
              >
                <button
                  type="button"
                  role="tab"
                  ref={(el) => {
                    tabRefs.current[i] = el;
                  }}
                  id={`${PREFIX}-tab-${i}`}
                  aria-selected={isActive}
                  aria-expanded={isActive}
                  aria-controls={`${PREFIX}-panel-${i}`}
                  tabIndex={isActive ? 0 : -1}
                  onClick={() => setActive(i)}
                  onKeyDown={(e) => onKeyNav(e, i)}
                  className="flex w-full items-center gap-4 px-4 py-4 text-left focus: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-neutral-900 sm:px-6 sm:py-5"
                >
                  {/* index / thumb */}
                  <div className="relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl sm:h-14 sm:w-14">
                    {/* eslint-disable-next-line @next/next/no-img-element */}
                    <img
                      src={panel.src}
                      alt=""
                      loading="lazy"
                      draggable={false}
                      className={`absolute inset-0 h-full w-full object-cover transition-all duration-500 ${
                        isActive
                          ? "scale-100 opacity-100"
                          : "scale-110 opacity-70 grayscale group-hover:opacity-90 group-hover:grayscale-0"
                      }`}
                    />
                    <span className="absolute inset-0 ring-1 ring-inset ring-black/10 dark:ring-white/10" />
                  </div>

                  <div className="min-w-0 flex-1">
                    <div className="flex items-center gap-2">
                      <span
                        className={`text-[11px] font-semibold uppercase tracking-[0.16em] transition-colors ${
                          isActive
                            ? "text-indigo-600 dark:text-indigo-300"
                            : "text-slate-400 dark:text-neutral-500"
                        }`}
                      >
                        {panel.category}
                      </span>
                      <span className="text-[11px] tabular-nums text-slate-400 dark:text-neutral-600">
                        · {panel.year}
                      </span>
                    </div>
                    <h3
                      className={`mt-0.5 truncate text-lg font-medium tracking-tight transition-colors sm:text-xl ${
                        isActive
                          ? "text-slate-900 dark:text-white"
                          : "text-slate-700 dark:text-neutral-300"
                      }`}
                    >
                      {panel.title}
                    </h3>
                  </div>

                  {/* chevron */}
                  <span
                    aria-hidden
                    className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border transition-all duration-500 ${
                      isActive
                        ? "rotate-180 border-indigo-300 bg-indigo-50 text-indigo-600 dark:border-indigo-500/40 dark:bg-indigo-500/15 dark:text-indigo-300"
                        : "border-slate-200 bg-transparent text-slate-400 dark:border-neutral-700 dark:text-neutral-500"
                    }`}
                  >
                    <svg
                      width="16"
                      height="16"
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2.2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    >
                      <path d="m6 9 6 6 6-6" />
                    </svg>
                  </span>
                </button>

                {/* expanding body */}
                <AnimatePresence initial={false}>
                  {isActive && (
                    <motion.div
                      key="body"
                      id={`${PREFIX}-panel-${i}`}
                      role="tabpanel"
                      aria-labelledby={`${PREFIX}-tab-${i}`}
                      initial={{ height: 0, opacity: 0 }}
                      animate={{ height: "auto", opacity: 1 }}
                      exit={{ height: 0, opacity: 0 }}
                      transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
                      className="overflow-hidden"
                    >
                      <div className="grid gap-5 px-4 pb-5 sm:grid-cols-[1.4fr_1fr] sm:gap-6 sm:px-6 sm:pb-6">
                        {/* photo */}
                        <div className="relative overflow-hidden rounded-xl bg-slate-100 dark:bg-neutral-800">
                          {/* eslint-disable-next-line @next/next/no-img-element */}
                          <img
                            src={panel.src}
                            alt={panel.alt}
                            loading="lazy"
                            draggable={false}
                            className="h-full max-h-[420px] w-full object-cover"
                          />
                          <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/30 via-transparent to-transparent" />
                          <div className="pointer-events-none absolute inset-0 ring-1 ring-inset ring-black/10 dark:ring-white/10" />
                        </div>

                        {/* caption block */}
                        <div className="flex flex-col justify-center">
                          <motion.p
                            custom={0}
                            variants={captionVariants}
                            initial="hidden"
                            animate="show"
                            className="text-base leading-relaxed text-slate-700 dark:text-neutral-300"
                          >
                            {panel.caption}
                          </motion.p>

                          <motion.dl
                            custom={1}
                            variants={captionVariants}
                            initial="hidden"
                            animate="show"
                            className="mt-5 grid grid-cols-2 gap-4 border-t border-slate-200 pt-5 dark:border-neutral-800"
                          >
                            <div>
                              <dt className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400 dark:text-neutral-500">
                                Location
                              </dt>
                              <dd className="mt-1 text-sm font-medium text-slate-800 dark:text-neutral-200">
                                {panel.place}
                              </dd>
                            </div>
                            <div>
                              <dt className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400 dark:text-neutral-500">
                                Captured
                              </dt>
                              <dd className="mt-1 text-sm font-medium text-slate-800 dark:text-neutral-200">
                                {panel.year}
                              </dd>
                            </div>
                          </motion.dl>

                          <motion.div
                            custom={2}
                            variants={captionVariants}
                            initial="hidden"
                            animate="show"
                            className="mt-5"
                          >
                            <span className="inline-flex items-center gap-2 rounded-full bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
                              <span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
                              Frame {String(i + 1).padStart(2, "0")} of{" "}
                              {String(PANELS.length).padStart(2, "0")}
                            </span>
                          </motion.div>
                        </div>
                      </div>
                    </motion.div>
                  )}
                </AnimatePresence>

                {/* active accent bar */}
                <span
                  aria-hidden
                  className={`pointer-events-none absolute left-0 top-0 h-full w-1 origin-top bg-gradient-to-b from-indigo-500 to-violet-500 transition-transform duration-500 ${
                    isActive ? "scale-y-100" : "scale-y-0"
                  }`}
                />
              </motion.div>
            );
          })}
        </div>

        {/* footer hint */}
        <p className="mt-8 text-center text-xs text-slate-400 dark:text-neutral-600">
          {PANELS.length} photographs · shot on location · ↑ ↓ to browse
        </p>
      </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 →
Hover Caption Gallery

Hover Caption Gallery

Original

A responsive image grid where each tile subtly zooms and reveals a caption bar on hover.

Masonry Gallery

Masonry Gallery

Original

A Pinterest-style masonry gallery using CSS columns with varied-height images; tiles lift and brighten on hover.

Filterable Gallery

Filterable Gallery

Original

A filterable gallery with category tabs; clicking a tab animates the grid to show only that category.

Tilt Cards Gallery

Tilt Cards Gallery

Original

A grid of image cards that tilt in 3D toward the cursor on hover, with a soft glare.

Lightbox Gallery

Lightbox Gallery

Original

A thumbnail grid where clicking a thumb opens a full-screen lightbox with prev/next arrows and keyboard support.

Expanding Strip Gallery

Expanding Strip Gallery

Original

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

Marquee Gallery

Marquee Gallery

Original

Two rows of images auto-scrolling in opposite directions, pausing on hover, with edge fade masks.

Hover Zoom Overlay Gallery

Hover Zoom Overlay Gallery

Original

A grid where each image scales up under a dark gradient overlay revealing a title and a view button on hover.

Polaroid Stack Gallery

Polaroid Stack Gallery

Original

A scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.

Featured Thumbs Gallery

Featured Thumbs Gallery

Original

A large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

Carousel Gallery

Carousel Gallery

Original

An image carousel with arrows and dots, smooth slide transitions and autoplay that pauses on hover.

Duotone Hover Gallery

Duotone Hover Gallery

Original

A grid where images sit under a coloured duotone wash that clears to full colour on hover.