# Image Gallery 01

> Studio-style masonry gallery with a Tabs category filter showing counts, photo tiles in varied aspect ratios that open in a lightbox with thumbnails, and an empty state for categories with no work yet.

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

- Page, with a live preview: https://shadcn.exchange/r/hirael/image-gallery-01
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/image-gallery-01-light-da0610fc0e877067.png
- Registry item: https://hirael.com/r/image-gallery-01.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-01.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: `button`, `empty`, `https://hirael.com/r/lightbox.json`, `tabs`

## Files

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

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

'use client';

import * as React from 'react';
import Image from 'next/image';
import { Maximize2 } from 'lucide-react';

import { cn } from '@/lib/utils';
import {
  Lightbox,
  LightboxContent,
  LightboxThumbnails,
  LightboxTrigger,
  type LightboxItem,
} from '@/registry/hirael/bases/radix/components/lightbox';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@/registry/hirael/bases/radix/ui/empty';
import { Tabs, TabsList, TabsTrigger } from '@/registry/hirael/bases/radix/ui/tabs';

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 SWAP =
  'animate-in fade-in slide-in-from-bottom-2 duration-250 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`,
});

interface Tile {
  client: string;
  project: string;
  year: string;
  type: string;
  tag: Exclude<Filter, 'All'>;
  aspect: string;
  src: string;
}

const FILTERS = ['All', 'Web', 'Brand', 'Editorial', 'Motion', 'Print'] as const;
type Filter = (typeof FILTERS)[number];

// 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 = {
  a: 'https://hirael.com/media/blocks/image-gallery-01/tile-a.jpg',
  b: 'https://hirael.com/media/blocks/image-gallery-01/tile-b.jpg',
  c: 'https://hirael.com/media/blocks/image-gallery-01/tile-c.jpg',
} as const;

const TILES: readonly Tile[] = [
  {
    client: 'Helix',
    project: 'Marketing site',
    year: '2026',
    type: 'Case study',
    tag: 'Web',
    aspect: 'aspect-[4/5]',
    src: IMG.a,
  },
  {
    client: 'Hirael',
    project: 'Checkout',
    year: '2026',
    type: 'Product',
    tag: 'Web',
    aspect: 'aspect-[16/10]',
    src: IMG.b,
  },
  {
    client: 'Vanta',
    project: 'Annual report',
    year: '2025',
    type: 'Editorial',
    tag: 'Editorial',
    aspect: 'aspect-square',
    src: IMG.c,
  },
  {
    client: 'Brella',
    project: 'Identity',
    year: '2025',
    type: 'Brand',
    tag: 'Brand',
    aspect: 'aspect-[3/4]',
    src: IMG.a,
  },
  {
    client: 'Quartz',
    project: 'Motion reel',
    year: '2026',
    type: 'Motion',
    tag: 'Motion',
    aspect: 'aspect-[16/10]',
    src: IMG.c,
  },
  {
    client: 'Hirael Docs',
    project: 'Field guide',
    year: '2025',
    type: 'Editorial',
    tag: 'Editorial',
    aspect: 'aspect-[4/5]',
    src: IMG.b,
  },
  {
    client: 'Lattice',
    project: 'Product UI',
    year: '2026',
    type: 'Product',
    tag: 'Web',
    aspect: 'aspect-[4/3]',
    src: IMG.c,
  },
  {
    client: 'Mercado',
    project: 'Packaging',
    year: '2025',
    type: 'Brand',
    tag: 'Brand',
    aspect: 'aspect-square',
    src: IMG.a,
  },
];

const countFor = (filter: Filter) => (filter === 'All' ? TILES.length : TILES.filter((t) => t.tag === filter).length);

