Overview
Masonry Grid lays out tiles of any height in columns, placing each one into the currently shortest column. The layout is computed in JavaScript from measured heights rather than CSS columns, so placement is stable and the DOM order stays the reading order instead of running down one column at a time. The column count follows the container's width through breakpoints (or a fixed number), and every tile is watched by a ResizeObserver, so images that load late, text that wraps and content that expands all trigger a re-measure. Tiles are absolutely positioned and spring to their new places on reflow, new tiles scale in and removed tiles fade out, and the first placement happens before paint so nothing jumps on load. Before JavaScript runs, tiles flow in CSS columns, so the content is never hidden.
01Installation
Method 01 · Registry
CLI verifiedUses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/masonry-grid.tsx and adds its dependencies.
npx shadcn@latest add https://wancraft.my/r/masonry-grid.jsonMethod 02 · Copy and paste
- 1. Install the dependencies below.
- 2. Copy the source from the Code tab into
components/ui/masonry-grid.tsx. - 3. Import it and adjust props.
npm install motion- Dependencies
- motion
- Peer
- react ≥ 18, tailwindcss ≥ 4
- Added
- 13 Oct 2026
- Updated
- —
02Usage
The default configuration. Change properties above to generate a tailored example in the Usage tab.
import { MasonryGrid } from "@/components/ui/masonry-grid";
export function MasonryGridExample() {
const photos = [
{ id: "a", src: "/photos/a.jpg", alt: "Harbour at dawn" },
{ id: "b", src: "/photos/b.jpg", alt: "Concrete stairwell" },
{ id: "c", src: "/photos/c.jpg", alt: "Fern detail" },
];
return (
<MasonryGrid columns={{ 0: 1, 480: 2, 720: 3 }}>
{photos.map((photo) => (
<img key={photo.id} src={photo.src} alt={photo.alt} className="block w-full rounded-xl" />
))}
</MasonryGrid>
);
}03Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Required. Keyed elements, one per tile; the key identifies a tile across reorders. |
| columns | number | Record<number, number> | { 0: 1, 480: 2, 720: 3, 960: 4 } | Fixed count, or column counts keyed by minimum container width in px. |
| gap | number | 16 | Horizontal and vertical space between tiles, in px. |
| animate | boolean | true | Spring tiles to new places and fade tiles in and out. |
| spring | { stiffness; damping; mass } | { 320, 32, 1 } | Spring for reflow. |
| as | "div" | "ul" | "div" | Render a list (ul > li) for collections. |
| ...props | HTMLAttributes<HTMLElement> | — | Forwarded to the root (aria-label, className…). |
04Features
- 01Shortest-column placement from measured heights, deterministic and order-stable
- 02Responsive column count from container-width breakpoints (not the viewport)
- 03ResizeObserver on every tile: late images and expanding content re-flow automatically
- 04Spring reflow, scale-in for new tiles and fade-out for removed ones
- 05First placement measured before paint; CSS-columns fallback before hydration
- 06Optional list semantics with as="ul"
05Accessibility
- DOM order is the order of your children, so keyboard focus and screen readers follow the data order, which matches the visual order row by row as closely as masonry allows.
- Tiles are not reordered visually by CSS (no column flow after hydration), so tab order never jumps between columns.
- as="ul" renders a real list with list items; label it with aria-label.
- prefers-reduced-motion (or animate={false}) moves tiles instantly with no scale or fade.
06Limitations
- Tiles are absolutely positioned, so the container's height is set from the layout; content outside the tiles' boxes (e.g. large shadows) can overlap the next element.
- Very large collections should be virtualised; every tile is measured and rendered.