masonix
v0.3.1
Published
The React masonry layout library — lightweight, balanced, virtualized, SSR-ready
Maintainers
Readme
Correct reading order. Balanced columns. Optional virtualization. SSR-ready. Tailwind-compatible
Why masonix?
Every popular masonry library has at least one of these problems:
| Problem | Root cause |
| ----------------------------- | ------------------------------------------------------------------------------- |
| Wrong reading order | CSS column-fill places items 1, 4, 2, 5, 3, 6 instead of 1, 2, 3, 4, 5, 6 |
| Unbalanced columns | Height guesses break shortest-column-first; lazy images make it worse |
| Always-on virtualization | Overhead even for 20-item galleries |
| Layout flash on hydration | No SSR fallback or pre-known heights |
| Unmaintained | Most popular options haven't shipped a release since 2022 |
masonix solves all of these with a three-tier progressive enhancement model — use only what your use case needs.
Pick the right component
| | Masonry | MasonryBalanced | MasonryVirtual |
| ----------------------- | :-------------------------: | :------------------------: | :----------------------------: |
| Layout engine | CSS flexbox | JS positioner | JS positioner + virtualization |
| Item height measurement | None (browser-native) | ResizeObserver | ResizeObserver |
| Absolute positioning | No | Yes | Yes |
| Handles lazy images | No | Yes | Yes |
| Correct reading order | Yes | Yes | Yes |
| SSR with zero CLS | Yes (with defaultColumns) | Yes (with getItemHeight) | Yes (with getItemHeight) |
| 10,000+ items | No | No | Yes |
| Bundle size | ~6 kB | ~6 kB | ~9 kB |
| Import | masonix | masonix | masonix/virtual |
Decision guide:
- Most use cases →
Masonry - Need balanced columns →
MasonryBalanced - Balanced columns + large datasets (1000+ items) →
MasonryVirtual
Installation
npm install masonix
# or
pnpm add masonix
# or
yarn add masonixRequires React 18 or 19 as a peer dependency.
Quick start
Masonry — CSS mode
Zero JS measurement. Items are distributed into flexbox columns round-robin (1→col0, 2→col1, 3→col2, 4→col0, …), preserving left-to-right, top-to-bottom reading order.
import { Masonry } from 'masonix';
function Gallery({ photos }) {
return (
<Masonry
items={photos}
columns={{ 0: 1, 640: 2, 1024: 3, 1280: 4 }}
gap={16}
render={({ data }) => (
<img src={data.src} alt={data.alt} className="w-full rounded-lg" />
)}
/>
);
}MasonryBalanced — JS-measured mode
Measures rendered item heights via ResizeObserver and places each item into the shortest column. Handles lazy images, variable-height content, and container resize automatically.
import { MasonryBalanced } from 'masonix';
function Blog({ posts }) {
return (
<MasonryBalanced
items={posts}
columns={{ 0: 1, 640: 2, 1024: 3 }}
gap={24}
render={({ data, width }) => <PostCard post={data} width={width} />}
/>
);
}Zero-CLS with pre-known heights
If you can compute item height ahead of time (e.g. from image aspect ratios), pass getItemHeight to skip two-phase measurement entirely. This enables zero layout shift on SSR.
<MasonryBalanced
items={photos}
columns={3}
gap={16}
getItemHeight={(photo, _index, columnWidth) =>
columnWidth * (photo.height / photo.width)
}
render={({ data }) => <Photo photo={data} />}
/>MasonryVirtual — virtualized mode
Renders only items within the viewport. Built for 10,000+ item feeds. Drop-in replacement for MasonryBalanced.
import { MasonryVirtual } from 'masonix/virtual';
function Feed({ items }) {
return (
<MasonryVirtual
items={items}
columns={3}
gap={16}
estimatedItemHeight={300}
overscanBy={3}
render={({ data }) => <Card data={data} />}
/>
);
}Why a separate import?
masonix/virtualincludes the interval tree and scroll tracking code. Keeping it separate ensures these never land in your main bundle unless you use them.
Props reference
Common props (all three components)
| Prop | Type | Default | Description |
| ----------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------- |
| items | T[] | required | Data array. Generic — works with any shape. |
| render | ComponentType<{ index, data, width }> | required | Item renderer. width is the computed column width in px. |
| columns | number \| Record<number, number> | — | Fixed column count or responsive map { minWidthPx: count }. |
| columnWidth | number | — | Auto-compute column count from a minimum column width in px. |
| maxColumns | number | — | Upper bound when using columnWidth. |
| gap | number \| Record<number, number> | 0 | Row and column gap in px. Accepts a responsive map. |
| defaultColumns | number | 3 | Column count used during SSR before the container is measured. |
| defaultWidth | number | — | Container width used during SSR. Set to your expected width to avoid layout shift. |
| className | string | — | CSS class on the outer container. |
| style | CSSProperties | — | Inline styles on the outer container. |
| columnClassName | string | — | CSS class on each column wrapper (Masonry only). |
| itemClassName | string | — | CSS class on each item wrapper. |
| as | ElementType | "div" | Override the container element type. |
| itemAs | ElementType | "div" | Override the item wrapper element type (e.g. "article", "li"). |
| itemKey | (data: T, index: number) => string \| number | — | Stable React key extractor. Recommended when items can be reordered. |
| role | "grid" \| "list" \| "none" | "list" | ARIA role on the container. Pass "none" to suppress. |
| aria-label | string | — | ARIA label on the container. |
| ref | Ref<HTMLElement> | — | Forwarded ref to the container element. |
MasonryBalanced additional props
| Prop | Type | Default | Description |
| --------------------- | --------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------- |
| getItemHeight | (data: T, index: number, columnWidth: number) => number | — | Pre-compute item height to skip ResizeObserver measurement. Enables zero-CLS SSR. |
| estimatedItemHeight | number | 150 | Placeholder height (px) used before a item is measured. Items are hidden until measured. |
| minItemHeight | number | — | Clamp all measured heights to at least this value. |
MasonryVirtual additional props
| Prop | Type | Default | Description |
| --------------------- | ------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------- |
| estimatedItemHeight | number | 150 | Placeholder height for unrendered items. More accurate estimates improve initial scroll position. |
| overscanBy | number | 2 | Number of extra viewport-heights to render above and below the visible area. |
| scrollContainer | RefObject<HTMLElement \| null> | — | Custom scroll container. Defaults to window. Use when the masonry sits inside a scrollable div. |
| totalItems | number | — | Total item count for aria-setsize (useful when items are loaded in pages). |
| scrollRef | Ref<MasonryVirtualHandle> | — | Ref to gain imperative scroll control (see scrollToIndex). |
| onRangeChange | (startIndex: number, stopIndex: number) => void | — | Called when the visible index range changes. Use this to implement infinite scroll. |
Responsive values
Both columns and gap accept a plain number or a breakpoint map where each key is a minimum container width in pixels:
// Activates at each threshold — similar to Tailwind breakpoints
columns={{ 0: 1, 640: 2, 1024: 3, 1280: 4 }}
gap={{ 0: 8, 640: 12, 1024: 16 }}
// Fixed value
columns={3}
gap={16}
// Auto-compute count from a minimum column width
// e.g. a 1200px container with columnWidth=280 → 4 columns
columnWidth={280}
maxColumns={5}SSR / Next.js
All three components render on the server using defaultColumns and defaultWidth as fallbacks. Since the container hasn't been measured yet, the initial render uses these values.
// Next.js example — avoids column count mismatch on hydration
<MasonryBalanced
items={posts}
columns={{ 0: 1, 768: 2, 1200: 3 }}
defaultColumns={3}
defaultWidth={1200}
gap={24}
render={({ data }) => <PostCard post={data} />}
/>For zero CLS on image-heavy pages, provide getItemHeight:
// Known aspect ratios → no layout shift, no measurement phase
<MasonryBalanced
items={photos}
columns={3}
gap={16}
defaultWidth={900}
getItemHeight={(photo, _index, columnWidth) =>
Math.round(columnWidth * (photo.naturalHeight / photo.naturalWidth))
}
render={({ data }) => <Photo photo={data} />}
/>Infinite scroll
Use onRangeChange on MasonryVirtual to load more items when the user approaches the bottom of the list:
const [items, setItems] = useState(initialItems);
const [isLoading, setIsLoading] = useState(false);
async function loadMore() {
if (isLoading) return;
setIsLoading(true);
const next = await fetchNextPage();
setItems((prev) => [...prev, ...next]);
setIsLoading(false);
}
<MasonryVirtual
items={items}
columns={3}
gap={16}
estimatedItemHeight={300}
onRangeChange={(startIndex, stopIndex) => {
if (stopIndex >= items.length - 10 && !isLoading) {
loadMore();
}
}}
render={({ data }) => <Card data={data} />}
/>;Scroll to index
Use scrollRef to programmatically scroll to any item:
import { useRef } from 'react';
import { MasonryVirtual, type MasonryVirtualHandle } from 'masonix/virtual';
function Feed({ items }) {
const scrollRef = useRef<MasonryVirtualHandle>(null);
return (
<>
<button
onClick={() =>
scrollRef.current?.scrollToIndex(0, { align: 'start', smooth: true })
}
>
Back to top
</button>
<MasonryVirtual
items={items}
columns={3}
gap={16}
estimatedItemHeight={300}
scrollRef={scrollRef}
render={({ data }) => <Card data={data} />}
/>
</>
);
}scrollToIndex(index, options?)
| Option | Values | Default | Description |
| -------- | ------------------------------ | --------- | ---------------------------------------- |
| align | "start" \| "center" \| "end" | "start" | Where to align the item in the viewport. |
| smooth | boolean | false | Use smooth scrolling. |
Custom scroll container
By default MasonryVirtual tracks the window scroll. To use a scrollable div instead:
const containerRef = useRef<HTMLDivElement>(null);
<div ref={containerRef} style={{ height: '100vh', overflow: 'auto' }}>
<MasonryVirtual
items={items}
columns={3}
gap={16}
estimatedItemHeight={300}
scrollContainer={containerRef}
render={({ data }) => <Card data={data} />}
/>
</div>;Semantic HTML
Use itemAs and as to produce meaningful markup:
<MasonryBalanced
items={articles}
columns={3}
gap={24}
as="ul"
itemAs="li"
role="list"
aria-label="Latest articles"
render={({ data }) => <ArticleCard article={data} />}
/>Tailwind usage
masonix applies only structural styles. All visual styles come from your class names and render function.
// CSS mode — Tailwind gaps via className
<Masonry
items={items}
columns={{ 0: 1, 640: 2, 1024: 3 }}
className="p-4"
columnClassName="gap-4"
itemClassName="overflow-hidden rounded-xl"
render={({ data }) => <Card data={data} />}
/>
// Balanced mode — gap must be numeric (used in JS math)
<MasonryBalanced
items={items}
columns={3}
gap={20}
className="mx-auto max-w-6xl px-4"
itemClassName="overflow-hidden rounded-xl shadow-sm"
render={({ data }) => (
<div className="bg-white p-4">
<Card data={data} />
</div>
)}
/>Note: In
MasonryBalancedandMasonryVirtual, row and column gaps are applied via inline styles since they participate in the JS layout math. Usegap(numeric) rather than Tailwind gap classes for these components.
Native CSS masonry (experimental)
Masonry supports the upcoming grid-template-rows: masonry CSS spec via the enableNative prop. When the browser supports it, the component switches from flexbox columns to a native CSS grid — no JS layout at all.
<Masonry
items={items}
columns={3}
gap={16}
enableNative
render={({ data }) => <Card data={data} />}
/>The component gracefully falls back to flexbox columns in unsupported browsers. As of 2025, native masonry is available behind a flag in Chrome and enabled by default in Firefox.
TypeScript
All types are exported from both entry points:
// From masonix
import type {
MasonryProps,
MasonryBalancedProps,
MasonryRenderProps,
ResponsiveValue,
PositionedItem,
Positioner,
} from 'masonix';
// From masonix/virtual
import type {
MasonryVirtualProps,
MasonryVirtualHandle,
} from 'masonix/virtual';All components are fully generic over your item type:
interface Photo {
id: string;
src: string;
width: number;
height: number;
}
// render receives { data: Photo, index: number, width: number }
<MasonryBalanced<Photo>
items={photos}
columns={3}
gap={16}
render={({ data }) => <img src={data.src} alt={`Photo ${data.id}`} />}
/>Bundle size
| Entry point | Gzipped |
| ----------------- | ------- |
| masonix | ~6 kB |
| masonix/virtual | ~9 kB |
Two separate entry points ensure the interval tree, scroll tracking, and virtualization code are never included in your bundle unless you import from masonix/virtual. "sideEffects": false enables full tree-shaking.
Both ESM and CJS builds are provided. The "use client" directive is included for React Server Components compatibility.
Development
pnpm install
pnpm test:run # run all tests once
pnpm test # watch mode
pnpm test:coverage # coverage report
pnpm build # typecheck + build dist/
pnpm playground # dev sandbox at localhost:3000
pnpm lint # lint src/
pnpm format # format src/Run a single test file:
pnpm vp test src/__tests__/core/positioner.test.tsLicense
MIT
