Items keep their own height and stack into columns without gaps, like a Pinterest board. Modeled on MUI's Masonry, and works in React, Vue, Svelte, Solid, Preact and vanilla JS.
- Each item goes to the shortest column, or fills the columns in order
- A fixed column count, a minimum column width, or a column count set from CSS per breakpoint
- Items can span several columns
- Re-lays out when an item resizes, an image loads, or items are added or removed
- Renders as a plain grid until measured, so server-rendered markup reads fine before hydration
- RTL support
Installation
in the preview, then copy the prompt into Claude Code, Cursor or another coding agent, or add the component yourself with the shadcn CLI or by hand.
Show prompt
Add the masonry layout from isbatak-labs to this project. It lays out items of different heights in columns, placing each one in the shortest column or in order. Use the React version styled with Panda CSS and built on the Zag state machine. Docs: https://isbatak-labs.vercel.app/components/prototypes/masonry.md 1. Install it with the shadcn CLI. Swap `pnpm dlx` for `npx`, `yarn dlx` or `bunx` to match the project's lockfile: pnpm dlx shadcn@latest add https://isbatak-labs.vercel.app/r/masonry-basic-react-panda.json This installs `@isbatak/zag-masonry` and `@zag-js/react`, plus `@isbatak/panda-masonry` as a dev dependency, and adds the component to `src/components/masonry-basic/`. If the CLI can't run here, install those packages yourself and write the files listed in https://isbatak-labs.vercel.app/r/masonry-basic-react-panda.json (each entry has its content and target path). 2. Finish the setup: - Panda CSS: add `"@isbatak/panda-masonry"` to the presets in the Panda config (or import `masonryPreset` from it if the config already imports its presets), then run `panda codegen`. If the project doesn't use Panda CSS, stop and tell me to pick the CSS version in the docs instead. 3. Ask me where the masonry should go and what it should show, and suggest a spot if one stands out. Then render it there: copy the installed component, give it a name that fits, replace the items with mine (each item needs a unique `value` and must be a direct child of the root), and keep the layout options that fit (`columns`, `gap`, `sequential`). 4. Start the dev server, check that the items render in columns without gaps and re-flow when the window is resized, then stop it. If you can't run it, tell me how to check it myself.
pnpm dlx shadcn@latest add https://isbatak-labs.vercel.app/r/masonry-basic-react-panda.jsonThis installs the dependencies and adds the component to src/components/masonry-basic/. It imports the recipe from styled-system/recipes.
Add "@isbatak/panda-masonry" to presets in your Panda config, or import masonryPreset from it and add that instead.
- Install the dependencies:
pnpm add @isbatak/zag-masonry @zag-js/react pnpm add -D @isbatak/panda-masonry Add
"@isbatak/panda-masonry"topresetsin your Panda config, or importmasonryPresetfrom it and add that instead.- Copy these files into
src/components/masonry-basic/:"use client" import { css } from "styled-system/css" import { masonry as masonryRecipe } from "styled-system/recipes" import * as masonry from "@isbatak/zag-masonry" import { normalizeProps } from "@zag-js/react" import { useEffect, useId, useRef, useState } from "react" const styles = masonryRecipe() const classes = { tile: css({ display: "flex", alignItems: "center", justifyContent: "center", color: "fg.muted", textStyle: "sm", }), } export function MasonryBasic() { const id = useId() const [controller] = useState(() => masonry.createMasonry()) const [layout, setLayout] = useState<masonry.Layout | null>(null) const masonryProps: masonry.Props = { id, columns: 4 } const latestProps = useRef(masonryProps) latestProps.current = masonryProps useEffect(() => controller.mount(() => latestProps.current, setLayout), [controller]) const api = controller.connect(masonryProps, layout, normalizeProps) return ( <div {...api.getRootProps()} className={styles.root}> <div {...api.getItemProps({ value: "1" })} className={styles.item}> <div className={classes.tile} style={{ height: 150 }}> 1 </div> </div> <div {...api.getItemProps({ value: "2" })} className={styles.item}> <div className={classes.tile} style={{ height: 30 }}> 2 </div> </div> <div {...api.getItemProps({ value: "3" })} className={styles.item}> <div className={classes.tile} style={{ height: 90 }}> 3 </div> </div> <div {...api.getItemProps({ value: "4" })} className={styles.item}> <div className={classes.tile} style={{ height: 70 }}> 4 </div> </div> <div {...api.getItemProps({ value: "5" })} className={styles.item}> <div className={classes.tile} style={{ height: 110 }}> 5 </div> </div> <div {...api.getItemProps({ value: "6" })} className={styles.item}> <div className={classes.tile} style={{ height: 150 }}> 6 </div> </div> <div {...api.getItemProps({ value: "7" })} className={styles.item}> <div className={classes.tile} style={{ height: 130 }}> 7 </div> </div> <div {...api.getItemProps({ value: "8" })} className={styles.item}> <div className={classes.tile} style={{ height: 80 }}> 8 </div> </div> <div {...api.getItemProps({ value: "9" })} className={styles.item}> <div className={classes.tile} style={{ height: 50 }}> 9 </div> </div> <div {...api.getItemProps({ value: "10" })} className={styles.item}> <div className={classes.tile} style={{ height: 90 }}> 10 </div> </div> <div {...api.getItemProps({ value: "11" })} className={styles.item}> <div className={classes.tile} style={{ height: 100 }}> 11 </div> </div> <div {...api.getItemProps({ value: "12" })} className={styles.item}> <div className={classes.tile} style={{ height: 150 }}> 12 </div> </div> <div {...api.getItemProps({ value: "13" })} className={styles.item}> <div className={classes.tile} style={{ height: 30 }}> 13 </div> </div> <div {...api.getItemProps({ value: "14" })} className={styles.item}> <div className={classes.tile} style={{ height: 50 }}> 14 </div> </div> <div {...api.getItemProps({ value: "15" })} className={styles.item}> <div className={classes.tile} style={{ height: 80 }}> 15 </div> </div> </div> ) }
Examples
Scroll to an example to load it in the preview, and open the code view there for the full source. The settings button in the preview changes the example live, and the snippet under it follows. Each example is its own registry item, so you can add it with the CLI the same way.
Items must be direct children of the root, and each one needs a unique value.
Image masonry
Items can hold anything, images included. The layout runs again as each image loads, so lazy-loaded images don't need a known size up front. Items are placed in reading order, row by row.
const controller = masonry.createMasonry()
const api = controller.connect(
{
id,
columns: 3,
},
layout,
normalizeProps,
)Items with variable height
Every item is watched for size changes. Open an accordion and the items below it move, and may switch columns, so each one still lands in the shortest column.
const controller = masonry.createMasonry()
const api = controller.connect(
{
id,
columns: 3,
},
layout,
normalizeProps,
)Columns
Set columns for a fixed count. To change it per breakpoint, leave columns unset and set the --masonry-columns CSS
variable on the root instead, here 3 columns on small screens and 4 from sm up. Set minColumnWidth, for example
"12rem", to fit as many columns as there is room for.
const controller = masonry.createMasonry()
const api = controller.connect(
{
id,
},
layout,
normalizeProps,
)Spacing
Set gap to a number of pixels or a CSS length. Leave it unset to take the spacing from CSS, so it can change per
breakpoint, here from 2 to 6. With Panda CSS, the recipe's gap variant picks from the spacing tokens.
const controller = masonry.createMasonry()
const api = controller.connect(
{
id,
columns: 3,
},
layout,
normalizeProps,
)Sequential
Set sequential to fill the columns in order, left to right, instead of adding each item to the shortest column.
const controller = masonry.createMasonry()
const api = controller.connect(
{
id,
columns: 4,
sequential: true,
},
layout,
normalizeProps,
)Spanning columns
Set span on an item to make it cover several columns. It goes where the columns it covers are shortest, and the span
is capped at the number of columns.
const controller = masonry.createMasonry()
const api = controller.connect(
{
id,
columns: 3,
},
layout,
normalizeProps,
)Server-side rendering
Until the items are measured, the root is a plain CSS grid with the same columns and gap, so server-rendered markup
shows every item in place without overlapping. After hydration the items are measured and moved into their columns, and
the root gets a data-measured attribute. There's no need for a default height or column count.
Props
| Prop | Type | Default |
|---|---|---|
id | string The unique id of the masonry. | — |
ids | ElementIds The ids of the elements in the masonry. Useful for composition. | — |
columns | number The number of columns. Leave it unset to control it from CSS with the --masonry-columns variable, for example
to change it per breakpoint. | 4 |
minColumnWidth | string The minimum column width, for example "12rem". When set, the root fits as many columns as there is room for and
columns is ignored. | — |
gap | number | string The space between items, in pixels or as a CSS length. Leave it unset to control it from CSS with gap. | — |
sequential | boolean Whether items fill the columns in order, left to right, instead of going to the shortest column. | false |
onLayoutChange | (details: LayoutChangeDetails) => void Function called when the number of columns or the height changes. | — |
dir | "ltr" | "rtl" The document's text/writing direction. | "ltr" |
API
| Property | Type |
|---|---|
columns | number The number of columns, or 0 before the first layout. |
measured | boolean Whether the items have been measured and placed. Before that they render as a plain grid. |
reflow | () => void Measure and place the items again. |