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/ui/badge";
import { Button } from "@/registry/hirael/ui/button";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/registry/hirael/ui/empty";
import { Tabs, TabsList, TabsTrigger } from "@/registry/hirael/ui/tabs";

type 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];

// Free-to-use photos from Unsplash. Swap these for your own assets — and
// remember to add the image host to `images.remotePatterns` in next.config.
const IMG = {
  a: "https://images.unsplash.com/photo-1502447533750-9860c1269ae3?q=80&w=687&auto=format&fit=crop",
  b: "https://images.unsplash.com/photo-1571832194445-4480b617ee01?q=80&w=880&auto=format&fit=crop",
  c: "https://images.unsplash.com/photo-1678769435037-2de940e13c89?q=80&w=750&auto=format&fit=crop",
} 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;

export default function 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">
              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>
  );
}

Dependencies

shadcn registry

badgebuttonemptytabs

npm

lucide-react