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

A shadcn/ui block from the [@geturui](https://geturui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/geturui/masonry-gallery).

- Page, with a live preview: https://shadcn.exchange/r/geturui/masonry-gallery
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/geturui/masonry-gallery-light-f850b5558ac3d0c6.png
- Registry item: https://geturui.com/r/masonry-gallery.json
- Categories: blocks, masonry, gallery, grid, portfolio, images, filter, layout
- Listed: 2026-10-23, source last changed 2026-10-23

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/geturui/masonry-gallery.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`

## Files

### `registry/geturui/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>
  );
}
```

### `LICENSE-geturui.md`

```md
MIT License

Copyright (c) 2026 geturui.com

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
