Cascading grid layout library
This is a fork of desandro/masonry whose upstream has been effectively dormant since 2018. The original library still works and is the canonical reference — for stable, plain Masonry use that.
Masonry works by placing elements in optimal position based on available vertical space, sort of like a mason fitting stones in a wall. You’ve probably seen it in use all over the Internet.
See masonry.desandro.com for complete docs and demos of the original library.
The goals of this fork are narrow and concrete:
- Fix the long-standing pain points that dominate the upstream issue tracker — image overlap when content lazy-loads, custom-font flicker on first paint, SSR (
windowundefined), modern bundler (Vite/Rollup) compatibility, percentage width + gutter math. - Modernize the build and runtime — drop IE/legacy code paths, replace bundled polyfills with native browser APIs (
ResizeObserver,MutationObserver,Element.matches, Web Animations API,EventTarget,AbortController,document.fonts.ready), ship a real ESM build, and shrink the bundle from ~7.4 KB gzipped toward ~2 KB gzipped. - Integrate chenglou/pretext so text-driven bricks can be measured arithmetically without forcing DOM reflow.
Every change in this fork has to produce a measurable improvement in speed, bundle size, or UX. Cosmetic refactors and abstractions without a benchmark or before/after number are explicitly out of scope.
Internal documentation:
FORK_ROADMAP.md— master plan, dependency audit, prioritized work list, measurement methodology, rejected non-improvementsPRETEXT_SSR_ROADMAP.md— focused single-feature roadmap for the pretext + SSR + computeLayout vision (the headline feature below). All 6 phases shipped.FORK_RELEASE_NOTES.md— Keep-a-Changelog–style user-facing notes per dev tag, with predicted-vs-actual measurementsimprovements/— per-change engineering audit trail (one file per improvement, with hypothesis / method / before / after / verdict)CLAUDE.md— instructions for future Claude sessions (build commands, test gates, architecture, conventions)
Cumulative Layout Shift drops from 0.74 to 0.00 — measured. No other masonry-style library on the market can do this.
masonry-pretext ships Masonry.computeLayout, a pure-Node helper that computes cascading-grid positions on the server. Combined with initLayout: false on the client (masonry-pretext adopts the existing positions instead of recomputing them) and the static: true preset (no observers, no animations, no fonts.ready hook), the result is a cascading grid that renders correctly on first paint — no flow-to-absolute reflow, no animated settle, no observable hydration jank.
| Strategy | Median CLS | First-paint final layout | Hydration flash |
|---|---|---|---|
Old way — server emits flow layout, client runs new Masonry(grid, {}) (every other masonry library) |
0.7421 ❌ | ❌ No (waits for JS) | ❌ Visible reflow |
masonry-pretext SSR pipeline — server Masonry.computeLayout → inline positions → client initLayout: false, static: true |
0.0000 ✅ | ✅ Yes (positions in HTML) | ✅ None |
Reproduce with make bench (drives Playwright + chromium against two synthetic SSR pages, measures CLS via PerformanceObserver, reports median + p10/p90 across 30 runs). Source: test/visual/bench-hydration.mjs.
The server-side layout cost is also negligible: Masonry.computeLayout runs in 0.13 ms median for a 5000-item grid in pure Node — under the 5 ms budget for a single server response by 40×. Reproduce with node test/visual/bench-server-layout.mjs.
See examples/astro/ for a runnable end-to-end demo (Astro + Masonry.computeLayout + initLayout: false + static: true). Drop the file into a fresh Astro project, install masonry-pretext, run npm run dev, and verify CLS = 0.00 in DevTools yourself. Full design + acceptance criteria in PRETEXT_SSR_ROADMAP.md.
User-visible wins that have already landed in the fork. Each entry links to the per-change record in improvements/ and the git tag where the change first shipped.
Until v5.0.0 ships, every line below is from a
5.0.0-dev.Npre-release tag. The runtime library (dist/masonry.pkgd.min.js) is byte-identical to upstream v4.2.2 at this stage — the bundle-shrinking work begins in improvement004. The wins so far are about toolchain health, not runtime size.
| Tag | What you get | Number |
|---|---|---|
v5.0.0-dev.1 |
97% smaller npm install. Dropped the broken Gulp 3 + JSHint + RequireJS + QUnit + Bower toolchain. npm install goes from 349 → 10 packages (the original devDeps had multiple unmaintained packages with open security advisories that will never be patched). See improvements/001-foundation-cleanup.md. |
|
v5.0.0-dev.2 |
The build actually runs again. The upstream Gulp 3 build has been broken on Node ≥ 17 since ~2020. Replaced with a single ~120-line esbuild script that produces the same dist/masonry.pkgd.js + dist/masonry.pkgd.min.js artifacts in 17 ms (vs gulp's previously-multi-second build, ~500× faster). Source unchanged, behavior verified by 4/4 visual regression tests. Trade-off: the minified bundle is +0.83 % raw / +7.1 % gzipped vs the upstream frozen file because of esbuild's CommonJS runtime helper — that cost is recoverable as later improvements delete dead code. Full numbers + the surprise that jquery-bridget secretly bundles all of jQuery in improvements/002-esbuild-build.md. |
|
v5.0.0-dev.3 |
First slice of dead-code deletion. Removed the desandro-matches-selector polyfill (a 50-LOC vendor-prefix walker for Element.matchesSelector) — Element.matches has been unprefixed in every browser since 2014 and is universally available at the fork's target baseline. dist/masonry.pkgd.min.js drops by −401 B raw / −102 B gzipped / −96 B brotli. The minified raw is now smaller than the upstream-frozen v4.2.2 file for the first time (−201 B raw vs upstream). Behavior unchanged, 4/4 visual tests still pass. See improvements/003-delete-matches-selector-polyfill.md. |
|
v5.0.0-dev.4 |
Second slice of dead-code deletion. Removed the transition / transform vendor-prefix detection in outlayer/item.js (WebkitTransition, WebkitTransform, webkitTransitionEnd, dashedVendorProperties, toDashedAll helper, vendorProperties lookup table, scattered consumer sites — ~50 LOC of dead browser-compat). dist/masonry.pkgd.min.js drops by −606 B raw / −172 B gzipped / −189 B brotli. Vs upstream v4.2.2: −807 B raw, gzip is now within +249 B (was +524 B after the esbuild build replacement — 52 % of that regression is now recovered). Behavior unchanged, 4/4 visual tests still pass. What this is not: the original roadmap claimed this would close upstream #1194 / #1121 (SSR window undefined) as a side effect of removing the module-load document.documentElement.style access. A new test/visual/ssr-smoke.mjs test in this improvement disproves that — the actual crash is at the UMD-wrapper IIFE call site (one frame earlier), not inside the deleted block. SSR fix has been split out as a separate planned improvement. Full record + the negative-result analysis in improvements/004-delete-vendor-prefix-detection.md. |
|
v5.0.0-dev.5 |
SSR import works. import Masonry from 'masonry-pretext' no longer crashes in Next.js / Nuxt / SvelteKit / Vite SSR build passes. Wrapped every UMD wrapper's window reference with typeof window !== 'undefined' ? window : {} (in masonry.js directly, plus build-time patches for outlayer/outlayer.js, outlayer/item.js, get-size.js, fizzy-ui-utils.js, jquery-bridget.js) and added a typeof document short-circuit at the top of fizzy-ui-utils.docReady. Verified by test/visual/ssr-smoke.mjs (loads the bundle in a Node vm context with empty globals — flips from ✗ to ✓), now part of make test so the SSR fix is gated against future regression. Closes upstream desandro/masonry#1194, #1121, #1201 — all three have been open for 1–2 years with no upstream movement. Cost: +13 B gzipped on dist/masonry.pkgd.min.js (essentially free). Behavior in the browser is identical (4/4 visual tests still pass — the typeof window guard always evaluates to the real window in browsers). Full record in improvements/005-ssr-import-fix.md. |
|
v5.0.0-dev.6 |
Removed jQuery entirely. Zero jquery / bridget strings remain in dist/masonry.pkgd.{js,min.js} — verified by a new make test gate (test/visual/no-jquery.mjs). MILESTONE: every minified-bundle metric is now smaller than upstream v4.2.2 for the first time (raw −2,129 B / −8.8 %, gzip −295 B / −4.0 %, brotli −200 B / −3.0 %). Dropped jquery-bridget from devDeps (so npm install masonry-pretext no longer pulls jQuery into the dep tree), removed it from the bundle entry, deleted the jqueryStubPlugin, and directly deleted every if (jQuery) { … } branch from outlayer/outlayer.js and fizzy-ui-utils/utils.js via build-time exact-string transforms (an initial DCE-via-const jQuery = false attempt didn't work — esbuild's minifier doesn't constant-propagate across function-property closures). Breaking change for jQuery shim users. $('.grid').masonry() and .masonry('reloadItems') syntax no longer works — migrate to new Masonry('.grid', { … }) and instance method calls (the documented vanilla API). All three gates (visual + SSR + no-jquery) green. Full record in improvements/006-remove-jquery.md. |
|
v5.0.0-dev.7 |
Deleted get-size box-sizing detection. A 40-LOC setup() function in the bundled get-size dependency created a probe div, measured it via getComputedStyle, and removed it on the first getSize() call — solely to detect an IE11 / Firefox <29 quirk where style.width returned the inner width on border-box elements. At the fork's browser baseline (chrome 84 / firefox 86 / safari 15 / edge 84), modern browsers always return the outer width, so the detection is dead code. Deleted via three build-time transforms that strip the setup function, the call site inside getSize(), and inline the now-redundant isBorderBoxSizeOuter variable. Side benefit: eliminates one forced reflow round-trip on the first getSize() call. dist/masonry.pkgd.min.js drops by −378 B raw / −148 B gzipped / −156 B brotli. Vs upstream v4.2.2: now over 10 % smaller raw, 6 % smaller gzipped, 5.4 % smaller brotli. All three gates still green. Full record in improvements/007-delete-getsize-boxsizing-setup.md. |
|
v5.0.0-dev.8 |
Deleted unused fizzy-ui-utils methods. An audit of every utils.X call site in masonry.js and outlayer/{outlayer,item}.js revealed two methods that are never called from the masonry consumption path: utils.modulo and utils.getParent. esbuild can't tree-shake them (they're properties on a utils object so the whole object stays reachable), so they were deleted explicitly via two build-time transforms. dist/masonry.pkgd.min.js drops by −138 B raw / −53 B gzipped / −43 B brotli. Vs upstream v4.2.2: now −10.97 % raw / −6.73 % gzipped / −6.04 % brotli. The smallest L.* deletion so far — pure deletions are approaching diminishing returns; the next big size wins will come from architectural changes (event target replacement, ResizeObserver, etc.). All three gates green. Full record in improvements/008-delete-unused-fizzy-utils.md. |
|
v5.0.0-dev.9 |
The headline fork feature: pretext integration. Added a pretextify(element) option callback to Masonry. If set and returns {outerWidth, outerHeight}, the size is used as-is and item.getSize() (which forces a DOM reflow) is skipped entirely. Designed to plug into @chenglou/pretext for arithmetic text measurement, but library-agnostic — works with any DOM-free measurement strategy or pre-computed sizes. Measured speedup: ~1.2-1.3× faster initial layout (17-24% reduction) across grids of 100-2000 items, verified by a new test/visual/bench-pretext.mjs benchmark. The bench is checked in as a permanent tool — node test/visual/bench-pretext.mjs reproduces the numbers. Cost: +22 B gzipped on dist/masonry.pkgd.min.js. Discriminating visual fixture (test/visual/pages/pretext.html) proves the callback is really bypassing DOM measurement. All five visual fixtures + SSR + no-jquery gates green. Full record in improvements/009-pretext-integration.md. |
|
v5.0.0-dev.10 |
Custom font flicker fix — closes upstream desandro/masonry#1182. When a web font hasn't finished loading at construction time, masonry measures items at the fallback font's height and the layout overlaps until something triggers a relayout. Added a _create override that schedules a deferred layout() when document.fonts.ready resolves. Guarded by typeof document (SSR-safe), document.fonts.status !== 'loaded' (no-op when fonts are already loaded), and an alive-check (no-op if the instance was destroyed before fonts loaded). Cost: +63 B gzipped on dist/masonry.pkgd.min.js for an issue that's been open in upstream since 2022 with no fix. Discriminating visual fixture (test/visual/pages/fonts-ready.html) mocks document.fonts.ready and asserts the deferred layout fires (item 3 lands at the post-font-load position, not the pre-font-load position). All six visual fixtures + SSR + no-jquery gates green. Full record in improvements/010-document-fonts-ready.md. |
|
v5.0.0-dev.11 |
Foundation fixes (Tier 0): README + packaging + CI + portable harness. Closes the four foundation gaps surfaced by the post-#010 multi-review. README's Install / Initialize sections were stale (told users to npm install masonry-layout, use Bower, and call $('.grid').masonry({...}) — none of which work) — now rewritten to match what masonry-pretext actually is, with a From source install path, the vanilla API examples, and a real pretextify usage example. package.json gained exports, module, types, and sideEffects: false fields so modern bundlers (Vite/Rollup/esbuild/webpack 5) can find the right entry per consumer style; main now points at dist/masonry.pkgd.min.js instead of the source UMD wrapper. New hand-written masonry.d.ts (~210 lines) gives TypeScript users autocomplete on the public surface — including the pretextify callback typed correctly. New .github/workflows/test.yml runs make ci on push + PR, with cached chromium and the make measure size report on every run. Hardened _harness.mjs chromium launch with --no-sandbox / --disable-dev-shm-usage / --disable-gpu so the test gate runs in any container/CI environment. Zero source code change. Zero bundle byte change. Pure adoption-ergonomics + automation. Full record in improvements/011-tier0-foundation.md. |
|
v5.0.0-dev.12 |
Per-item ResizeObserver — closes 8+ duplicate upstream issues in one shot. When a masonry item contains a lazy-loading <img>, masonry measures the item at its empty fallback size, packs it, then the image loads and the item grows — but masonry doesn't know to relayout. The result is the dominant complaint category in the upstream tracker (desandro/masonry#1147 "Overlapping Images - Bootstrap User" plus 7 duplicates: #1185, #1158, #1152, #1108, #1165, #1189, #1199). All have been open for 2-4 years with no upstream fix. Fix: a per-instance ResizeObserver observing every item element, with requestAnimationFrame coalescing so multiple resize events in the same frame collapse to one layout() call. SSR-safe. Cleaned up automatically on destroy(). Items added via appended()/prepended() are auto-observed via an _itemize override; removed items are auto-unobserved via a remove override (no memory leak). Cost: +365 B gzipped on dist/masonry.pkgd.min.js for 8+ closed upstream issues. Discriminating visual fixture (test/visual/pages/resize-observer.html) programmatically resizes item 0 from 30→60 after construction and asserts the relayout fires (item 3 lands at (60, 30), the post-resize position, not (0, 30)). All seven visual fixtures + SSR + no-jquery gates green. The first attempt's "skip first observer event" logic was a bug that the discriminating fixture caught immediately — see improvements/012-per-item-resize-observer.md for the calibration lesson. |
|
v5.0.0-dev.13 |
import and require actually work now. Every dev tag through v5.0.0-dev.12 shipped a package.json exports field that pointed import, require, and default at dist/masonry.pkgd.min.js — which is format: 'iife', a bare var Masonry = (() => { … })() with no module exports of any kind. await import('masonry-pretext') from any modern bundler (Vite, Rollup, esbuild, webpack 5, Astro, Next.js, Nuxt, SvelteKit) resolved to default = undefined, and consumers got TypeError: Masonry is not a constructor. The IIFE bundle worked for <script src="…"> browser drop-in but broke every other path. Fix: added dist/masonry.cjs (CommonJS, ~9.5 KB gz) and dist/masonry.mjs (ES module, ~10 KB gz) — built in parallel by the same esbuild script — and rewired package.json main / module / exports to point at them. The IIFE bundles are unchanged byte-for-byte; existing <script> tag consumers see no diff, modern-bundler consumers see a working install. New test/visual/module-smoke.mjs gate loads both new bundles via Node require() and dynamic import() and asserts they expose a constructor — runs as part of make test, so this regression class is now impossible. The Tier 0 packaging fix in #011 was source-change-free and acknowledged the real ESM build was still pending; #013 is that build, closing roadmap § 2.2. Verified end-to-end against a real Astro/Vite downstream (enacast-astro). All seven visual fixtures + SSR + module-smoke + no-jquery gates green. Full record in improvements/013-esm-cjs-builds.md. |
|
v5.0.0-dev.14 |
Percentage column width + gutter math fix — closes upstream desandro/masonry#1006 (53 reactions, the highest-reaction open issue in the upstream tracker, more than the next 5 combined). Open since 2018 with no upstream movement. When the user gives masonry a percentage column width — either as a literal columnWidth: '20%' option (now first-class supported), an inline style="width: 20%" on the sizer element, or a stylesheet rule like .grid-sizer { width: 20% } — masonry's gutter-overshoot math drops a column. Concrete example: in a 1000px container with a 20% sizer and a 10px gutter, floor((1000+10) / (200+10)) = 4 columns instead of the obvious 5, leaving 170px of unused space on the right. Fix: detect that columnWidth originated from a percentage (three layers — literal option, inline style, walked stylesheet rules), then snap cols = round(100/percent) and recompute columnWidth = (containerWidth + gutter) / cols so the gutters fit inside the container. The stylesheet walker recurses into @media / @supports rules only when their condition currently matches, and silently skips cross-origin sheets that throw on .cssRules access. Discriminating visual fixture (test/visual/pages/percent-cols.html): container 240px, gutter 20px, sizer 20% — without the fix masonry computes 3 columns and items 3+4 wrap to row 2; with the fix it computes 5 columns and all 5 items pack into row 1. Cost: +391 B gzipped on dist/masonry.pkgd.min.js for 53 reactions × 8 years × zero upstream fix. All 8 visual fixtures + SSR + module-smoke + no-jquery gates green. Full record in improvements/014-percent-column-width-fix.md. |
|
v5.0.0-dev.15 |
static: true SSR preset — one flag to opt out of all dynamic-content machinery. For server-rendered grids whose items will not change size after first paint (Next.js, Astro, SvelteKit, Nuxt SSR pages — the common SSR case), this single option flips three runtime behaviors in one shot: forces transitionDuration: 0 (no animated settle on any relayout, including window-resize relayouts), skips the document.fonts.ready deferred layout from #010, and skips the entire per-item ResizeObserver construction from #012 — including the per-item getBoundingClientRect() pre-seed loop. What this buys SSR users: no hydration flash, no 0.4s animated reposition on window resize, and on a 100-item grid, ~100 fewer reflows on construction. The _itemize, remove, and destroy hooks all already check this._resizeObserver before touching it, so they no-op correctly in static mode — no additional changes needed. Cost: +20 B gzipped on dist/masonry.pkgd.min.js (+101 B raw / +21 B brotli). Discriminating visual fixture (test/visual/pages/static-mode.html) is the exact inverse of the resize-observer.html fixture — same shape, programmatically resizes item 0 from 30→60 after construction, but expects item 3 to stay at (0, 30) because the observer is never constructed. All 9 visual fixtures + SSR + module-smoke + no-jquery gates green. See the new examples/nextjs/ and examples/astro/ for runnable copies. Full record in improvements/015-static-ssr-preset.md. Also ships PRETEXT_SSR_ROADMAP.md — the focused single-feature roadmap for the broader pretext + SSR + computeLayout vision. |
|
v5.0.0-dev.16 |
Engine/adapter split — pure-math placeItem(size, state). Foundational refactor that extracts the packing math from _getItemLayoutPosition into a top-level pure function with no this, no DOM, no option lookups. Zero behavior change — all 9 visual fixtures pass byte-for-byte against unchanged screenshot baselines. Prerequisite for Masonry.computeLayout (#017) which calls the same pure layer from a Node-callable static method. The four backward-compat proto._getX methods stay on the prototype as thin shims that delegate to the pure helpers. Cost: +164 B gzipped on dist/masonry.pkgd.min.js — over the predicted "±0" because esbuild doesn't inline file-local helpers across function boundaries. Bytes recoverable later by deleting the proto wrappers (breaking change for plugin authors, deferred to v5.0.0-rc). Full record in improvements/016-engine-adapter-split.md. Phase 1 of PRETEXT_SSR_ROADMAP.md. |
|
v5.0.0-dev.17 |
Masonry.computeLayout(opts) static helper — THE killer SSR feature. New static method on the Masonry constructor that takes pre-measured item sizes + container width + column width + gutter and returns absolute positions. No DOM, no instance, no this — runs in Node, edge functions, web workers, or any JavaScript runtime. The killer use case: server-side cascading-grid layout for SSR pages. Render your grid in your server framework's route handler (Astro frontmatter, Next.js Server Component, etc.), call Masonry.computeLayout(...) with sizes from @chenglou/pretext or any DOM-free measurement library, and emit the resulting positions inline as style="left: Xpx; top: Ypx". New test/visual/compute-layout.mjs Node-only test gate proves byte-for-byte agreement with the browser-side layout for all 9 visual fixtures, on the first build, with no debugging required. Fully typed in masonry.d.ts (ComputeLayoutOptions + ComputeLayoutResult). Cost: +393 B gzipped on dist/masonry.pkgd.min.js for the entire SSR feature line (subsequently trimmed by the simplify pass after #020 that extracted shared deriveCols / applyStamp / computeFitContainerWidth helpers between proto.* and Masonry.computeLayout). All 9 visual fixtures + ssr + module + new compute-layout + no-jquery gates green. Full record in improvements/017-compute-layout-static-helper.md. Phase 2 of PRETEXT_SSR_ROADMAP.md. |
|
v5.0.0-dev.18 |
initLayout: false SSR adoption verification — zero bundle bytes, locks in the entire client-side half of the SSR feature line. Phase 2 (#017) added Masonry.computeLayout (server-side helper). Phase 3's question: does the client adopt those positions correctly when constructing masonry? Answer (after reading the Outlayer + Item source): yes, out of the box, no source change needed. initLayout: false from Outlayer skips the constructor's layout() call, Item._create's style.position = 'absolute' is a no-op for items the server already pre-rendered with that, and static: true (#015) skips every dynamic-content hook that could later overwrite the SSR positions. Phase 3 ships the discriminating fixture that locks this in permanently: test/visual/pages/init-layout-false.html pre-positions 4 items in a single-column stack at (0,0), (0,30), (0,60), (0,90) — a layout shape masonry would NEVER produce naturally — and asserts the items stay there. Verified discriminator by toggling initLayout: false → true and watching the fixture fail loudly. Zero bytes added to any bundle output — the smallest improvement on record. All 10 visual fixtures + ssr + module + compute-layout + no-jquery gates green. Full record in improvements/018-init-layout-false-adoption.md. Phase 3 of PRETEXT_SSR_ROADMAP.md. |
|
v5.0.0-dev.19 |
End-to-end Astro SSR pipeline example — the runnable proof. examples/astro/ is now an end-to-end SSR demo that uses every piece of the SSR feature line: Masonry.computeLayout in the Astro frontmatter (Node), inline absolute positions in the server-rendered HTML, and new Masonry(grid, { initLayout: false, static: true }) on the client to adopt the existing positions. Drop the file into a fresh Astro project, install masonry-pretext, run npm run dev, and verify CLS = 0.00 in DevTools yourself. The four-step pipeline is documented step-by-step in the example README. The --grid-height CSS variable trick (server reserves the full computed container height) is the secret to CLS = 0.00. The example uses hardcoded heights for reproducibility; the swap-in for real @chenglou/pretext.layout() is one diff. The four constraints (predictable container width, predictable item heights, font metrics match server↔client, grid is static after first paint) are documented in a "When NOT to use this pattern" section. Zero bundle byte change — the library is unchanged; the example is rewritten. Full record in improvements/019-astro-ssr-pipeline-example.md. Phase 4 of PRETEXT_SSR_ROADMAP.md. |
|
v5.0.0-dev.20 |
Hydration + server-layout benchmarks + README headline — MEASURED CLS 0.7421 → 0.0000 (100% reduction). The load-bearing measurement step for the entire SSR feature line. Two new permanent benchmarks, both reproducible by anyone via make bench: bench-server-layout.mjs (pure-Node microbench, times Masonry.computeLayout for grids of N=100/500/1000/5000 items, measured 0.131 ms median for 5000 items — 38× under the 5 ms predicted budget) and bench-hydration.mjs (Playwright-driven CLS bench, generates two HTML fixtures at runtime, navigates a fresh chromium page to each × 30 interleaved runs, captures CLS via PerformanceObserver with buffered: true). Headline result: control variant CLS = 0.7421 (Lighthouse "Poor"), pipeline variant CLS = 0.0000 across every run. 100% reduction. This is the headline number for the entire fork. The README's new "🎯 The headline feature" callout (above this table) puts the number in the first sentence of the first screen. The non-negotiable § marker in PRETEXT_SSR_ROADMAP.md is satisfied: a working Masonry.computeLayout with no published number is a half-shipped feature; both halves now ship together. All gates green. Zero source change to masonry.js — Phase 5 is purely measurement + documentation. Full record in improvements/020-bench-and-headline.md. Phase 5 of PRETEXT_SSR_ROADMAP.md — the SSR feature line is COMPLETE. |
|
v5.0.0-dev.38 |
Source maps in dist/. Every output bundle now ships an external *.map sibling with sourcesContent inlined, so production error trackers (Sentry, Datadog, Rollbar) can resolve minified stack traces back to masonry.js line numbers. Cost: +45 B raw / +34 B gzipped on dist/masonry.pkgd.min.js (just the //# sourceMappingURL=... directive — the maps themselves are external and the browser never parses them). Addresses downstream consumer ask D.5. Full record in improvements/038-source-maps.md. |
|
v5.0.0-dev.39 |
Per-instance silent option. The one-time console.info banner from v5.0.0-dev.37 is now suppressible per-instance via new Masonry(grid, { silent: true }) — not just globally via Masonry.silent = true. Per-instance wins over the global flag but does NOT mutate it; a silent grid coexisting with a noisy grid in the same page still lets the noisy one trigger the banner. Cost: +5 B gzipped. Closes downstream consumer ask D.12. Full record in improvements/039-per-instance-silent.md. |
|
v5.0.0-dev.40 |
'layoutError' event. A new msnry.on('layoutError', cb) event surfaces silent layout failures so multi-tenant frontends can forward them to Sentry / Datadog / Rollbar. Reasons in the initial set: 'detached' (element gone), 'zero-width' (display: none etc.), 'colspan-overflow' (item wider than grid). The library still positions the item — the event is informational. Hot path stays branchless when no listener is registered. Cost: +136 B gzipped. Closes downstream consumer ask D.6. Full record in improvements/040-layout-error-event.md. |
|
v5.0.0-dev.41 |
Multi-breakpoint Masonry.computeLayouts(opts, breakpoints). A thin wrapper over Masonry.computeLayout that loops per-breakpoint and returns a { name → result } map. Unlocks responsive SSR: a server can't know which breakpoint a viewer is on, so it computes layouts for ALL of them up front, emits each set inline, and lets the client pick the right one via matchMedia. Cost: +63 B gzipped. New compute-layouts.mjs test gate with 4 discriminating cases. Closes downstream consumer ask D.1. Full record in improvements/041-multi-breakpoint-compute-layouts.md. |
|
v5.0.0-dev.42 |
itemSizer(element, columnWidth) callback ⭐ — the structural unblocker for mixed-media SSR grids. New constructor option that runs in BOTH browser and pure-Node (Masonry.computeLayout({ itemSizer })), with the resolved column stride as input. Lets non-text grids (news cards, podcast tiles, weather widgets, banner groups) declare per-item heights as closed-form functions of column width without writing a separate measurement library. Resolution order: itemSizer first, then pretextify, then item.getSize() — each layer falls through on null/false return. The SSR side accepts both top-level itemSizer(item, stride) AND per-item { data, sizer(stride, data) } closures. Cost: +80 B gzipped. Closes downstream consumer ask D.3 — flagged in the audit as the highest-leverage Tier 1 item. Full record in improvements/042-item-sizer-callback.md. |
|
v5.0.0-dev.43 |
measureFromAttributes option. New new Masonry(grid, { measureFromAttributes: true }) flag that walks each item element looking for an aspect-ratio hint — [data-aspect-ratio] first, then <img width height>, then <img style="aspect-ratio: …"> — and computes a closed-form item height from columnWidth × (h/w). Eliminates the post-image-load relayout cycle: modern browsers reserve the box natively via CSS aspect-ratio, but the per-item ResizeObserver still fires during the reserved → loaded transition; this option pre-records the expected size so masonry skips the spurious relayout. Browser-side only (Masonry.computeLayout consumers should use itemSizer). Cost: +228 B gzipped (with a bonus refactor of the resolution chain to a flat sequence for cheaper future resolver additions). Closes downstream consumer ask D.7. Full record in improvements/043-measure-from-attributes.md. |
|
v5.0.0-dev.44 |
dynamicItems selector opt-out. New new Masonry(grid, { static: true, dynamicItems: '.dynamic-item' }) option lets a server-rendered grid tolerate a small number of dynamic items (lazy-loading iframes, podcast embeds, weather widgets) while keeping the rest pre-positioned with zero observer overhead. Only items matching the selector get the per-item ResizeObserver wired up; when one of them grows, masonry runs a full relayout pass that picks up new sizes for ALL items (so the cascade reaches static siblings without each needing its own observer). Lets a v2 modular page coexist with one or two embeds without dropping to the v1 dynamic-content path. Cost: +41 B gzipped. Closes downstream consumer ask D.4. Full record in improvements/044-dynamic-items-opt-out.md. |
|
v5.0.0-dev.45 |
static: 'until-resize' hybrid mode. A new string variant of the existing static option that behaves like static: true on construction but flips back to dynamic-content behavior on the first window-resize-driven relayout. Effectively: "trust the server until the client proves the server was wrong." Useful when the server can't reliably know the viewer's container width and may pick the wrong breakpoint — the first user resize triggers a one-shot handoff that restores transitionDuration and wires up the per-item ResizeObserver retroactively. Cost: +114 B gzipped. Closes downstream consumer ask D.2 — all four Tier 1 downstream items now shipped (D.1, D.2, D.3, D.4). Full record in improvements/045-static-until-resize.md. |
|
v5.0.0-dev.46 |
replaceItems(newElems) atomic swap. New msnry.replaceItems(newElems) method that removes all current items + appends a new set in a single relayout pass. Equivalent to destroy() + new Masonry(...) but reuses the existing observer wiring + column measurements, so SPA navigation between two structurally similar grids skips the construction cost. Cost: +59 B gzipped. Closes downstream consumer ask D.9. Full record in improvements/046-replace-items.md. |
|
v5.0.0-dev.47 |
pause() / resume() for View Transitions. New msnry.pause() / msnry.resume() methods that suspend the per-item ResizeObserver and MutationObserver callbacks during half-swapped document states (View Transitions). The observers themselves stay connected; only the rAF coalescing + relayout path is gated. Events accumulated while paused collapse into a single catch-up layout() call when resume() is invoked. Cost: +51 B gzipped. Closes downstream consumer ask D.10. Full record in improvements/047-pause-resume.md. |
|
v5.0.0-dev.48 |
msnry.diagnose() structured snapshot. New instance method returning a typed MasonryDiagnostic object describing the current state of the grid — cols, columnWidth, container size, items with their positions/sizes/observer-wired flag, observer status strings, last layout timestamp, last relayout reason. Standardized shape for dev tools / testing frameworks instead of every consumer rewriting their own debug helper. Cost: +319 B gzipped (above the predicted band — dominated by human-readable status strings; revisit-able in a simplify pass). Closes downstream consumer ask D.11. Full record in improvements/048-diagnose.md. |
|
v5.0.0-dev.49 |
masonry-pretext/astro integration subpath. New import 'masonry-pretext/astro' side-effect bundle that loads the <masonry-grid> Custom Element + wires up astro:page-load / astro:after-swap listeners. Detects stale persisted <masonry-grid> elements (where Astro swapped contents in-place but connectedCallback didn't fire) via an O(1) item-identity heuristic and reconstructs them. Zero bytes added to imperative-API bundles — ships as two new separate files (dist/masonry-astro.{js,mjs}) that opt-in consumers pay for. Closes downstream consumer ask D.8 — all 12 downstream consumer asks (D.1–D.12) are now landed. Full record in improvements/049-astro-integration-subpath.md. |
- The fork is primarily developed by Claude (Anthropic's AI coding assistant) under the direction of the maintainer.
- The maintainer is a working developer, not a Masonry expert. They don't know the original library's internals deeply, don't have time to learn its history in detail, and rely on Claude to do the heavy reading and reasoning.
- Contributions are very welcome — especially from people who know the original codebase, who have hit one of the upstream issues this fork targets, or who can write tests / benchmarks. Open an issue or PR; expect a friendly but slow review cadence. The roadmap document is the best place to see what's planned and where help is most useful.
- If you need a guaranteed long-term-maintained, human-reviewed Masonry, the original desandro/masonry (or a Metafizzy commercial library) is still the right choice.
masonry-pretextis in pre-release (v5.0.0-dev tags). It is not yet published to npm. The instructions below cover the working install paths until v5.0.0 ships. If you need a stable npm-installable masonry today, use the originalmasonry-layoutinstead.
git clone https://github.com/oriolj/masonry-pretext.git
cd masonry-pretext
make install # npm install + downloads chromium for the visual test suite
make build # produces dist/masonry.pkgd.{js,min.js} via esbuild (~14 ms)The packaged file lands at dist/masonry.pkgd.min.js. Drop it into your page via a <script> tag, copy it into your bundler's vendor folder, or import from a relative path.
Each improvement is released as a v5.0.0-dev.N git tag — see the tag list. You can pin to one via npm's git URL syntax:
npm install github:oriolj/masonry-pretext#v5.0.0-dev.10Note: npm install from a git URL clones the repo but does not run the build. After install, run make build (or npm run build) inside node_modules/masonry-pretext/ to produce dist/. The published-to-npm release (v5.0.0 final) will ship pre-built dist/ files.
Chrome 84+ / Firefox 86+ / Safari 15+ / Edge 84+. The fork drops IE / Edge Legacy / Safari ≤14 support — see FORK_ROADMAP.md § Browser support cuts.
masonry-pretext only supports the vanilla JS API. The jQuery shim from upstream was removed in improvement #006. Migration is mechanical:
// before (upstream / pre-#006)
$('.grid').masonry({ columnWidth: 200 });
$('.grid').masonry('reloadItems');
$('.grid').masonry('layout');
// after (masonry-pretext)
const msnry = new Masonry('.grid', { columnWidth: 200 });
msnry.reloadItems();
msnry.layout();const msnry = new Masonry('.grid', {
itemSelector: '.grid-item',
columnWidth: 200,
});const grid = document.querySelector('.grid');
const msnry = new Masonry(grid, {
itemSelector: '.grid-item',
columnWidth: 200,
});The HTML auto-init path inherited from upstream still works in pre-release. Note that it is currently slated for removal in the v5.0.0 line — see FORK_ROADMAP.md item E (closes upstream desandro/masonry#1104).
<div class="grid" data-masonry='{ "itemSelector": ".grid-item", "columnWidth": 200 }'>
<div class="grid-item"></div>
<div class="grid-item"></div>
</div>Pass a pretextify(element) callback to skip per-item DOM measurement. Designed to plug into @chenglou/pretext for arithmetic text measurement against cached font metrics — but the callback is library-agnostic and works with any pre-computed sizes:
import { prepare, layout } from '@chenglou/pretext';
const cache = new WeakMap();
const FONT = '16px/1.5 Inter, sans-serif';
const COL_WIDTH = 280;
const LINE_HEIGHT = 24;
new Masonry('.grid', {
columnWidth: COL_WIDTH,
pretextify(elem) {
let prepared = cache.get(elem);
if (!prepared) {
prepared = prepare(elem.dataset.text || elem.textContent, FONT);
cache.set(elem, prepared);
}
const { height } = layout(prepared, COL_WIDTH, LINE_HEIGHT);
return { outerWidth: COL_WIDTH, outerHeight: height };
},
});Measured speedup vs DOM measurement: ~1.2-1.3× faster initial layout (17-24% reduction) across grids of 100-2000 items. The callback's lookup must be O(1) (a WeakMap keyed on element, or a cached prepare() result) — an O(N) per-call lookup will erase the savings. See improvements/009-pretext-integration.md for the full record + the calibration lesson that surfaced this.
masonry-pretext is safe to import from server code — Next.js (App Router and Pages Router), Nuxt, SvelteKit, Astro, Remix, Vite SSR, any build that evaluates the module graph in Node. import Masonry from 'masonry-pretext' (and require('masonry-pretext')) no longer crashes with ReferenceError: window is not defined or document is not defined.
This closes long-standing upstream issues desandro/masonry#1194, #1121, and #1201. How it works: every UMD call site inside the bundle (masonry, outlayer, outlayer/item, get-size, fizzy-ui-utils, jquery-bridget, plus fizzy-ui-utils.docReady) is wrapped in a typeof window !== 'undefined' / typeof document !== 'undefined' guard. In a browser the guards evaluate to the real globals; in Node they short-circuit to empty objects, and the typeof ResizeObserver / document.fonts checks in _create no-op. The bundle loads cleanly in a DOM-less vm context — verified on every build by test/visual/ssr-smoke.mjs, which runs as part of npm test. See improvements/005-ssr-import-fix.md for the full record.
- ✅ Import is safe. You can put
import Masonry from 'masonry-pretext'in the top of a server component, layout, or route file — it will not crash the server render, even if you never construct an instance there. - ✅ Constructing inside a
typeof window !== 'undefined'guard is safe. All the DOM-touching work in_createis gated behindtypeofchecks. - ❌ The library does not lay out on the server. Masonry needs real DOM elements with real measured sizes. The server renders the grid markup in flow layout; a tiny client-side effect constructs
new Masonry(...)after hydration, at which point the items get absolutely positioned. - ❌ Do not construct a Masonry instance at module scope in a server file. That would run in Node and hit DOM APIs. Always defer construction to a
useEffect/onMount/client:loadboundary.
// React (Next.js, Remix, etc.)
'use client';
import { useEffect, useRef } from 'react';
import Masonry from 'masonry-pretext';
export default function Grid({ items }) {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
const msnry = new Masonry(ref.current, {
itemSelector: '.grid-item',
columnWidth: 200,
gutter: 10,
transitionDuration: 0, // see "Optimizations for SSR mode" below
});
return () => msnry.destroy();
}, [items]);
return (
<div ref={ref} className="grid">
{items.map((item) => (
<div key={item.id} className="grid-item">{item.content}</div>
))}
</div>
);
}The grid markup is server-rendered in flow layout (no position: absolute on items, no computed heights). On hydration Masonry constructs, measures, and reflows into a cascading grid. Users see one layout pass from flow → absolute.
Full runnable examples:
- Next.js (App Router +
'use client'):examples/nextjs/ - Astro (server component +
client:loadisland):examples/astro/
Before the client JS boots, the user sees the grid rendered by the server — in normal flow layout, because Masonry has not run yet. When hydration completes and new Masonry(...) runs, the items suddenly reflow into the absolute-positioned cascade. On a fast page this is invisible; on a slow page it is a visible layout shift (CLS).
Two practical mitigations today:
- Reserve vertical space with CSS. Give the grid container a
min-height(or the items a fixedaspect-ratio). Flow layout will match the final height closely enough that the reflow is not a vertical jump, only a horizontal rearrangement. - Disable the enter animation. Pass
transitionDuration: 0so the flow → absolute transition is instantaneous, not a 0.4s animated settle. This is the single highest-impact option for SSR content — see below.
Masonry's default options assume a client-rendered SPA where items fade in, animate on resize, and may grow as lazy images load. When your content is server-rendered and static after first paint, most of that machinery is wasted work.
One flag flips all of it off:
new Masonry(ref.current, {
columnWidth: 200,
gutter: 10,
static: true, // ← SSR preset: no animations, no fonts.ready gate, no ResizeObserver
});Landed in v5.0.0-dev.15. Setting static: true does three things in one flag:
| What it skips | Effect |
|---|---|
Forces transitionDuration: 0 |
No animated settle on any relayout — including window-resize relayouts. Eliminates the visible "settle" on hydration. |
Skips the document.fonts.ready deferred layout (#010) |
No extra relayout after font load. Safe because static SSR content is rendered in its final font. |
Skips the per-item ResizeObserver (#012) |
No observer, no getBoundingClientRect() pre-seed per item, no rAF callback, no auto-relayout on item size changes. Safe because items will not grow. |
Cost: +20 B gzipped on the bundle for users who don't opt in. Runtime savings for users who do: on a 100-item grid, ~100 fewer reflows on construction (the ResizeObserver pre-seed loop runs getBoundingClientRect() on every item — that is now skipped), no promise chain for document.fonts.ready, no rAF scheduling, and no transition-property CSS writes on subsequent layouts.
Per-option granularity — you can skip static and tune individual options if you want:
| Option | Default | SSR recommendation | Why |
|---|---|---|---|
static |
false |
true (preferred — enables the three below) |
Single flag for the whole SSR preset. Use unless you need per-option control. |
transitionDuration |
'0.4s' |
0 (implied by static: true) |
Relayouts on resize / font load / image load are instant instead of a 0.4s animated reposition. |
stagger |
0 |
0 |
Already 0; call out so readers know not to set it in SSR contexts. |
resize |
true |
true (keep) |
Window-resize relayouts are still valuable on the client. Cheap to leave on. |
initLayout |
true |
true (or false if you pre-positioned items server-side) |
Set false only when you have already written position:absolute;left:…;top:… into each item's inline style on the server, and you want Masonry to only handle subsequent resizes. |
When NOT to use static: true:
- Your grid contains lazy-loading images (
<img loading="lazy">) that will grow after first paint — you want the ResizeObserver to catch the growth and relayout. - Your page uses custom web fonts that may still be loading when masonry constructs — you want the
document.fonts.readydeferred layout to re-measure items at their real font height. - You add or remove items dynamically and you want the fade-in animation on appended items.
In any of those cases, either leave static unset (default) or tune the individual options you need.
Other things that are already safe / already good in SSR mode without static: true:
- First layout is already instant. Outlayer skips transitions on the very first
layout()call via_isLayoutInited. The first layout after hydration is always transition-free;transitionDuration: 0only matters for subsequent layouts (resize, image load, etc.). - Pretext (
pretextify) works in SSR and non-SSR equally. It is not specific to SSR but pairs naturally with it: if you have measured heights from a cached font-metrics pass, you can skip the per-item reflow on hydration entirely. document.fonts.readygate is a no-op when fonts are already loaded. If your page's fonts are preloaded or inlined, the #010 deferred layout never fires even withoutstatic: true.- Per-item
ResizeObserveris already SSR-safe. It is only constructed iftypeof ResizeObserver !== 'undefined', which is false in Node. On the client it keeps working normally.
Short answer: you do not need to do anything different. The pretextify callback runs inside layout(), which only runs on the client (inside your useEffect / onMount / <script>). The callback body never executes during server render, so it is free to assume a DOM environment.
The only caveat is on the pretext library's own import, not on masonry's. If @chenglou/pretext (or whatever measurement library you are plugging in) touches document, window, or OffscreenCanvas at import time, then import { prepare, layout } from '@chenglou/pretext' at the top of a server component file could crash — that would be a pretext-side SSR bug, not a masonry one. Two mitigations:
- Import pretext inside the client effect rather than at module top. In a React
'use client'component this is trivial — the whole file is client-only, so a top-level import is fine. In mixed server/client files, move the import inside theuseEffectbody (or useawait import(...)lazily). - Keep the
pretextifycallback closure-local to the client code. Build your size cache (WeakMap, etc.) inside the effect, not in module scope, so it is never instantiated during server render.
Example inside a Next.js 'use client' component:
'use client';
import { useEffect, useRef } from 'react';
import Masonry from 'masonry-pretext';
import { prepare, layout } from '@chenglou/pretext';
const FONT = '16px/1.5 Inter, sans-serif';
const COL_WIDTH = 240;
const LINE_HEIGHT = 24;
export default function Grid({ items }) {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
const cache = new WeakMap();
const msnry = new Masonry(ref.current, {
columnWidth: COL_WIDTH,
transitionDuration: 0,
pretextify(elem) {
let prepared = cache.get(elem);
if (!prepared) {
prepared = prepare(elem.textContent, FONT);
cache.set(elem, prepared);
}
const { height } = layout(prepared, COL_WIDTH, LINE_HEIGHT);
return { outerWidth: COL_WIDTH, outerHeight: height };
},
});
return () => msnry.destroy();
}, [items]);
return <div ref={ref} className="grid">{/* ... */}</div>;
}That is it. The masonry side of SSR + pretext has no special setup.
Candidate future optimizations (not yet landed — tracked as ideas, open an issue if you want one prioritized):
- A
Masonry.computeLayout(sizes, options)static helper (pure packing math, no DOM) so the server can pre-compute(x, y)positions and emit them inline as CSS. Combined withinitLayout: false, this gives a zero-flash SSR path where items land at their final positions on the very first paint. Depends on roadmap item P (engine/adapter split). - Deferring ResizeObserver attachment to
requestIdleCallbackto keep it off the hydration critical path for non-static grids.
Masonry is released under the MIT license. Have at it.
Original library by David DeSandro · masonry-pretext fork by Oriol Jimenez (primarily developed by Claude — see CLAUDE.md)