Projection

projectLayout(layout, targetCanvas, options) returns a new layout whose items keep their relationships (rows, columns, alignment, fixed sizes) while filling the target canvas. It is the function that makes a pixel layout responsive.

import { projectLayout, type GridLayout } from 'gridla'

declare const authored: GridLayout // authored at 1200 × 720

const rendered = projectLayout(authored, { width: 900, height: 540 }, { gap: 12 })

targetCanvas is a Partial<GridCanvas>; missing fields come from the source canvas. Two engines are available through options.strategy.

Chain (default)

Items that overlap on the perpendicular axis form a chain: items sharing vertical extent form a horizontal chain, items sharing horizontal extent a vertical one. Within a chain:

  • fixed-axis items keep their pixel size;
  • gaps equal to options.gap (within 2px, as are touching edges) stay exactly that size;
  • empty space scales with the canvas;
  • free items absorb the remainder proportionally to their authored size.

A second pass restores configured gaps, anchors fixed items that touch a canvas edge to that edge, and snaps edges that were identical in the source to identical values in the target, so aligned items stay aligned after rounding.

source · 960

rail · 240 fixed

chart · 456

note · 240

target · 640

240

chart · 242

134

A horizontal chain projected from 960px to 640px with a 12px gap. The fixed rail keeps 240px; the two free items share what is left in proportion.

The chain engine is what nested layouts use to render each container, and what GridProvider uses on every measurement. It costs roughly two to three times the segment engine on the same input (see performance).

Lower-level pieces are exported for custom pipelines: scaleItems (fractional flex projection), preserveGaps (the second pass; mutates in place), projectItemsByChain (both together), projectFloatingRect (ratio-scale a rect that is not part of any chain), roundItemRects, and syncFixedDimensions.

Segments

Every distinct item edge along an axis becomes a stop. The segments between stops are either fixed (empty, or covered only by fixed-size items) or flexible. Flexible segments scale to absorb the difference between the source and target span; fixed segments keep their pixels. A normalization pass then resolves any overlaps that per-item clamping introduced.

import { projectLayout, type GridLayout } from 'gridla'

declare const sparse: GridLayout

const projected = projectLayout(sparse, { width: 640 }, { strategy: 'segments' })

Segments are simpler, cheaper, and work well for sparse layouts where items do not form obvious rows and columns. They do not take a gap option; authored spacing scales with everything else unless it sits between fixed items. The engine also writes projected minW/maxW/minH/maxH onto each item so constraints scale with the canvas.

Properties you can rely on

  • Identity at the same size. Projecting a layout onto a canvas equal to its own returns the same geometry (clamped to bounds). This is what lets a committed rendered layout become the next source without drift.
  • Padding is preserved in pixels. Both engines work inside the padded area and translate back.
  • Locked items are respected by the chain engine when redistributing space; they are still scaled when the whole chain must fit.
  • Scrollable canvases grow. Projection alone does not change height; call fitCanvasToContent(canvas, items) afterwards (the React provider does) to extend a scrollable canvas below its last item.

Choosing a target size

Measure the element and round to whole pixels. Projecting on every ResizeObserver callback is fine at typical item counts: 128 items project in about 1.6ms with chains and 1ms with segments on a desktop CPU. For very large layouts, throttle to animation frames.

demo · responsive-projectionOpen full size