# Gallery Section v1

> Ten side-by-side gallery panels with hover expansion, focus framing, and vertical indicator lines within a dedicated canvas area.

A shadcn/ui block from the [@uiception](https://uiception.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiception/gallery-section-v1).

- Page, with a live preview: https://shadcn.exchange/r/uiception/gallery-section-v1
- Screenshot: https://shadcn.exchange/shot/uiception/gallery-section-v1/light.png
- Registry item: https://uiception.com/r/gallery-section-v1.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/uiception/gallery-section-v1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://uiception.com/r/block-media.json`

## Files

### `registry/new-york/blocks/gallery-section-v1/page.tsx`

```tsx
import GallerySectionV1 from "./components/gallery-section-v1"

export default function Page() {
  return <GallerySectionV1 />
}
```

### `registry/new-york/blocks/gallery-section-v1/components/gallery-section-v1.tsx`

```tsx
import GalleryTrack from "./gallery-track"
import { createBlockImage } from "@/lib/block-media"
import { imageFiles, sectionMeta, type GalleryItem } from "../lib/config"

const blockImage = createBlockImage("gallery-section-v1")
const galleryItems: GalleryItem[] = imageFiles.map(({ file, alt }, index) => ({
  id: `panel-${index + 1}`,
  label: `Panel ${index + 1}`,
  alt,
  imageSrc: blockImage(file),
}))

export type GallerySectionV1Props = {
  /** Pass `false` to render only the gallery canvas (no title or description). */
  header?: { title: string; description?: string } | false
  items?: GalleryItem[]
}

export default function GallerySectionV1({
  header = sectionMeta,
  items = galleryItems,
}: GallerySectionV1Props = {}) {
  return (
    <section className="py-4 md:py-6 lg:py-8">
      <div className="mx-auto max-w-7xl px-4">
        {header !== false ? (
          <div className="mb-10 md:mb-12">
            <h2 className="text-4xl font-medium leading-[1.1] tracking-[-0.03em] lg:text-5xl">
              {header.title}
            </h2>
            {header.description ? (
              <p className="mt-3 max-w-xl text-[0.9375rem] leading-relaxed text-muted-foreground">
                {header.description}
              </p>
            ) : null}
          </div>
        ) : null}
        <GalleryTrack items={items} />
      </div>
    </section>
  )
}
```

### `registry/new-york/blocks/gallery-section-v1/components/gallery-track.tsx`

```tsx
"use client"

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"

import GalleryPanel from "./gallery-panel"
import type { GalleryItem } from "../lib/config"
import "../styles/gallery-track.css"

type GalleryTrackProps = {
  items: GalleryItem[]
}

export default function GalleryTrack({ items }: GalleryTrackProps) {
  const [activeIndex, setActiveIndex] = useState(0)
  const [coarsePointer, setCoarsePointer] = useState(false)

  const trackRef = useRef<HTMLDivElement>(null)
  const canvasRef = useRef<HTMLDivElement>(null)
  const panelRefs = useRef<(HTMLDivElement | null)[]>([])
  const activeIndexRef = useRef(activeIndex)
  const topLineRef = useRef<HTMLDivElement>(null)
  const bottomLineRef = useRef<HTMLDivElement>(null)
  const scheduleLinesRef = useRef<() => void>(() => {})

  useLayoutEffect(() => {
    activeIndexRef.current = activeIndex
  })

  useEffect(() => {
    const mq = window.matchMedia("(hover: none)")
    const update = () => setCoarsePointer(mq.matches)
    update()
    mq.addEventListener("change", update)
    return () => mq.removeEventListener("change", update)
  }, [])

  const handleActivate = useCallback((index: number) => {
    setActiveIndex(index)
  }, [])

  const handleMouseLeave = () => {
    if (!coarsePointer) setActiveIndex(0)
  }

  const handleFocusOut = (event: React.FocusEvent<HTMLDivElement>) => {
    const next = event.relatedTarget
    if (next instanceof Node && event.currentTarget.contains(next)) return
    setActiveIndex(0)
  }

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "ArrowRight") {
      event.preventDefault()
      const next = (activeIndex + 1) % items.length
      setActiveIndex(next)
      panelRefs.current[next]?.focus()
    } else if (event.key === "ArrowLeft") {
      event.preventDefault()
      const prev = (activeIndex - 1 + items.length) % items.length
      setActiveIndex(prev)
      panelRefs.current[prev]?.focus()
    }
  }

  useLayoutEffect(() => {
    const track = trackRef.current
    const canvas = canvasRef.current
    if (!track || !canvas) return

    let rafId = 0

    const commitLines = () => {
      const currentTrack = trackRef.current
      const currentCanvas = canvasRef.current
      const currentPanel = panelRefs.current[activeIndexRef.current]
      if (!currentTrack || !currentCanvas || !currentPanel) return
      if (!topLineRef.current || !bottomLineRef.current) return

      const panelRect = currentPanel.getBoundingClientRect()
      const trackRect = currentTrack.getBoundingClientRect()
      const canvasRect = currentCanvas.getBoundingClientRect()
      const left = panelRect.left + panelRect.width / 2 - canvasRect.left

      topLineRef.current.style.transform = `translateX(${left}px)`
      topLineRef.current.style.height = `${trackRect.top - canvasRect.top}px`
      bottomLineRef.current.style.transform = `translateX(${left}px)`
      bottomLineRef.current.style.top = `${trackRect.bottom - canvasRect.top}px`
      bottomLineRef.current.style.height = `${canvasRect.bottom - trackRect.bottom}px`
    }

    const scheduleLines = () => {
      cancelAnimationFrame(rafId)
      rafId = requestAnimationFrame(commitLines)
    }

    scheduleLinesRef.current = scheduleLines

    commitLines()

    const observer = new ResizeObserver(scheduleLines)
    panelRefs.current.forEach((panel) => {
      if (panel) observer.observe(panel)
    })

    window.addEventListener("resize", scheduleLines, { passive: true })

    return () => {
      cancelAnimationFrame(rafId)
      observer.disconnect()
      window.removeEventListener("resize", scheduleLines)
    }
  }, [])

  useEffect(() => {
    scheduleLinesRef.current()
  }, [activeIndex])

  return (
    <div className="gsv1-root relative w-full">
      <div ref={canvasRef} className="gsv1-canvas">
        <div
          ref={topLineRef}
          aria-hidden
          className="gsv1-vline gsv1-vline--top"
          style={{ top: 0 }}
        />
        <div
          ref={bottomLineRef}
          aria-hidden
          className="gsv1-vline gsv1-vline--bottom"
        />
        <div
          ref={trackRef}
          aria-label="Photo gallery — use arrow keys to navigate"
          className="gsv1-track flex h-96 w-full overflow-x-auto overflow-y-visible md:h-104 md:overflow-x-hidden"
          role="list"
          onBlurCapture={handleFocusOut}
          onKeyDown={handleKeyDown}
          onMouseLeave={handleMouseLeave}
        >
          {items.map((item, index) => (
            <GalleryPanel
              key={item.id}
              ref={(node) => {
                panelRefs.current[index] = node
              }}
              index={index}
              isActive={activeIndex === index}
              item={item}
              priority={index === 0}
              onActivate={handleActivate}
            />
          ))}
        </div>
      </div>
    </div>
  )
}
```

### `registry/new-york/blocks/gallery-section-v1/components/gallery-panel.tsx`

```tsx
"use client"

import { forwardRef, memo, useState } from "react"
import Image from "next/image"

import type { GalleryItem } from "../lib/config"

type GalleryPanelProps = {
  item: GalleryItem
  index: number
  isActive: boolean
  priority?: boolean
  onActivate: (index: number) => void
}

const GalleryPanel = memo(
  forwardRef<HTMLDivElement, GalleryPanelProps>(function GalleryPanel(
    { item, index, isActive, priority = false, onActivate },
    ref,
  ) {
    const [loaded, setLoaded] = useState(false)

    return (
      <div
        ref={ref}
        aria-label={item.label}
        className={`gsv1-panel group relative min-w-12 shrink-0 overflow-hidden outline-none md:min-w-0${isActive ? " gsv1-panel--active" : ""}`}
        role="listitem"
        tabIndex={0}
        onFocus={() => onActivate(index)}
        onMouseEnter={() => onActivate(index)}
      >
        <Image
          src={item.imageSrc}
          alt={item.alt}
          fill
          unoptimized
          priority={priority}
          sizes="(max-width: 768px) 20vw, 15vw"
          className={`object-cover transition-[transform,scale,opacity] duration-680 ease-[var(--gsv1-ease)]${isActive ? " scale-105" : " scale-100"}${loaded ? " opacity-100" : " opacity-0"}`}
          onLoad={() => setLoaded(true)}
          onError={() => setLoaded(true)}
        />

        <div aria-hidden className="gsv1-caption">
          {item.alt}
        </div>
      </div>
    )
  }),
)

export default GalleryPanel
```

### `registry/new-york/blocks/gallery-section-v1/lib/config.ts`

```ts
export type GalleryItem = {
  id: string
  label: string
  alt: string
  imageSrc: string
}

export const sectionMeta = {
  title: "Moments In Focus",
}

export const imageFiles = [
  { file: "image-1.jpg",  alt: "Mountain landscape at golden hour" },
  { file: "image-2.jpg",  alt: "Coastal cliffs and ocean waves" },
  { file: "image-3.jpg",  alt: "Forest trail through tall trees" },
  { file: "image-4.jpg",  alt: "City skyline at dusk" },
  { file: "image-5.jpg",  alt: "Desert dunes under a clear sky" },
  { file: "image-6.jpg",  alt: "Snow-capped peaks above a valley" },
  { file: "image-7.jpg",  alt: "Lush green hills and farmland" },
  { file: "image-8.jpg",  alt: "Rocky shoreline at sunset" },
  { file: "image-9.jpg",  alt: "Misty lake surrounded by mountains" },
  { file: "image-10.jpg", alt: "Cherry blossoms in spring light" },
]
```

### `registry/new-york/blocks/gallery-section-v1/styles/gallery-track.css`

```css
.gsv1-root {
  --gsv1-ease: cubic-bezier(0.16, 1, 0.3, 1);
  --gsv1-duration: 680ms;
}

.gsv1-canvas {
  --gsv1-stub: clamp(4rem, 12vw, 9rem);
  position: relative;
  padding-top: var(--gsv1-stub);
  padding-bottom: var(--gsv1-stub);
}

.gsv1-track {
  scrollbar-width: none;
  isolation: isolate;
}

.gsv1-track::-webkit-scrollbar {
  display: none;
}

/* ── Panel ── */

.gsv1-panel {
  flex: 1 1 0;
  min-width: 0;
  min-height: 100%;
  background-color: var(--muted);
  transition: flex var(--gsv1-duration) var(--gsv1-ease);
  contain: layout paint;
  margin-right: -1px;
  cursor: pointer;
}

.gsv1-panel:last-child {
  margin-right: 0;
}

.gsv1-panel--active {
  flex: 5 1 0;
  z-index: 2;
}

/* Dark scrim on inactive panels */
.gsv1-panel::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: rgb(0 0 0 / 0.32);
  opacity: 1;
  transition: opacity var(--gsv1-duration) var(--gsv1-ease);
}

.gsv1-panel--active::before {
  opacity: 0;
}

/* Inset ring on active panel */
.gsv1-panel::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 3;
  box-shadow: inset 0 0 0 1px transparent;
  transition: box-shadow calc(var(--gsv1-duration) * 0.45) var(--gsv1-ease);
}

.gsv1-panel--active::after {
  box-shadow: inset 0 0 0 1px var(--foreground);
  transition-delay: 80ms;
}

/* ── Caption ── */

.gsv1-caption {
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  z-index: 4;
  padding: 2.5rem 0.875rem 0.75rem;
  background: linear-gradient(to top, rgb(0 0 0 / 0.52) 0%, transparent 100%);
  color: #fff;
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: 0;
  transform: translateY(5px);
  transition:
    opacity calc(var(--gsv1-duration) * 0.5) var(--gsv1-ease),
    transform calc(var(--gsv1-duration) * 0.5) var(--gsv1-ease);
  pointer-events: none;
}

.gsv1-panel--active .gsv1-caption {
  opacity: 1;
  transform: translateY(0);
  transition-delay: calc(var(--gsv1-duration) * 0.3);
}

/* ── Vlines ── */

.gsv1-vline {
  position: absolute;
  left: 0;
  z-index: 50;
  width: 1px;
  margin-left: -0.5px;
  pointer-events: none;
  background: var(--foreground);
  opacity: 1;
  will-change: transform;
}

.gsv1-vline--top {
  mask-image: linear-gradient(to bottom, transparent 0%, black 40%);
}

.gsv1-vline--bottom {
  mask-image: linear-gradient(to top, transparent 0%, black 40%);
}

/* ── Reduced motion ── */

@media (prefers-reduced-motion: reduce) {
  .gsv1-panel,
  .gsv1-panel::before,
  .gsv1-panel::after,
  .gsv1-caption {
    transition: none;
  }
}
```

### `public/images/blocks/gallery-section-v1/image-1.jpg.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/gallery-section-v1/image-2.jpg.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/gallery-section-v1/image-3.jpg.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/gallery-section-v1/image-4.jpg.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/gallery-section-v1/image-5.jpg.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/gallery-section-v1/image-6.jpg.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/gallery-section-v1/image-7.jpg.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/gallery-section-v1/image-8.jpg.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/gallery-section-v1/image-9.jpg.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/gallery-section-v1/image-10.jpg.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```
