# Masonry Gallery

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

- Collection: Blocks
- Tier: free
- Docs: https://geturui.com/docs/masonry-gallery
- Install: `npx shadcn@latest add @geturui/masonry-gallery`
- Dependencies: motion, lucide-react

## When to use it

- Portfolios, lookbooks and case-study walls where images have different shapes and shouldn't be cropped to one ratio.
- Mood boards and inspiration pages with a handful of categories to filter.

### What it needs from your app

- Each item's aspect ratio (width ÷ height) up front, so tiles reserve their space before the image loads.

### Limits

- Grid layout measures every tile in the browser; with JavaScript off it falls back to estimated heights.
- Built for tens of items, not thousands: there is no virtualisation or infinite scroll.

### Pairs with

- [Feature Grid](https://geturui.com/docs/feature-grid): A bento grid for a few curated features; the masonry wall for many items of unknown shape.

## Usage

```tsx
import { MasonryGallery } from "@/components/blocks/masonry-gallery";

export default function Work() {
  return (
    <MasonryGallery
      heading="Recent work"
      items={[
        { title: "Harbour at dawn", caption: "Lisbon", category: "Travel", ratio: 4 / 5, src: "/work/harbour.jpg", alt: "Fishing boats moored at sunrise" },
        { title: "Studio portrait", caption: "Editorial", category: "People", ratio: 3 / 4, src: "/work/portrait.jpg", alt: "A woman in a red coat against a grey wall" },
        { title: "Market stalls", caption: "Marrakesh", category: "Travel", ratio: 16 / 10, src: "/work/market.jpg", alt: "Spice stalls under striped awnings", href: "/work/market" },
      ]}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| items | `MasonryItem[]` | — | { title, ratio, caption?, category?, src?, alt?, art?, href? }. `ratio` is width ÷ height. |
| layout | `"grid" \| "columns"` | "grid" | grid: measured row spans, reads left to right in source order. columns: CSS multi-column, no JavaScript layout, reads down each column. |
| filter | `boolean` | true | Category chips above the wall, shown when items have two or more categories. |
| eyebrow / heading / subheading | `string` | — | Section copy. |
| className | `string` | — | Classes for the outer section. |

## Accessibility

- In grid layout, keyboard and screen-reader order matches the visual order: left to right, then down.
- Filter chips are buttons with aria-pressed, and a polite live region announces how many items are shown.
- Images need alt text; the drawn placeholders are aria-hidden. Linked tiles have a visible focus ring.
- Tiles fade in without movement under reduced motion, and filtering doesn't animate positions.

## Source

### components/blocks/masonry-gallery.tsx

```tsx
"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>
  );
}

```
