How MasonryKit is built
How I split MasonryKit, a TypeScript library for masonry layouts, into three packages: pure layout math, DOM helpers, and a React hook. Plus the patterns in each layer worth borrowing for your own libraries.
MasonryKit is a TypeScript library I built for masonry layouts. That’s the Pinterest-style layout where cards of different heights pack into columns without leaving gaps. Native CSS masonry is still spotty across browsers, so this is a layout you usually build with JavaScript.
The library ships three packages. This post walks through what’s in each, why the boundary between them is where it is, and which ideas inside each layer are worth borrowing. You don’t need to use MasonryKit to get something out of it. The same layering works for most UI libraries, and the masonry layout is just a concrete example.
The three packages
@masonrykit/core # Pure layout math, no DOM, no React
↓
@masonrykit/browser # + DOM utilities. Re-exports core.
↓
@masonrykit/react # + the useMasonry hook. Re-exports browser.
Each layer stacks on the one below. You install only what you need. A vanilla DOM project installs @masonrykit/browser. A Node script that pre-computes layouts for server-side rendering (SSR) installs @masonrykit/core. A React app installs @masonrykit/react and gets the entire stack from one import.
That works because each package re-exports everything from the one below it. You never have to install or import a lower layer on your own.
Layer 1: core, where the math lives
@masonrykit/core is pure TypeScript with no side effects. A “cell” is one item in the grid. You pass in cells and the grid’s width, and you get back an x, y, width, and height for each one.
import { computeLayout, heightCell, aspectCell } from '@masonrykit/core'
const cells = [heightCell('a', 100), aspectCell('b', 16 / 9), heightCell('c', 50)]
const layout = computeLayout(cells, {
gridWidth: 800,
columnWidth: 200,
gap: 16,
})
for (const cell of layout.cells) {
console.log(cell.id, cell.x, cell.y, cell.width, cell.height)
}
computeLayout is the entire surface of the algorithm. There are also factories for the three cell types, helpers for breakpoint resolution, viewport filtering, and column geometry. Everything else is a type.
Core has two rules. It can’t import anything that touches the DOM, and it has no runtime dependencies. Those two rules pay for three things.
Tests run fast. Every layout test is a function call against pure inputs. No DOM, no rendering, no React wrapping.
The math is portable. Need masonry on the server for SSR? Call computeLayout. In a Web Worker? Same call. A Vue, Svelte, Solid, or vanilla port doesn’t touch the algorithm. Only the binding, the glue code for that one framework, is new.
The math is easy to reason about. Pure inputs, pure output. No hidden state. When the layout looks wrong, you log the inputs, log the output, and the bug is somewhere obvious in the geometry.
The cell type itself is a discriminated union: a union of object types where one field (here, type) tells TypeScript which shape you have. There are three shapes:
type HeightCell<M> = {
id: string
type: 'height'
height: number
columnSpan?: number
} & Meta<M>
type AspectCell<M> = {
id: string
type: 'aspect'
aspectRatio: number
columnSpan?: number
} & Meta<M>
type MeasuredCell<M> = {
id: string
type: 'measured'
estimatedHeight?: number
columnSpan?: number
} & Meta<M>
type Cell<M> = HeightCell<M> | AspectCell<M> | MeasuredCell<M>
Three cell types because there are three real input shapes for a masonry. You either know the height up front (a fixed-height tile), or you know the aspect ratio (an image with intrinsic dimensions), or you discover the height at render time (user-generated content). A single “cell” type with optional fields would have made the algorithm a tree of if statements. The discriminated union turns each branch into a clean pattern match.
Every cell can also carry your own data in a meta field, like a photo’s URL or a card’s title, so you have it when you render. The Meta<M> type that makes this work is worth pausing on:
type Meta<M> = [M] extends [undefined] ? { meta?: M } : { meta: M }
interface Photo {
src: string
alt: string
}
const cell = heightCell<Photo>('p1', 200, { meta: { src: '/a.jpg', alt: 'A' } })
cell.meta.src // type-checks with no `!`
When you pass your own type, as in heightCell<Photo>, meta becomes required. When you don’t, it’s optional. The user’s data shape flows from input to output without a single non-null assertion. Many libraries either ignore the metadata problem entirely (pass-through unknown) or force you into casts. This conditional type is small and it pays off every time someone uses the library with their own data.
Layer 2: browser, where the DOM lives
@masonrykit/browser is the bridge between the math and the DOM. It’s also framework-agnostic. You can use it directly to back a vanilla masonry, or compose on top of it to build a framework binding. The React package does exactly that.
The whole API is small:
observeElementWidth(element, onWidth): () => void
createMeasuredHeightTracker(onChange): MeasuredHeightTracker
startViewTransition(callback): void
Each of these wraps a browser API that’s a small headache to use directly.
observeElementWidth is a ResizeObserver batched through requestAnimationFrame, so your callback runs at most once per frame. Without that, a fast window resize can fire it many more times than the screen can paint.
createMeasuredHeightTracker manages one ResizeObserver per cell and drops spurious 0-height reports. That filter matters because a zero can arrive before a child has laid out. With virtualization on (rendering only the cells near the screen), a cell that reports zero height can look empty and get removed by mistake.
startViewTransition is a feature-detected wrapper around document.startViewTransition. In browsers without support, it runs your callback right away. The DOM still updates. You just don’t get the animation.
The boundary rule for this layer is that nothing here renders. It only observes. The vanilla quick-start in the repo looks roughly like this:
const dispose = observeElementWidth(grid, (width) => {
const layout = computeLayout(cells, { gridWidth: width, columnWidth: 200, gap: 12 })
grid.style.setProperty('--grid-h', `${layout.height}px`)
grid.replaceChildren(/* … cells with --x, --y, --w, --h vars … */)
})
Look at who calls replaceChildren. You do. The library reports the width and computes the numbers, but it never creates or moves a DOM node. That separation is what lets one package back both a vanilla demo and a React hook.
Layer 3: react, where the bindings live
@masonrykit/react is one hook.
const { stableCells, gridRef, cellRef, measuredIds, layout } = useMasonry(cells, {
columnWidth: 240,
gap: 16,
})
What you get back, and only what you get back:
layout. The full computed result. You decide what to render.stableCells. The cells in an order that stays the same when you reorder your input. React keeps each DOM node where it is, and only the position numbers change, so CSS can animate the move.visibleCells. Only the cells near the viewport, when you turn on the hook’svirtualizeoption. Otherwise it’s the same asstableCells.measuredIds. The set of cell ids whose input was ameasuredCell. Use it to branchheight: autovsheight: cell.heightin your render.gridRef. Attach to your grid element so the hook can auto-measure width.cellRef(id). Returns a ref callback for that cell, with a stable identity per id so React doesn’t re-attach on every render. Cells that don’t need measuring get a shared no-op ref, so you can attach it to every cell.
That’s the whole API. The hook deliberately returns data, not elements. There’s no <Grid> or <Cell> component, and no prop-getter helpers like getGridProps / getCellProps. You write the JSX yourself, all the way down.
This is what “headless” means in MasonryKit. Some libraries use the word to mean “unstyled component”. You still get a <Component>, the library just doesn’t ship CSS. MasonryKit’s hook hands you the layout numbers and the refs the observers need to wire up. Everything else is your code.
The reason for that decision is composition. A consumer might need custom keyboard navigation for an accessibility audit, drag-and-drop, server-rendered grids, or View Transitions only on certain changes. None of that should require a fork. With data instead of elements, it’s all code at the call site.
The recommended way to apply the layout numbers is to set them as CSS custom properties (variables) on each cell, and let a stylesheet use them:
<div
ref={cellRef(cell.id)}
className="cell"
style={
{
'--x': `${cell.x}px`,
'--y': `${cell.y}px`,
'--w': `${cell.width}px`,
'--h': measuredIds.has(cell.id) ? null : `${cell.height}px`,
} as React.CSSProperties
}
/>
.cell {
position: absolute;
top: 0;
left: 0;
width: var(--w);
height: var(--h, auto);
translate: var(--x) var(--y);
}
The className stays the same across renders, and only the variable values change. That helps in a few places. The browser can reuse its selector matching. The translate can run on the compositor, the part of the browser that moves already-painted layers without laying out the page again. React’s reconciler has less work, because the style object always has the same keys. Setting transform: translate(...) inline forces a fresh style invalidation every frame, even when only the transform changed. When many cells animate at once, the CSS-variable pattern is faster.
The library doesn’t pick the variable names for you. Use --x, --col, --offset-x, or whatever fits your codebase. Tailwind 4’s translate-x-(--x) shorthand reads them inline as utilities if you’d rather skip the stylesheet.
Abstractions worth borrowing
These patterns from the codebase are worth copying into your own libraries.
Stable ref callbacks per id. cellRef(id) returns a function whose identity doesn’t change across renders. React doesn’t re-run attach/detach cycles. Underneath it’s a Map<string, RefCallback> cached behind the hook. The pattern is easy to get wrong. A naive (id) => (el) => ... creates a fresh function on every render, so React detaches and reattaches every cell every time.
Side-channel data instead of fattened types. measuredIds is a Set<string> returned alongside stableCells. The instinct might be to put originType: 'measured' on every LayoutCell. That would have leaked the input shape into the output type forever. A side-channel set is cheaper and trivial to remove later if the hook stops needing it.
Pure functions for things you’d think need hooks. filterVisibleCells(cells, gridTop, viewport, overscan?) takes plain numbers and returns a sliced array. The hook reads getBoundingClientRect() and window.innerHeight and passes the values in. You can unit-test virtualization with a few cells and a viewport rectangle. No DOM mock. No jsdom.
Required-when-typed generics. Meta<M> is small, but it threads the user’s type through the entire library without casts. No !, no as. The standard alternative is to type the metadata as unknown and force consumers to cast at every read site. The conditional type avoids that.
Layer re-exports. @masonrykit/browser re-exports everything from @masonrykit/core. @masonrykit/react re-exports everything from @masonrykit/browser. One import per layer covers the whole stack underneath. Your consumers never need to know the layering exists.
Boundary discipline is the point
The hardest part of building a library like this isn’t the math. It’s deciding what each layer is not allowed to do.
Core can’t reach for the DOM. Browser can’t render. React can’t bake in CSS conventions. Each rule narrows what the layer can be, which is what makes the layer above it possible to build. Without the rule that core stays pure, the browser layer can’t be framework-agnostic. Without the rule that browser doesn’t render, the React hook can’t be a thin binding.
If you’re designing a library and you’re not sure where to draw the boundaries, the question that helps me is: what would you have to delete if someone asked you to support a different framework next month? In MasonryKit the answer is “the React hook.” Core and browser come along unchanged. That’s the test that tells you the boundaries are in the right place.
The repo
Source, demos, and install instructions live at masonrykit/masonrykit. The packages are published as @masonrykit/core, @masonrykit/browser, and @masonrykit/react. Issues and pull requests are welcome.
