Web InnoventixFreeCode

Circle Thumbs Gallery

Original · free

A row of circular thumbnails that scale and show a caption on hover.

byWeb InnoventixReact + Tailwind
circlethumbsgalleryimages
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-circle-thumbs.json
gallery-circle-thumbs.tsx
"use client";

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

type Thumb = {
  src: string;
  title: string;
  category: string;
  caption: string;
  alt: string;
};

const THUMBS: Thumb[] = [
  {
    src: "/img/gallery/g03.webp",
    title: "Coastal Dawn",
    category: "Landscape",
    caption: "First light spilling over the headland cliffs at Praia da Marinha.",
    alt: "Golden sunrise breaking over rugged coastal cliffs above a calm sea",
  },
  {
    src: "/img/gallery/g08.webp",
    title: "Studio Portrait",
    category: "People",
    caption: "Soft window light, 85mm, shot for the autumn lookbook.",
    alt: "Portrait of a person lit softly by natural window light in a studio",
  },
  {
    src: "/img/gallery/g14.webp",
    title: "Market Colours",
    category: "Street",
    caption: "Spice stalls at Marrakech's Jemaa el-Fnaa, mid-morning rush.",
    alt: "Vibrant spice and produce stall at a busy outdoor market",
  },
  {
    src: "/img/gallery/g21.webp",
    title: "Alpine Silence",
    category: "Nature",
    caption: "Fresh powder above the tree line, Dolomites in January.",
    alt: "Snow-covered alpine peaks under a clear winter sky",
  },
  {
    src: "/img/gallery/g05.webp",
    title: "Neon After Rain",
    category: "Urban",
    caption: "Reflections on wet asphalt, Shinjuku after the evening storm.",
    alt: "City street at night with neon signs reflected on wet pavement",
  },
  {
    src: "/img/gallery/g27.webp",
    title: "Ceramic Hands",
    category: "Craft",
    caption: "A potter centres clay on the wheel in a Lisbon workshop.",
    alt: "Close-up of hands shaping wet clay on a pottery wheel",
  },
  {
    src: "/img/gallery/g11.webp",
    title: "Desert Lines",
    category: "Landscape",
    caption: "Wind-carved dunes at golden hour, Erg Chebbi.",
    alt: "Sweeping sand dunes with sharp ridge lines at sunset",
  },
  {
    src: "/img/gallery/g33.webp",
    title: "Harbour Blue",
    category: "Travel",
    caption: "Fishing boats resting in the still morning harbour of Chefchaouen.",
    alt: "Small wooden fishing boats moored in a calm blue harbour",
  },
  {
    src: "/img/gallery/g18.webp",
    title: "Forest Canopy",
    category: "Nature",
    caption: "Looking straight up through old-growth redwoods in fog.",
    alt: "Tall forest trees seen from below with fog in the canopy",
  },
  {
    src: "/img/gallery/g30.webp",
    title: "Table Setting",
    category: "Food",
    caption: "Seasonal plating for the winter tasting menu.",
    alt: "Elegantly plated dish on a rustic table with soft light",
  },
];

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

  return (
    <section
      ref={sectionRef}
      className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-5 py-20 sm:px-8 sm:py-28 dark:from-zinc-950 dark:via-zinc-950 dark:to-black"
    >
      <style>{`
        @keyframes gct-float {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-6px); }
        }
        @keyframes gct-ring {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
        .gct-ring-spin { animation: gct-ring 14s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .gct-ring-spin { animation: none; }
          .gct-thumb { transition: none !important; }
        }
      `}</style>

      {/* ambient glow */}
      <div
        aria-hidden
        className="pointer-events-none absolute left-1/2 top-0 h-[420px] w-[820px] -translate-x-1/2 rounded-full bg-gradient-to-tr from-indigo-300/30 via-violet-200/20 to-transparent blur-3xl dark:from-indigo-500/15 dark:via-violet-500/10"
      />

      <div className="relative mx-auto max-w-6xl">
        <div className="mx-auto max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-widest text-indigo-600 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Field Portfolio
          </span>
          <h2 className="mt-5 text-balance text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Ten frames from the road
          </h2>
          <p className="mx-auto mt-3 max-w-md text-pretty text-sm leading-relaxed text-slate-600 sm:text-base dark:text-zinc-400">
            Hover, tap or focus a portrait to bring it forward and read the story
            behind the shot.
          </p>
        </div>

        <ul
          className="mt-14 flex flex-wrap items-start justify-center gap-x-6 gap-y-12 sm:gap-x-9 sm:gap-y-14"
          onMouseLeave={() => setActive(null)}
        >
          {THUMBS.map((thumb, i) => {
            const isActive = active === i;
            const dimmed = active !== null && !isActive;
            return (
              <motion.li
                key={thumb.src}
                initial={{ opacity: 0, y: 24, scale: 0.9 }}
                animate={
                  inView
                    ? { opacity: 1, y: 0, scale: 1 }
                    : { opacity: 0, y: 24, scale: 0.9 }
                }
                transition={{
                  duration: 0.55,
                  delay: i * 0.06,
                  ease: [0.22, 1, 0.36, 1],
                }}
                className="relative flex w-28 flex-col items-center sm:w-36"
              >
                <button
                  type="button"
                  aria-label={`${thumb.title} — ${thumb.category}. ${thumb.caption}`}
                  aria-pressed={isActive}
                  onMouseEnter={() => setActive(i)}
                  onFocus={() => setActive(i)}
                  onBlur={() => setActive(null)}
                  onClick={() => setActive(isActive ? null : i)}
                  className="group relative outline-none"
                >
                  {/* spinning gradient ring on active/hover */}
                  <span
                    aria-hidden
                    className={`gct-ring-spin absolute -inset-[6px] rounded-full bg-[conic-gradient(from_0deg,theme(colors.indigo.400),theme(colors.violet.400),theme(colors.emerald.400),theme(colors.indigo.400))] opacity-0 transition-opacity duration-500 group-hover:opacity-100 group-focus-visible:opacity-100 ${
                      isActive ? "opacity-100" : ""
                    }`}
                  />

                  <span
                    className={`gct-thumb relative block aspect-square w-28 overflow-hidden rounded-full border-2 border-white shadow-lg shadow-slate-900/10 ring-1 ring-slate-900/5 transition-all duration-500 ease-out sm:w-36 dark:border-zinc-800 dark:shadow-black/40 dark:ring-white/10 ${
                      isActive
                        ? "-translate-y-2 scale-110"
                        : "group-hover:-translate-y-1 group-hover:scale-105"
                    } ${dimmed ? "scale-95 opacity-55 saturate-50" : ""}`}
                  >
                    {/* eslint-disable-next-line @next/next/no-img-element */}
                    <img
                      src={thumb.src}
                      alt={thumb.alt}
                      loading="lazy"
                      draggable={false}
                      className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
                    />
                    <span
                      aria-hidden
                      className="pointer-events-none absolute inset-0 rounded-full bg-gradient-to-t from-slate-900/45 via-transparent to-white/10 opacity-60 transition-opacity duration-500 group-hover:opacity-30"
                    />
                  </span>

                  {/* category chip */}
                  <span
                    className={`absolute -bottom-1 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-700 shadow-sm transition-all duration-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 ${
                      isActive
                        ? "translate-y-1 opacity-100"
                        : "opacity-0 group-hover:translate-y-1 group-hover:opacity-100"
                    }`}
                  >
                    {thumb.category}
                  </span>
                </button>

                {/* caption reveal */}
                <div
                  className={`mt-4 overflow-hidden text-center transition-all duration-500 ease-out ${
                    isActive
                      ? "max-h-32 opacity-100"
                      : "max-h-0 opacity-0"
                  }`}
                >
                  <p className="text-sm font-semibold text-slate-900 dark:text-white">
                    {thumb.title}
                  </p>
                  <p className="mx-auto mt-1 max-w-[15rem] text-pretty text-xs leading-relaxed text-slate-500 dark:text-zinc-400">
                    {thumb.caption}
                  </p>
                </div>
              </motion.li>
            );
          })}
        </ul>

        <p className="mt-16 text-center text-xs text-slate-400 dark:text-zinc-600">
          {THUMBS.length} selects · shot 2024–2026 · available as archival prints
        </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.