# Masonry Kit — AI Reference `@macrulez/masonry-kit-core` / `-vue` / `-react` / `-nuxt` — a framework-agnostic masonry/bento grid layout engine (`ResizeObserver`- measured, `transform`-positioned), with Vue 3, React, and Nuxt adapters over the same core. Version 0.1.4. This document is hand-written for AI agents and other tools that generate code against this package: every signature, default, and behavior note below is verified directly against the TypeScript source (not summarized from prose docs), and prose is kept to the minimum needed to use the API correctly. For human-readable narrative docs (why you'd reach for each piece, worked examples), see the interactive site instead: - Full docs (EN): https://npm.vuecraft.ru/en/packages/masonry-kit/guide/overview - Full docs (RU): https://npm.vuecraft.ru/packages/masonry-kit/guide/overview - GitHub: https://github.com/macrulezru/masonry-kit - npm: https://www.npmjs.com/package/@macrulez/masonry-kit-core Links below starting with "/" are relative to https://npm.vuecraft.ru. --- ## 1. Package map — what to import from where | Package | Install | Peer deps | Provides | |---|---|---|---| | `@macrulez/masonry-kit-core` | `npm install @macrulez/masonry-kit-core` | none | `createMasonryEngine()` — the framework-agnostic engine (section 3). | | `@macrulez/masonry-kit-vue` | `npm install @macrulez/masonry-kit-vue` | `vue: ^3.3.0` | `` + `useMasonry()` (section 4), **plus `export * from '@macrulez/masonry-kit-core'`** — the full core surface. | | `@macrulez/masonry-kit-react` | `npm install @macrulez/masonry-kit-react` | `react: ^18.0.0 \|\| ^19.0.0` | `` + `useMasonry()` (section 5), **plus `export * from '@macrulez/masonry-kit-core'`**. | | `@macrulez/masonry-kit-nuxt` | `npm install @macrulez/masonry-kit-nuxt` | `nuxt: ^3.9.0 \|\| ^4.0.0` | Nuxt module: auto-imports ``/`useMasonry` from `-vue`, seeds shared option defaults from `nuxt.config.ts` — **universal (server + client) plugin, not client-only** (section 6). Depends on `-vue` internally. | **Rule: install exactly one package and import everything from it** — `-vue` and `-react` both re-export 100% of `-core`. Installing `-core` alongside an adapter is redundant, never required. `-nuxt` doesn't need `-vue` installed separately either. --- ## 2. Core types (verbatim from `@macrulez/masonry-kit-core`'s `types.ts`) ```ts type LaneBreakpoints = { default: number; [maxCrossSize: number]: number } type LaneSpec = number | 'auto' | LaneBreakpoints // 'auto': as many lanes of >= minLaneSize as the container's cross size allows. // number: fixed lane count. // LaneBreakpoints: nearest key >= container's current cross size wins; else `default`. interface MasonryItemDescriptor { id: string el?: HTMLElement | (() => HTMLElement | null) // omitted/null-returning → item skipped until it resolves, not an error colSpan?: number // direction: 'vertical' only. clamped to [1, lanes] every relayout. default 1 rowSpan?: number // direction: 'horizontal' only. default 1 aspectRatio?: number // provisional main-axis size (crossSize / ratio at vertical, crossSize * ratio at horizontal) before a real measurement exists estimatedSize?: number // virtualize-only, explicit px estimate, takes priority over aspectRatio order?: number // explicit pack order; default is array position in setItems() } interface MasonryOptions { direction?: 'vertical' | 'horizontal' // default 'vertical' columns?: LaneSpec // direction: 'vertical'. default 'auto' rows?: LaneSpec // direction: 'horizontal'. default 'auto' minLaneSize?: number // required for columns/rows: 'auto'. default 240 gap?: number | { main?: number; cross?: number } // default 16/16 placement?: 'balanced' | 'ordered' // default 'balanced' virtualize?: boolean | { overscan?: number } // default false; true → overscan 600 scrollContainer?: HTMLElement | 'self' | 'window' // default 'window' at vertical, 'self' at horizontal. ignored if virtualize is off estimateSize?: (item: MasonryItemDescriptor) => number // ignored if virtualize is off animate?: boolean // default true transitionDuration?: number // ms, ignored if animate is off. default 250 transitionEasing?: string // ignored if animate is off. default 'cubic-bezier(0.2, 0, 0, 1)' ssrColumns?: number // engine NEVER reads this — resolved via resolveSsrColumns(), consumed entirely by adapters } interface MasonryItemLayout { // direction-agnostic physical box, already resolved id: string; x: number; y: number; width: number; height: number } interface MasonryEngineEventMap { layout: { items: MasonryItemLayout[]; visibleIds: string[] } // fired at the end of EVERY relayout (initial, structural, resize-, scroll-triggered) } ``` --- ## 3. `@macrulez/masonry-kit-core` ### 3.1 `createMasonryEngine(container, options?)` ```ts function createMasonryEngine(container: HTMLElement, options?: MasonryOptions): MasonryEngine interface MasonryEngine { setItems(items: MasonryItemDescriptor[]): void // full replace, triggers immediate relayout updateItem(id: string, patch: Partial>): void // merge; no-op if id not registered; id itself cannot be changed addItem(item: MasonryItemDescriptor, index?: number): void // incremental add; index defaults to end of list removeItem(id: string): void // incremental remove relayout(): void // forces immediate recompute, bypasses the rAF batch getLayout(): MasonryItemLayout[] // most recent resolved layout getVisibleIds(): string[] // every packed id when virtualize is off; only the visible-range ids when on setDragging(id: string | null): void // excludes id from packing entirely; caller owns its transform/opacity/classes until called again with null on(event: E, handler: (payload) => void): () => void // returns unsubscribe; no separate off() destroy(): void } ``` ```ts import { createMasonryEngine } from '@macrulez/masonry-kit-core' const container = document.querySelector('#grid')! const engine = createMasonryEngine(container, { columns: 'auto', minLaneSize: 240 }) engine.setItems([ { id: 'a', el: document.querySelector('#card-a')! }, { id: 'b', el: document.querySelector('#card-b')!, colSpan: 2 }, ]) const unsubscribe = engine.on('layout', ({ items, visibleIds }) => console.log(items.length, visibleIds)) // later: unsubscribe(); engine.destroy() ``` - **Unconditionally sets `container.style.position = 'relative'`** — always overwrites, not gated on the computed position first (unlike some sibling packages in this ecosystem that only set it if the position was `static`). At `direction: 'vertical'`, also sets `container.style.overflowX = 'clip'` — items can briefly extend past the container's right edge while a lane-count change (a resize crossing a responsive breakpoint) is still FLIP-animating into its new position (width updates instantly, `transform` doesn't), and this keeps that transient overflow from becoming a page-level horizontal scrollbar. - `direction: 'horizontal'`: the engine sets `overflowX: 'auto'`/`overflowY: 'hidden'` on `container` and creates a 1px `aria-hidden` spacer element (moved via the same `transform` every item uses) to give the container a real `scrollWidth` — but **never sets the container's own CSS height**. You must give the container an explicit height yourself in `'horizontal'` mode; the engine only ever grows it in `'vertical'` mode (via `container.style.height`). - **No live `updateOptions()`** — options are captured once at `createMasonryEngine()` call time. To apply new options, destroy the old engine and create a new one. `optionsEqual(a, b)` (also exported from core) does a structural — not just referential — comparison of two `MasonryOptions` objects, for adapters deciding whether an `options` prop/argument change is a real content change worth recreating the engine for, or just an incidental new object reference with identical content. - **`colSpan`/`rowSpan` are silently clamped** to `[1, current lane count]` on every relayout — a span wider than the current lane count (e.g. on a narrow viewport) never overflows or errors. - **A connection between an item and its element is lazy**: `el` can be a function returning `HTMLElement | null`; an item with no resolvable element yet is skipped from measurement but still included in the pack order once it does resolve — it's never an error state. - **`resolveSsrColumns(spec, explicit)`** (also exported from core): resolution order is (1) explicit `explicit` (i.e. `MasonryOptions.ssrColumns`) always wins, (2) else if `spec` (i.e. `columns`/`rows`) is already a concrete `number` or `LaneBreakpoints` object, reused as-is (a breakpoints object resolves to its `default` value), (3) `'auto'`/unset falls back to a conservative `2`. **The engine itself never calls this** — only framework adapters do, before the engine exists, to render a CSS `columns` approximation during SSR and the one pre-measurement client frame. - **Packing algorithm** (`packLanes`, also structurally described here since it drives `placement`): `'balanced'` (default) is classic skyline packing generalized to spans — for every candidate starting lane a span could occupy, it computes the tallest edge among the lanes it would cover, and picks whichever starting lane minimizes that tallest edge; `'ordered'` instead assigns lanes via strict round-robin, wrapping the cursor back to 0 once it would exceed the lane count. - **`aspectRatio` is optional, not a workaround** — if an item's element is (or contains) an `` that hasn't finished loading, its `getBoundingClientRect()` isn't trusted for that pass (a not-yet-loaded `` with no known intrinsic size and no `aspect-ratio` CSS falls back to the browser's own 150px default-replaced-element height, which would otherwise read as a valid-but-wrong measurement). Without `aspectRatio`/`estimatedSize`, such an item is simply excluded from packing — not shown, not mis-sized — until the image fires `load`/`error`, at which point the engine relayouts on its own. A gallery of images with no size hints therefore renders correctly, just with cards popping in one at a time as each image finishes loading, instead of settling immediately; give `aspectRatio` (or size the `` itself via `width`/`height` attributes or CSS `aspect-ratio`) for an accurate first paint instead of that pop-in. - **Virtualization** (full detail in the site docs' Virtualization page): with `virtualize` on, an item's main-axis size is resolved through a chain, in order: (1) a real measurement if it currently has a mounted element, (2) the last known real measurement if it was measured before and has since scrolled out of view, (3) `MasonryItemDescriptor.estimatedSize`, (4) `MasonryItemDescriptor.aspectRatio`, (5) `MasonryOptions.estimateSize(item)`, (6) a guessed fallback (average of what's been measured, or the item's own cross size) — this last path logs exactly one `console.warn`. - **Animation** (full detail in the site docs' Animations page): FLIP-animates `transform` via inline styles, `opacity`-only fade in/out (no `scale()` — it was tried and dropped because it distorts the gap math), toggles `.mk-item-enter`/`.mk-item-moving`/`.mk-item-leave` classes, and sets `--mk-transition-duration`/`--mk-transition-easing` CSS custom properties on the container (informational only — core's own logic never reads these variables back). Core fades a removed item's element to `opacity: 0` but does NOT delay its actual DOM removal — that's the adapter's job (see section 4/5's `leaving` mechanism). --- ## 4. `@macrulez/masonry-kit-vue` (own exports — also re-exports all of section 2–3) ### 4.1 `` ```ts interface MasonryGridItem { // MasonryItemDescriptor minus `el` — the component owns the wrapper element itself id?: string // auto-generated (stable per item object reference) if omitted — see the id bullet below colSpan?: number rowSpan?: number aspectRatio?: number estimatedSize?: number order?: number } // Props: items: MasonryGridItem[] (required), options: MasonryOptions (default {}), sortable: boolean (default false) // Emits: layout(items: MasonryItemLayout[]), reorder(items: MasonryGridItem[]) // Slots: item — scope { item: MasonryGridItem }, one static slot name (not per-id dynamic) ``` ```vue ``` - Renders one wrapper `
` per item, plus a root `
`. Before the engine's first `layout` event, the root gets `style="columns: ; column-gap: "` (the SSR CSS-columns fallback) and each wrapper gets `break-inside: avoid; margin-bottom: ` — this is entirely replaced (not merged) by the real `position/transform` layout the instant the first `layout` event fires. - **`options` is watched with `{ deep: true }`, but a changed reference is only acted on if `optionsEqual()` says the content actually differs** — the engine is torn down (`destroy()`) and recreated from scratch on a real change. An unrelated re-render that produces a fresh `{ columns: 'auto' }` object literal every time does NOT recreate the engine. - **`items` sync is batched into a microtask** via item ref-callbacks — mounting/unmounting several item wrappers in the same Vue patch results in one `setItems()` call, not one per item. - **`sortable` never mutates `items`.** It only emits `reorder` with a new array; the caller's own `@reorder="items = $event"` (or equivalent) is what actually applies the new order — same pattern as a controlled `v-model`. - **Removed items stay mounted for `transitionDuration` ms** after disappearing from `items` (tracked via an internal `leaving` map), specifically so core's leave fade-out gets to finish playing instead of being cut short by an immediate unmount. This only happens when `animate` resolves to `true` (via `options.animate ?? masonryDefaults.animate ?? true`). - SSR-safe: the engine is only created inside `onMounted`, guarded by `typeof window === 'undefined'`. No `` needed. - **`id` is optional.** When omitted, a stable id is generated once per item *object* (a `WeakMap` keyed by the item itself, not by array position) — the same object keeps the same id across re-renders as long as your own state keeps the same reference. Re-mapping/cloning the items array into fresh objects on every render defeats this (each clone gets a new generated id) — give those an explicit `id` instead. ### 4.2 `useMasonry(container, options?)` ```ts interface RefFriendlyMasonryItem extends Omit { el: MaybeRefOrGetter } interface UseMasonryOptions extends MasonryOptions { items?: MaybeRefOrGetter } interface UseMasonryReturn { engine: ShallowRef } function useMasonry( container: MaybeRefOrGetter, options?: UseMasonryOptions, ): UseMasonryReturn ``` - `engine.value` is `null` until `onMounted` AND `toValue(container)` resolves to a real element. - If `options.items` is passed, it's synced via a `watchEffect` (not a plain `watch`) so that an item's `el` ref read via `toValue()` inside that callback is itself tracked — a ref/getter starting `null` (unmounted template ref) resolves correctly once it mounts, even though the surrounding `items` array never changed identity. Omit `items` entirely to call `engine.value.setItems(...)` yourself. - `engine.value.destroy()` is called automatically in `onBeforeUnmount`. - **No `sortable`/no `reorder` here** — that's ``-only. Use `engine.value.setDragging(id)` directly if you're building keyboard/pointer reordering on top of this low-level hook yourself. ### 4.3 `masonryDefaults` / `setMasonryDefaults(overrides)` ```ts type MasonryDefaults = Pick const masonryDefaults: MasonryDefaults // starts as {} — every field falls through to core's own default until set function setMasonryDefaults(next: Partial): void // Object.assign into the shared module-level object ``` **The `Pick` list does NOT include `virtualize`, `scrollContainer`, or `estimateSize`.** These three cannot be set through `masonryDefaults`/ `setMasonryDefaults()`, and therefore also not through the Nuxt module's config (section 6 — identical gap). Pass them directly in every `options`/`useMasonry()` call instead (see gotcha #1). --- ## 5. `@macrulez/masonry-kit-react` (own exports — also re-exports all of section 2–3) ### 5.1 `` ```ts interface MasonryGridItem { // identical shape to the Vue adapter's, including the optional/auto-generated id id?: string; colSpan?: number; rowSpan?: number; aspectRatio?: number; estimatedSize?: number; order?: number } interface MasonryGridProps { items: MasonryGridItem[] options?: MasonryOptions sortable?: boolean onLayout?: (items: MasonryItemLayout[]) => void onReorder?: (items: MasonryGridItem[]) => void // called instead of mutating items — same idea as Vue's `reorder` emit children: (item: MasonryGridItem) => ReactNode // REQUIRED, a render-prop function, not JSX children } function MasonryGrid(props: MasonryGridProps): ReactNode ``` ```tsx import { useState } from 'react' import { MasonryGrid } from '@macrulez/masonry-kit-react' function Gallery() { const [items, setItems] = useState([{ id: 'a' }, { id: 'b', colSpan: 2 }]) return ( {(item) => } ) } ``` - **`children` is a function, not JSX nodes.** `{someNode}` is a type error, not a smaller variant of the API — it must be `{(item) => someNode}`. - **`options` is internally stabilized** (`useStableOptions`, structural comparison via `optionsEqual`) — you do NOT need to `useMemo` the `options` object yourself for `` specifically (unlike the raw `useMasonry` hook below, where you do — see gotcha #2 for why this asymmetry exists). - Same behavior as the Vue component otherwise: SSR CSS-columns fallback swapped in one frame, removed items kept mounted for `transitionDuration` to let the leave fade play, `sortable` never mutates `items` (calls `onReorder` instead). - Uses `useIsomorphicLayoutEffect` internally (`useLayoutEffect` on the client, `useEffect` on the server, since `useLayoutEffect` warns if it runs server-side) — you don't need to do anything special for SSR yourself. - **`id` is optional**, same auto-generation behavior as the Vue adapter (4.1's `id` bullet) — a `WeakMap` keyed by item object reference, stable as long as your `items` array keeps the same object per entry across re-renders. ### 5.2 `useMasonry(container, options?)` ```ts interface UseMasonryItem extends Omit { el: HTMLElement | null | undefined // plain value, NOT a ref/getter — see gotcha #2 } interface UseMasonryOptions extends MasonryOptions { items?: UseMasonryItem[] } interface UseMasonryReturn { engine: MasonryEngine | null } function useMasonry(container: HTMLElement | null, options?: UseMasonryOptions): UseMasonryReturn ``` ```tsx import { useState, useMemo } from 'react' import { useMasonry } from '@macrulez/masonry-kit-react' function Board() { const [containerNode, setContainerNode] = useState(null) const [cardAEl, setCardAEl] = useState(null) // BOTH container (via setContainerNode/useState below) and options (via useMemo // here) must be stable references — see gotcha #2. A `useRef`-backed container // or a fresh inline `options` object literal every render would tear down and // recreate the engine on every single render. const options = useMemo( () => ({ columns: 'auto' as const, items: [{ id: 'a', el: cardAEl }] }), [cardAEl], ) const { engine } = useMasonry(containerNode, options) return (
A
) } ``` - **`container` and `options` are compared BY REFERENCE, not by content** — this is the single biggest divergence from the Vue composable. Core has no live `updateOptions()`, so any reference change (including an incidentally-fresh-but-identical object) tears down and recreates the engine. - `container`: pass a `useState`-backed value (`const [node, setNode] = useState(null)`, `ref={setNode}`), never a plain `useRef` — a ref's `.current` mutation doesn't itself trigger React to re-run this hook's effect, so the engine would never actually attach. - `options`: wrap in `useMemo` unless it's already a module-level constant. - `options.items`' `el` field is a plain `HTMLElement | null | undefined`, not a ref/getter (contrast with the Vue composable's `MaybeRefOrGetter`) — drive it from `useState` (a callback ref), for the same by-reference reason `container` needs `useState`: a `useRef`-stored element wouldn't re-trigger the item-sync effect when it actually mounts. - Items with a currently `null`/`undefined` `el` are filtered out before being passed to `engine.setItems()`. - **No `sortable` here either** — same as the Vue `useMasonry`, that's ``-only. --- ## 6. `@macrulez/masonry-kit-nuxt` Not an export surface — a Nuxt module (`configKey: 'masonry'`). ```ts // nuxt.config.ts export default defineNuxtConfig({ modules: ['@macrulez/masonry-kit-nuxt'], masonry: { // ModuleOptions — same field set as MasonryDefaults (4.3), same gap noted there direction?: 'vertical' | 'horizontal' columns?: LaneSpec rows?: LaneSpec minLaneSize?: number gap?: number | { main?: number; cross?: number } placement?: 'balanced' | 'ordered' animate?: boolean transitionDuration?: number transitionEasing?: string ssrColumns?: number }, }) ``` - **Auto-imports/registers**: `` as a global component (`addComponent`), `useMasonry` as an auto-import (`addImports`), both sourced from `@macrulez/masonry-kit-vue` — core-level exports (`createMasonryEngine`, `resolveSsrColumns`, `optionsEqual`) are NOT separately auto-imported, even though `-vue` re-exports all of them; import those explicitly if you need them directly in a Nuxt app. - No `defaults` object passed to `defineNuxtModule()` — deliberate, so an unset `ModuleOptions` field stays `undefined` through `runtimeConfig.public.masonry` → `setMasonryDefaults()` → falls through to core's own `params`-level default. - **The plugin is universal (runs on BOTH server and client), unlike some sibling packages in this ecosystem whose Nuxt plugins are client-only.** This is deliberate and load-bearing: `ssrColumns` is read directly by ``'s render function, which itself runs during SSR — a client-only plugin would seed the defaults too late for the very first server-rendered output to use them. --- ## 7. Consolidated gotcha list Cross-cutting facts most likely to produce subtly wrong generated code if missed — each is explained in full where it first applies above, listed here for a fast pre-flight check: 1. **`virtualize`, `scrollContainer`, and `estimateSize` cannot be set as shared defaults from Vue or Nuxt** — `MasonryDefaults`'s `Pick<...>` (4.3) and the Nuxt module's `ModuleOptions` (section 6) both omit these three fields. Pass them directly in every `options`/ `useMasonry()` call, or call `createMasonryEngine()` from `-core` directly if you need one true app-wide default for them. 2. **React's raw `useMasonry()` hook compares `container`/`options` BY REFERENCE; `` (React) and both Vue APIs compare `options` structurally instead.** Using the React hook directly without `useState` for `container` and `useMemo` for `options` is the single most likely mistake an agent will make porting a Vue example to React — see 5.2's full example. 3. **`direction: 'horizontal'` never gets a CSS height from the engine.** You must set one yourself on the container; the engine only sets `overflow-x: auto`/`overflow-y: hidden`, and only grows the container's own height in `'vertical'` mode. 4. **`children` (React ``) is a function, not JSX nodes** — `{node}` is wrong; it must be `{(item) => node}` (5.1). 5. **`sortable` never mutates the items array on its own, in either framework** — Vue emits `reorder`, React calls `onReorder`; both require the caller to apply the new array to their own state. The same is true at the raw engine level: `setDragging(id)` only excludes an item from packing, it never reorders anything by itself. 6. **`ssrColumns` is read only by framework adapters, never by the engine itself.** Setting it on a raw `createMasonryEngine()` call with no adapter in front of it has zero effect on anything the engine does — it only matters if something (an adapter) actually calls the separately-exported `resolveSsrColumns()` helper. 7. **A changed `options` reference does not necessarily recreate the engine** in the Vue/React component APIs (structural comparison via `optionsEqual`) — but it DOES in the raw React `useMasonry()` hook (reference comparison). The same "did anything really change" check behaves differently depending on which of the four APIs you're using. 8. **`colSpan`/`rowSpan` are auto-clamped, never an error** — a span wider than the current lane count (e.g. `colSpan: 3` with only 2 lanes on a narrow viewport) is silently capped to the lane count. 9. **The estimate-size fallback chain has a specific priority order** under `virtualize`: a real/last-known measurement beats `estimatedSize`, which beats `aspectRatio`, which beats `MasonryOptions.estimateSize()`, which beats a guessed average (with one `console.warn`). Don't assume `aspectRatio` alone is enough once `virtualize` is on — `estimatedSize` silently overrides it if both are set. 10. **Core fades a removed item's opacity to 0 but never delays its actual DOM removal** — that's why both `` implementations keep a removed item's wrapper mounted for `transitionDuration` ms themselves (tracked in a `leaving` map/state) after it disappears from `items`/`props.items`. A hand-rolled low-level integration via `useMasonry`/`createMasonryEngine` directly needs to replicate this delay itself if it wants the leave fade to actually play. 11. **``'s auto-generated `id` (4.1/5.1) is keyed by item *object reference*, not by content or array position.** Re-mapping the items array into fresh objects every render (`items.map(d => ({...d}))`, a common pattern with derived/computed data) gives every item a brand-new generated id on every render, breaking measurement caching, FLIP identity, and `sortable` reordering. Give those items an explicit `id` from your own data instead of relying on auto-generation. `MasonryItemDescriptor.id` at the raw core level (section 2) was never optional and isn't affected by this — only the two `` components' own `MasonryGridItem.id` is.