Mental model

Gridla has three ideas. Everything else is a consequence of them.

1. A layout is data

import type { GridLayout } from 'gridla'

const layout: GridLayout = {
  canvas: {
    width: 960,
    height: 600,
    padding: { top: 0, right: 0, bottom: 0, left: 0 },
    heightMode: 'bounded',
  },
  items: [
    { id: 'chart', x: 0, y: 0, w: 640, h: 360 },
    { id: 'note', x: 652, y: 0, w: 308, h: 360, minW: 160 },
  ],
}

A GridLayout is a canvas and a list of items in canvas-relative pixel coordinates. There are no classes, no hidden state, no ids you did not choose. It serializes to JSON as-is, diffs with any structural comparison, and stores anywhere. data on an item is yours and passes through every function untouched.

2. Solvers are pure functions over that data

import { moveItem, type GridLayout } from 'gridla'

declare const layout: GridLayout

const result = moveItem({ layout, itemId: 'note', position: { x: 0, y: 0 }, options: { gap: 12 } })
result.accepted // true
result.strategy // 'push-x' — chart slid right to make room
result.layout // a new layout; `layout` is untouched

moveItem, resizeItem, placeItem, and transferItem take a layout plus an intent and return a SolveResult. The result is always a complete, independent copy. When the request cannot be honored, accepted is false and layout equals the input, so you never hold an invalid layout. The strategy names the rule that produced the result; the solver page lists them in the order they are tried.

Because solvers are pure, "preview during drag" and "commit on release" are the same call. Paint result.layout on every pointer move; keep the last accepted result on release; paint the previous layout to cancel.

3. Rendering is a projection

A layout is authored at one canvas size and rendered at another. projectLayout maps the authored layout onto the rendered size while keeping its structure: rows and columns stay rows and columns, fixed-size items keep their pixels, gaps stay exact, free space scales.

source layout (960 × 600)

rendered layout (measured)

projectLayout(source, size)

commit result.layout

moveItem / resizeItem / placeItem

The authored layout lives in its own coordinate space. Each render projects it onto the measured size; each commit writes the rendered coordinates back.

Solvers operate on the rendered layout, because that is what the pointer is over. After a commit, the rendered layout becomes the new source: it is a valid layout in its own right, and projecting it back onto the same size is the identity. This is exactly what GridProvider does; the vanilla quickstart does it by hand in a dozen lines.

What follows from this

  • No DOM in the core. Nothing in gridla reads an element, listens to an event, or schedules a frame. The core runs in Node and in workers. The React adapter is one consumer of it; a vanilla, Vue, or Svelte adapter is the same amount of code.
  • Nested layouts are the same model, applied recursively. A container's children live in the container's own layout; flattenLayout projects each container into the rectangle its parent gave it. See nesting.
  • Everything is inspectable. Because results carry the strategy and the full next layout, a debug overlay is a JSON.stringify away. onTrace in SolveOptions reports every solve for logging.
  • Constraints are per item, options are per call. minW, sizeMode, and policy travel with the item; gap, snapDistance, and snap are passed to each solver call. That keeps the layout portable and the behavior tunable.
demo · responsive-projectionOpen full size

Next