Skip to content
6

Image Gallery 1

Studio-style masonry gallery with Tabs-driven category filter, real photo tiles via next/image, varied aspect ratios, hover zoom + arrow chip and an Empty fallback for empty filters.

Preview

Installation

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

Code

components/blocks/image-gallery-01.tsx
'use client';

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

import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
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';

interface Tile {
  title: string;
  meta: string;
  tag: string;
  aspect: string;
  src: string;
}

const FILTERS = ['All', 'Web', 'Brand', 'Editorial', 'Motion'] 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: '/media/blocks/image-gallery-01/tile-a.jpg',
  b: '/media/blocks/image-gallery-01/tile-b.jpg',
  c: '/media/blocks/image-gallery-01/tile-c.jpg',
} as const;

const TILES: readonly Tile[] = [
  {
    title: 'Helix · marketing site',
    meta: '2026 · case study',
    tag: 'Web',
    aspect: 'aspect-[4/5]',
    src: IMG.a,
  },
  {
    title: 'Northwind · checkout',
    meta: '2026 · product',
    tag: 'Web',
    aspect: 'aspect-[16/10]',
    src: IMG.b,
  },
  {
    title: 'Vanta · annual report',
    meta: '2025 · editorial',
    tag: 'Editorial',
    aspect: 'aspect-square',
    src: IMG.c,
  },
  {
    title: 'Brella · identity',
    meta: '2025 · brand',
    tag: 'Brand',
    aspect: 'aspect-[3/4]',
    src: IMG.a,
  },
  {
    title: 'Quartz · motion reel',
    meta: '2026 · motion',
    tag: 'Motion',
    aspect: 'aspect-[16/10]',
    src: IMG.c,
  },
  {
    title: 'Plinth · field guide',
    meta: '2025 · editorial',
    tag: 'Editorial',
    aspect: 'aspect-[4/5]',
    src: IMG.b,
  },
  {
    title: 'Lattice · product UI',
    meta: '2026 · product',
    tag: 'Web',
    aspect: 'aspect-[4/3]',
    src: IMG.c,
  },
  {
    title: 'Mercado · packaging',
    meta: '2025 · brand',
    tag: 'Brand',
    aspect: 'aspect-square',
    src: IMG.a,
  },
] as const;

const ImageGallery01 = () => {
  const [filter, setFilter] = React.useState<Filter>('All');
  const visible = filter === 'All' ? TILES : TILES.filter((t) => t.tag === filter);

  return (
    <section className="bg-background py-20 sm:py-28" aria-labelledby="image-gallery-01-heading">
      <div className="container w-full">
        <div 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">
            <Badge
              variant="outline"
              className="w-fit rounded-full bg-card/70 px-4 py-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground"
            >
              Selected work 2025-2026
            </Badge>
            <h2
              id="image-gallery-01-heading"
              className="font-serif text-4xl font-medium leading-[1.04] tracking-tight sm:text-5xl"
            >
              A studio archive, gridded.
            </h2>
            <p className="text-base text-muted-foreground">
              Eight pieces from the last 14 months: websites, identity work, an annual report, and a motion reel. More
              in the full archive.
            </p>
          </div>

          <Tabs value={filter} onValueChange={(v) => setFilter(v as Filter)} aria-label="Filter gallery by category">
            <TabsList variant="line" className="flex-wrap">
              {FILTERS.map((f) => (
                <TabsTrigger key={f} value={f} className="font-mono uppercase">
                  {f}
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>
        </div>

        {visible.length === 0 ? (
          <Empty className="mt-10">
            <EmptyHeader>
              <EmptyTitle>Nothing in {filter} yet</EmptyTitle>
              <EmptyDescription>
                No pieces in this archive. Switch the filter, or check back next quarter.
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Button variant="outline" size="sm" onClick={() => setFilter('All')}>
                Show all
              </Button>
            </EmptyContent>
          </Empty>
        ) : (
          <div className="mt-10 columns-1 gap-4 sm:columns-2 lg:columns-3 [&>*]:mb-4">
            {visible.map((t) => (
              <a
                key={t.title}
                href="#"
                className="group block break-inside-avoid rounded-md border border-border bg-card transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                aria-label={t.title}
              >
                <div className={`${t.aspect} relative overflow-hidden rounded-t-md bg-muted`}>
                  <Image
                    src={t.src}
                    alt={t.title}
                    fill
                    sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
                    className="object-cover transition-transform duration-500 ease-out group-hover:scale-105"
                  />
                  <div
                    aria-hidden
                    className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/40 via-black/0 to-black/10"
                  />
                  <Badge
                    variant="outline"
                    className="absolute start-3 top-3 border-white/30 bg-black/20 text-white backdrop-blur-sm"
                  >
                    {t.tag}
                  </Badge>
                  <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-all duration-200 ease-out group-hover:-translate-y-0.5 group-hover:translate-x-0.5 rtl:group-hover:-translate-x-0.5 group-hover:opacity-100"
                  >
                    <ArrowUpRight className="size-3.5" />
                  </span>
                </div>
                <div className="flex items-center justify-between gap-3 px-4 py-3">
                  <span className="truncate text-sm font-medium tracking-[-0.01em] text-foreground">{t.title}</span>
                  <span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
                    {t.meta}
                  </span>
                </div>
              </a>
            ))}
          </div>
        )}
      </div>
    </section>
  );
};

export default ImageGallery01;

Dependencies

shadcn registry

badgebuttonemptytabs

npm

lucide-react