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.jsonCode
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