Masonry wall of tiles with mixed aspect ratios that keeps source order left to right, with category chips that filter the wall and tiles that glide into their new places.
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface MasonryItem {
title: string;
/** Small line under the title, e.g. a credit or a date. */
caption?: string;
/** Used by the filter chips. Items without one only show under "All". */
category?: string;
/** Width ÷ height of the tile, e.g. 4 / 5 for a portrait photo. */
ratio: number;
/** Image URL. Without it the tile shows `art` or a drawn placeholder. */
src?: string;
/** Required with `src`; leave empty only for purely decorative images. */
alt?: string;
/** Your own illustration; used when there is no `src`. */
art?: React.ReactNode;
/** Makes the whole tile a link. */
href?: string;
}
export interface MasonryGalleryProps {
eyebrow?: string;
heading?: string;
subheading?: string;
items?: MasonryItem[];
/**
* "grid": CSS grid with measured row spans, so tiles read left to right in source order (needs JavaScript to size).
* "columns": CSS multi-column, no JavaScript layout, but tiles read down the first column before the second.
*/
layout?: "grid" | "columns";
/** Show category chips above the gallery when items have categories. */
filter?: boolean;
className?: string;
}
const DEFAULT_ITEMS: MasonryItem[] = [
{ title: "Launch keynote slides", caption: "Product · 12 frames", category: "Product", ratio: 4 / 5 },
{ title: "Dashboard empty states", caption: "App UI · 6 frames", category: "App UI", ratio: 4 / 3 },
{ title: "Pricing page study", caption: "Marketing · 9 frames", category: "Marketing", ratio: 3 / 4 },
{ title: "Onboarding checklist", caption: "App UI · 4 frames", category: "App UI", ratio: 1 },
{ title: "Brand colour tokens", caption: "Brand · 3 frames", category: "Brand", ratio: 16 / 10 },
{ title: "Mobile settings", caption: "App UI · 8 frames", category: "App UI", ratio: 9 / 16 },
{ title: "Changelog cards", caption: "Marketing · 5 frames", category: "Marketing", ratio: 5 / 4 },
{ title: "Icon set, 24px", caption: "Brand · 48 icons", category: "Brand", ratio: 1 },
{ title: "Checkout flow", caption: "Product · 7 frames", category: "Product", ratio: 3 / 4 },
{ title: "Status page", caption: "Product · 2 frames", category: "Product", ratio: 4 / 3 },
];
/** Grid mode: rows are ROW px tall with no row gap; each tile spans enough rows for its measured height plus GAP. */
const ROW = 4;
const GAP = 16;
const TONES = ["bg-volt-soft text-volt-ink", "bg-ember-soft text-ember-ink", "bg-surface-raised text-ink-subtle", "bg-surface-sunken text-ink-muted"];
/** Drawn placeholder art from theme tokens, so the gallery works with no image assets. */
function Placeholder({ index }: { index: number }) {
const kind = index % 4;
return (
<div aria-hidden className={cn("grid size-full place-items-center", TONES[index % TONES.length])}>
<svg viewBox="0 0 120 120" className="size-1/2 max-h-32 max-w-32 opacity-80" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round">
{kind === 0 && (
<>
<circle cx="60" cy="60" r="34" />
<circle cx="60" cy="60" r="16" fill="currentColor" stroke="none" opacity="0.35" />
</>
)}
{kind === 1 && [28, 46, 64, 82].map((x, i) => <line key={x} x1={x} x2={x} y1={92} y2={92 - (i % 2 ? 44 : 26) - i * 6} />)}
{kind === 2 && <path d="M14 78c16-30 30-30 46 0s30 30 46 0" />}
{kind === 3 && (
<>
<rect x="22" y="22" width="34" height="34" rx="8" />
<rect x="64" y="22" width="34" height="34" rx="8" fill="currentColor" stroke="none" opacity="0.35" />
<rect x="22" y="64" width="76" height="34" rx="8" />
</>
)}
</svg>
</div>
);
}
function Tile({ item, index, reduce }: { item: MasonryItem; index: number; reduce: boolean }) {
const media = item.src ? (
// Plain <img> so the block has no framework dependency; swap in next/image in a Next.js app.
// eslint-disable-next-line @next/next/no-img-element
<img src={item.src} alt={item.alt ?? ""} loading="lazy" className="size-full object-cover transition-transform duration-500 ease-spring group-hover:scale-[1.03]" />
) : (
item.art ?? <Placeholder index={index} />
);
const body = (
<>
<div className="overflow-hidden" style={{ aspectRatio: String(item.ratio) }}>
{media}
</div>
<figcaption className="grid gap-0.5 border-t border-border bg-surface px-4 py-3">
<span className="text-sm font-semibold text-ink">{item.title}</span>
{item.caption && <span className="text-[12.5px] text-ink-subtle">{item.caption}</span>}
</figcaption>
</>
);
return (
<motion.figure
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16, filter: "blur(6px)" }}
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
viewport={{ once: true, margin: "-40px" }}
transition={reduce ? { duration: 0.2 } : { type: "spring", stiffness: 260, damping: 30, delay: (index % 3) * 0.06 }}
className="group overflow-hidden rounded-lg border border-border bg-surface transition-[border-color,translate] duration-200 has-[a:hover]:-translate-y-[3px] has-[a:hover]:border-border-strong"
>
{item.href ? (
<a href={item.href} className="block rounded-lg outline-none focus-visible:shadow-[var(--focus-ring)]">
{body}
</a>
) : (
body
)}
</motion.figure>
);
}
/** Rows the cell's content needs: measured once mounted (and on resize), estimated from the ratio before that. */
function useRowSpan(ref: React.RefObject<HTMLElement | null>, ratio: number) {
const [span, setSpan] = React.useState(() => Math.ceil((300 / ratio + 64 + GAP) / ROW));
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => setSpan(Math.ceil((e.contentRect.height + GAP) / ROW)));
ro.observe(el);
return () => ro.disconnect();
}, [ref]);
return span;
}
/** `ref` comes from AnimatePresence's popLayout mode, which lifts an exiting tile out of the grid so the rest move at once. */
function GridCell({ item, index, reduce, ref }: { item: MasonryItem; index: number; reduce: boolean; ref?: React.Ref<HTMLLIElement> }) {
const inner = React.useRef<HTMLDivElement>(null);
const span = useRowSpan(inner, item.ratio);
return (
<motion.li
ref={ref}
layout={reduce ? false : "position"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
// Positions glide on a spring; fades stay short so filtering feels instant.
transition={{ type: "spring", stiffness: 300, damping: 32, opacity: { duration: 0.18, ease: "easeOut" } }}
style={{ gridRowEnd: `span ${span}` }}
>
<div ref={inner}>
<Tile item={item} index={index} reduce={reduce} />
</div>
</motion.li>
);
}
/**
* Masonry gallery of tiles with mixed aspect ratios. Grid mode keeps source order left to right; columns mode is pure CSS.
* Optional category chips filter the tiles, which glide into their new places.
*/
export function MasonryGallery({
eyebrow = "Gallery",
heading = "Recent work, every shape and size",
subheading = "Portrait, landscape and square shots on one wall, with no cropping to a single ratio.",
items = DEFAULT_ITEMS,
layout = "grid",
filter = true,
className,
}: MasonryGalleryProps) {
const reduce = useReducedMotion() ?? false;
const categories = React.useMemo(() => [...new Set(items.map((i) => i.category).filter((c): c is string => !!c))], [items]);
const [active, setActive] = React.useState<string | null>(null);
const shown = items.map((item, index) => ({ item, index })).filter(({ item }) => !active || item.category === active);
return (
<section className={cn("@container w-full px-4 py-16 sm:px-6 sm:py-24", className)}>
<div className="mx-auto grid max-w-6xl gap-10">
<header className="grid max-w-2xl gap-3">
{eyebrow && <span className="text-[13px] font-semibold uppercase tracking-[1px] text-volt-ink">{eyebrow}</span>}
<h2 className="text-balance font-display text-3xl font-semibold tracking-[-0.8px] text-ink @xl:text-[40px] @xl:leading-[1.1]">{heading}</h2>
{subheading && <p className="text-base leading-7 text-ink-muted">{subheading}</p>}
</header>
{filter && categories.length > 1 && (
<div role="group" aria-label="Filter gallery" className="flex flex-wrap gap-2">
{[null, ...categories].map((c) => {
const on = active === c;
return (
<button
key={c ?? "all"}
type="button"
aria-pressed={on}
onClick={() => setActive(c)}
className={cn(
"relative rounded-full border px-3.5 py-1.5 text-sm outline-none transition-colors focus-visible:shadow-[var(--focus-ring)]",
on ? "border-transparent text-ink" : "border-border text-ink-muted hover:border-border-strong hover:text-ink",
)}
>
{on && (
<motion.span
layoutId="masonry-gallery-chip"
aria-hidden
className="absolute inset-0 rounded-full bg-volt-soft ring-1 ring-inset ring-volt-ink"
transition={{ type: "spring", stiffness: 420, damping: 32 }}
/>
)}
<span className="relative">{c ?? "All"}</span>
</button>
);
})}
</div>
)}
<p className="sr-only" aria-live="polite">
{active ? `Showing ${shown.length} ${active} items` : `Showing all ${shown.length} items`}
</p>
{layout === "grid" ? (
<ul className="grid list-none grid-cols-1 gap-x-4 p-0 @xl:grid-cols-2 @4xl:grid-cols-3" style={{ gridAutoRows: `${ROW}px` }}>
<AnimatePresence initial={false} mode="popLayout">
{shown.map(({ item, index }) => (
<GridCell key={item.title + index} item={item} index={index} reduce={reduce} />
))}
</AnimatePresence>
</ul>
) : (
<ul className="list-none columns-1 gap-4 p-0 @xl:columns-2 @4xl:columns-3">
{shown.map(({ item, index }) => (
<li key={item.title + index} className="mb-4 break-inside-avoid">
<Tile item={item} index={index} reduce={reduce} />
</li>
))}
</ul>
)}
</div>
</section>
);
}