const ImageGallery01 = () => {
  const [filter, setFilter] = React.useState<Filter>('All');
  const [hasFiltered, setHasFiltered] = React.useState(false);
  const [index, setIndex] = React.useState(0);
  const tileRefs = React.useRef<(HTMLButtonElement | null)[]>([]);

  const visible = filter === 'All' ? TILES : TILES.filter((t) => t.tag === filter);
  const items: LightboxItem[] = visible.map((t) => ({
    src: t.src,
    alt: `${t.client}, ${t.project}`,
    caption: `${t.client}, ${t.project}, ${t.year}`,
  }));

  const changeFilter = (next: string) => {
    setFilter(next as Filter);
    setHasFiltered(true);
    setIndex(0);
  };

  return (
    <section
      data-slot="image-gallery"
      className="bg-background py-20 sm:py-28"
      aria-labelledby="image-gallery-01-heading"
    >
      <div className="mx-auto w-full max-w-[1480px] px-4">
        <div
          data-slot="image-gallery-header"
          className="flex flex-col gap-5 border-b border-border pb-8 sm:flex-row sm:items-end sm:justify-between sm:pb-10"
        >
          <div className="flex max-w-xl flex-col gap-4">
            <span className={cn(ENTER, 'text-xs text-muted-foreground uppercase')}>Selected work 2025-2026</span>
            <h2
              id="image-gallery-01-heading"
              style={stagger(1, 70)}
              className={cn(ENTER, 'font-serif text-4xl leading-[1.04] font-medium tracking-tight sm:text-5xl')}
            >
              A studio archive, gridded.
            </h2>
            <p style={stagger(2, 70)} className={cn(ENTER, 'text-base text-muted-foreground')}>
              Eight pieces from the last 14 months: websites, identity work, an annual report, and a motion reel. Open
              any of them to see the full frame.
            </p>
          </div>

          <Tabs
            value={filter}
            onValueChange={changeFilter}
            aria-label="Filter gallery by category"
            style={stagger(3, 70)}
            className={ENTER}
          >
            <TabsList variant="line" className="flex-wrap group-data-[orientation=horizontal]/tabs:h-auto">
              {FILTERS.map((f) => (
                <TabsTrigger key={f} value={f}>
                  <span className="uppercase">{f}</span>
                  <span className="text-[10px] text-muted-foreground tabular-nums">{countFor(f)}</span>
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>
        </div>

        {visible.length === 0 ? (
          <Empty key={filter} data-slot="image-gallery-empty" className={cn(SWAP, 'mt-10')}>
            <EmptyHeader>
              <EmptyTitle>No {filter.toLowerCase()} work yet</EmptyTitle>
              <EmptyDescription>
                Our first print catalogue goes to press in the spring. Everything else is in the archive.
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Button variant="outline" size="sm" onClick={() => changeFilter('All')}>
                Show all work
              </Button>
            </EmptyContent>
          </Empty>
        ) : (
          <Lightbox items={items} index={index} onIndexChange={setIndex}>
            <ul
              key={filter}
              data-slot="image-gallery-grid"
              className="mt-10 columns-1 gap-4 sm:columns-2 lg:columns-3 [&>*]:mb-4"
            >
              {visible.map((t, tileIndex) => (
                <li
                  key={`${t.client}-${t.project}`}
                  style={hasFiltered ? stagger(tileIndex, 40) : stagger(tileIndex, 50, 300)}
                  className={cn(hasFiltered ? SWAP : ENTER, 'break-inside-avoid')}
                >
                  <LightboxTrigger
                    index={tileIndex}
                    data-slot="image-gallery-tile"
                    aria-label={`${t.client}, ${t.project}. Open image ${tileIndex + 1} of ${visible.length}`}
                    asChild
                  >
                    <button
                      type="button"
                      ref={(node: HTMLButtonElement | null) => {
                        tileRefs.current[tileIndex] = node;
                      }}
                      className="group block w-full cursor-zoom-in rounded-md border border-border bg-card text-start transition-colors duration-150 hover:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
                    >
                      <span className={cn('relative block overflow-hidden rounded-t-md bg-muted', t.aspect)}>
                        <Image
                          src={t.src}
                          alt=""
                          fill
                          sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
                          className="object-cover transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-105 motion-reduce:transition-none"
                        />
                        <span
                          aria-hidden
                          className="pointer-events-none absolute inset-0 bg-linear-to-b from-black/40 via-black/0 to-black/10"
                        />
                        <span className="absolute start-3 top-3 inline-flex items-center rounded-full border border-white/30 bg-black/20 px-2 py-0.5 text-xs font-medium text-white backdrop-blur-sm">
                          {t.tag}
                        </span>
                        <span
                          aria-hidden
                          className="absolute end-3 top-3 inline-flex size-7 items-center justify-center rounded-sm border border-white/30 bg-black/20 text-white opacity-0 backdrop-blur-sm transition-[opacity,transform] duration-150 ease-out group-hover:scale-105 group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none"
                        >
                          <Maximize2 className="size-3.5" />
                        </span>
                      </span>
                      <span className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 px-4 py-3">
                        <span className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-sm tracking-[-0.01em]">
                          <span className="font-medium text-foreground">{t.client}</span>
                          <span className="text-muted-foreground">{t.project}</span>
                        </span>
                        <span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground uppercase">
                          <span className="tabular-nums">{t.year}</span>
                          <span aria-hidden className="text-border">
                            |
                          </span>
                          <span>{t.type}</span>
                        </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 ImageGallery01;
```
