# Image Gallery 02

> Photo grid with a Grid and Rows layout switch, captions on hover or focus, and a lightbox with thumbnails that opens at the chosen photo and returns focus to it on close.

A shadcn/ui block from the [@hirael](https://hirael.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/hirael/image-gallery-02).

- Page, with a live preview: https://shadcn.exchange/r/hirael/image-gallery-02
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/image-gallery-02-light-d85fedb83fb2ced2.png
- Registry item: https://hirael.com/r/image-gallery-02.json
- Categories: blocks, image-gallery
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://hirael.com/r/image-gallery-02.json
```

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

## Dependencies

- npm packages: `lucide-react`
- Registry items: `https://hirael.com/r/lightbox.json`, `toggle-group`

## Files

### `registry/hirael/bases/radix/blocks/image-gallery-02/image-gallery-02.tsx`

```tsx
// Image Gallery 02 from Hirael <https://hirael.com/blocks/image-gallery/image-gallery-02>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { flushSync } from 'react-dom';
import Image from 'next/image';
import { LayoutGrid, Rows3 } from 'lucide-react';

import { cn } from '@/lib/utils';
import {
  Lightbox,
  LightboxContent,
  LightboxThumbnails,
  LightboxTrigger,
  type LightboxItem,
} from '@/registry/hirael/bases/radix/components/lightbox';
import { ToggleGroup, ToggleGroupItem } from '@/registry/hirael/bases/radix/ui/toggle-group';

const ENTER =
  'animate-in fade-in slide-in-from-bottom-4 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';

const stagger = (index: number, step = 60, offset = 0): React.CSSProperties => ({
  animationDelay: `${offset + index * step}ms`,
});

type View = 'grid' | 'rows';

interface Photo {
  location: string;
  date: string;
  alt: string;
  src: string;
  thumbnail: string;
  position: string;
}

// Placeholder photos served from hirael.com. Swap them for your own assets, or
// add the host to `images.remotePatterns` in next.config to keep them.
const IMG = {
  lake: { src: 'https://hirael.com/media/blocks/image-gallery-02/lake.jpg', thumbnail: 'https://hirael.com/media/blocks/image-gallery-02/lake-thumb.jpg' },
  forest: {
    src: 'https://hirael.com/media/blocks/image-gallery-02/forest.jpg',
    thumbnail: 'https://hirael.com/media/blocks/image-gallery-02/forest-thumb.jpg',
  },
  valley: {
    src: 'https://hirael.com/media/blocks/image-gallery-02/valley.jpg',
    thumbnail: 'https://hirael.com/media/blocks/image-gallery-02/valley-thumb.jpg',
  },
  ridge: {
    src: 'https://hirael.com/media/blocks/image-gallery-02/ridge.jpg',
    thumbnail: 'https://hirael.com/media/blocks/image-gallery-02/ridge-thumb.jpg',
  },
} as const;

const PHOTOS: readonly Photo[] = [
  {
    location: 'Lago di Braies, Italy',
    date: 'June 2026',
    alt: 'A rowing boat on a green alpine lake below pale limestone peaks',
    position: '50% 50%',
    ...IMG.lake,
  },
  {
    location: 'Feldberg trail, Germany',
    date: 'October 2025',
    alt: 'A dirt path through tall pines with low sun between the trunks',
    position: '50% 50%',
    ...IMG.forest,
  },
  {
    location: 'Yosemite Valley, California',
    date: 'May 2026',
    alt: 'Granite cliffs in wildfire haze above a shallow river at dusk',
    position: '50% 50%',
    ...IMG.valley,
  },
  {
    location: 'Ceahlau Massif, Romania',
    date: 'August 2025',
    alt: 'A hiker standing on a rock outcrop above misty green ridges',
    position: '50% 50%',
    ...IMG.ridge,
  },
  {
    location: 'Braies boathouse, Italy',
    date: 'July 2026',
    alt: 'Close view of a wooden rowing boat on clear turquoise water',
    position: '30% 85%',
    ...IMG.lake,
  },
  {
    location: 'Merced River, California',
    date: 'May 2026',
    alt: 'Smooth river stones in shallow water with pines behind',
    position: '75% 80%',
    ...IMG.valley,
  },
  {
    location: 'Toaca Peak, Romania',
    date: 'August 2025',
    alt: 'Weathered rock pillars on a grassy slope in late light',
    position: '55% 75%',
    ...IMG.ridge,
  },
  {
    location: 'Titisee woods, Germany',
    date: 'November 2025',
    alt: 'Mossy tree trunks at the edge of a forest clearing',
    position: '15% 60%',
    ...IMG.forest,
  },
];

const ITEMS: LightboxItem[] = PHOTOS.map((photo) => ({
  src: photo.src,
  thumbnail: photo.thumbnail,
  alt: photo.alt,
  caption: `${photo.location}, ${photo.date}`,
}));

const ImageGallery02 = () => {
  const [view, setView] = React.useState<View>('grid');
  const [index, setIndex] = React.useState(0);
  const tileRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const transitionId = React.useId().replace(/[^a-zA-Z0-9-]/g, '');

  const changeView = (next: string) => {
    if ((next !== 'grid' && next !== 'rows') || next === view) return;
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (reduce || typeof document.startViewTransition !== 'function') {
      setView(next);

      return;
    }
    document.startViewTransition(() => {
      flushSync(() => setView(next));
    });
  };

  return (
    <section data-slot="image-gallery" className="bg-background py-20 sm:py-28">
      <div className="mx-auto flex w-full max-w-6xl flex-col gap-10 px-6 md:gap-12 md:px-10">
        <div
          data-slot="image-gallery-header"
          className="flex flex-col gap-6 border-b border-border pb-8 md:flex-row md:items-end md:justify-between"
        >
          <div className="flex max-w-xl flex-col gap-4">
            <span className={cn(ENTER, 'text-xs text-muted-foreground uppercase')}>Field notes</span>
            <h2
              style={stagger(1, 80)}
              className={cn(ENTER, 'text-3xl font-semibold tracking-tight text-balance sm:text-4xl')}
            >
              Four walks in one year
            </h2>
            <p style={stagger(2, 80)} className={cn(ENTER, 'text-base leading-relaxed text-muted-foreground')}>
              Photos from the Dolomites, the Black Forest, Yosemite and the Carpathians. Open any of them to see the
              full frame.
            </p>
          </div>

          <div style={stagger(3, 80)} className={cn(ENTER, 'flex items-center justify-between gap-4 md:justify-end')}>
            <span data-slot="image-gallery-count" className="text-sm text-muted-foreground tabular-nums">
              {PHOTOS.length} photos
            </span>
            <ToggleGroup
              type="single"
              variant="outline"
              size="sm"
              value={view}
              onValueChange={changeView}
              aria-label="Gallery layout"
              data-slot="image-gallery-view"
            >
              <ToggleGroupItem value="grid" aria-label="Grid">
                <LayoutGrid aria-hidden />
                <span className="hidden sm:inline">Grid</span>
              </ToggleGroupItem>
              <ToggleGroupItem value="rows" aria-label="Rows">
                <Rows3 aria-hidden />
                <span className="hidden sm:inline">Rows</span>
              </ToggleGroupItem>
            </ToggleGroup>
          </div>
        </div>

        <Lightbox items={ITEMS} index={index} onIndexChange={setIndex}>
          <ul
            data-slot="image-gallery-grid"
            data-view={view}
            className={cn(
              'grid gap-3 sm:gap-4',
              view === 'grid' ? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4' : 'grid-cols-1 sm:grid-cols-2',
            )}
          >
            {PHOTOS.map((photo, photoIndex) => (
              <li key={`${photo.location}-${photo.date}`} style={stagger(photoIndex, 50, 280)} className={ENTER}>
                <LightboxTrigger
                  index={photoIndex}
                  ref={(node: HTMLButtonElement | null) => {
                    tileRefs.current[photoIndex] = node;
                  }}
                  data-slot="image-gallery-tile"
                  aria-label={`${photo.location}, ${photo.date}. Open photo ${photoIndex + 1} of ${PHOTOS.length}`}
                  style={{ viewTransitionName: `image-gallery-${transitionId}-${photoIndex}` }}
                  asChild
                >
                  <button
                    type="button"
                    className="group relative block w-full cursor-zoom-in overflow-hidden rounded-lg bg-muted outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                  >
                    <span className={cn('relative block w-full', view === 'grid' ? 'aspect-square' : 'aspect-[3/2]')}>
                      <Image
                        src={photo.src}
                        alt=""
                        fill
                        sizes={
                          view === 'grid'
                            ? '(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw'
                            : '(min-width: 640px) 50vw, 100vw'
                        }
                        style={{ objectPosition: photo.position }}
                        className="object-cover transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.03] motion-reduce:transition-none"
                      />
                    </span>
                    <span
                      aria-hidden
                      data-slot="image-gallery-caption"
                      className="pointer-events-none absolute inset-x-0 bottom-0 flex translate-y-1 flex-col items-start gap-0.5 bg-linear-to-t from-black/70 via-black/35 to-transparent px-3 pt-10 pb-3 text-start text-white opacity-0 transition-[opacity,transform] duration-150 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100 motion-reduce:transition-none sm:px-4 sm:pb-4"
                    >
                      <span className="text-sm font-medium text-pretty">{photo.location}</span>
                      <span className="text-xs text-white/75 tabular-nums">{photo.date}</span>
                    </span>
                  </button>
                </LightboxTrigger>
              </li>
            ))}
          </ul>

          <LightboxContent
            onCloseAutoFocus={(event) => {
              const tile = tileRefs.current[index];
              if (!tile) return;
              event.preventDefault();
              tile.focus();
            }}
          >
            <LightboxThumbnails />
          </LightboxContent>
        </Lightbox>
      </div>
    </section>
  );
};

export default ImageGallery02;
```
