Cards & Grids

Masonry Grid

A responsive masonry layout with animated reflow, cards gliding into place as the column count and the data change.

Items are absolutely positioned into whichever column is shortest, producing a staggered masonry flow. The column count changes with the container width, and every card glides into its new place when data is added, removed, or reordered.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add masonry-grid.en

New project or installation trouble? Start here →

Masonry Grid is a React masonry layout component (a Pinterest-style grid) built with Tailwind CSS and Motion. Each card goes into the currently shortest column, the column count follows the container width, and cards animate to their new positions when items are added, removed, reordered or the column count changes.

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

3
16
0.05
Active edited usageDefault props
<MasonryGrid/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

When to use

  • Photography portfolios and inspiration boards where images have different heights
  • Article card walls for blogs or knowledge bases with excerpts of varying length
  • Walls of customer reviews or social posts
  • Work listings filtered by tag, where cards slide into place as the filter changes
  • Not a good fit: fixed feature overviews with cards spanning columns, use Bento Grid

Installation

npx webberui@latest add https://webberui.com/r/masonry-grid.en.json

Or, once registries are configured in components.json, install it as @webberui/masonry-grid.

Usage

import { MasonryGrid, type MasonryItem } from "@/components/ui/masonry-grid";

const items: MasonryItem[] = [
  { id: "a", height: 200, className: "bg-sky-500" },
  { id: "b", height: 140, className: "bg-rose-500" },
  { id: "c", height: 260, className: "bg-emerald-500" },
];

<MasonryGrid items={items} columns={{ 0: 1, 640: 2, 1024: 3 }} gap={16} />;

columns can be a fixed number (for example 3) or a "container-width breakpoint map": the key is the minimum container width (px) and the value is the column count at or above that width. Breakpoints are decided by the container width rather than the viewport width, so the layout is still correct inside a sidebar or a popover.

Each item's height sets the card height while the width is derived from the column width; put your content in content, or apply a gradient background directly through className.

Examples

Image portfolio

Card height comes from height; content does not stretch the card. Fill images with object-cover, and precompute heights from the original aspect ratio in your data.

import { MasonryGrid, type MasonryItem } from "@/components/ui/masonry-grid";

const photos = [
  { id: "p1", src: "/works/cafe.jpg", alt: "Café interior design", height: 320 },
  { id: "p2", src: "/works/poster.jpg", alt: "Exhibition poster", height: 420 },
  { id: "p3", src: "/works/app.jpg", alt: "Mobile app UI", height: 240 },
  { id: "p4", src: "/works/brand.jpg", alt: "Brand identity", height: 360 },
];

const items: MasonryItem[] = photos.map((p) => ({
  id: p.id,
  height: p.height,
  content: <img src={p.src} alt={p.alt} className="h-full w-full object-cover" />,
}));

export function Portfolio() {
  return <MasonryGrid items={items} gap={12} />;
}

Filter by tag

When items changes, the remaining cards move from their old positions to new ones. Cards are tracked by id, so it must be stable and unique.

"use client";

import * as React from "react";
import { MasonryGrid, type MasonryItem } from "@/components/ui/masonry-grid";

type Post = MasonryItem & { tag: "Design" | "Engineering" };

export function FilterableWall({ posts }: { posts: Post[] }) {
  const [tag, setTag] = React.useState<"All" | Post["tag"]>("All");
  const visible = tag === "All" ? posts : posts.filter((p) => p.tag === tag);

  return (
    <>
      <div className="mb-4 flex gap-2">
        {(["All", "Design", "Engineering"] as const).map((t) => (
          <button key={t} onClick={() => setTag(t)} aria-pressed={tag === t}>
            {t}
          </button>
        ))}
      </div>
      <MasonryGrid items={visible} />
    </>
  );
}

Fixed two columns in a sidebar

A number for columns fixes the column count; animateFrom="center" with blur turned off leaves a simple fade and slight scale-in.

<aside className="w-80">
  <MasonryGrid
    items={items}
    columns={2}
    gap={8}
    animateFrom="center"
    blurToFocus={false}
    scaleOnHover={false}
  />
</aside>

Props

PropTypeDefaultDescription
itemsMasonryItem[]—Grid item data
columnsnumber | Record<number, number>{ 0: 1, 480: 2, 768: 3, 1024: 4 }Fixed column count or a responsive breakpoint map
gapnumber16Gap between cards (px)
durationnumber0.5Duration of a single card's animation (seconds)
staggernumber0.05Interval between cards on reveal (seconds)
animateFrom"bottom" | "top" | "left" | "right" | "center""bottom"Direction the reveal starts from
blurToFocusbooleantrueWhether the reveal goes from blurred to sharp
scaleOnHoverbooleantrueWhether a card scales up slightly on hover
ease[number, number, number, number][0.22, 1, 0.36, 1]Easing curve of the animation

MasonryItem

FieldTypeDescription
idstringUnique identifier; the reflow animation tracks the same card by it
heightnumberCard height (px); the width is derived from the column width
contentReact.ReactNodeCard content; a placeholder panel renders when it is not supplied
classNamestringStyles appended to the card's frame

Accessibility

  • When the user has "reduce motion" enabled at the system level, cards land in place directly with no reveal or reflow animation
  • The layout only renders after the container width has been measured on the client, avoiding a mismatch between server and client (hydration mismatch)
  • A ResizeObserver watches the container width and disconnects automatically on unmount, so any size change reflows immediately

FAQ

Can card height follow the content?

No. Cards are absolutely positioned and their positions are computed up front, so each item needs a height in px. For images, estimate the height from the aspect ratio and expected column width, or crop with object-cover.

Is card content rendered on the server?

No. Cards render only after the container width is measured on the client; the server outputs an empty container. If the content needs to be indexed by search engines, provide crawlable links or a list elsewhere on the page.

How is the order decided?

Items are placed one by one, in items order, into whichever column is currently shortest. Visual position is therefore not strictly left-to-right, top-to-bottom, while keyboard Tab order follows the items array.

Are column breakpoints based on the viewport or the container?

The container. The columns map is resolved against the container width from a ResizeObserver; the default is 1 column from 0px, 2 from 480px, 3 from 768px and 4 from 1024px.

Does it work with the Next.js App Router?

Yes. The source already has "use client". When you pass items from a Server Component, the JSX in content can be rendered on the server too. See the install section above for dependencies.