outlay computes Flexbox and CSS Grid box positions and sizes off the DOM, in pure synchronous JavaScript with no WASM or native binaries. It matches Chromium at 0.5px tolerance across 4,450 generated fixtures that span the supported CSS subset (see the compatibility matrix). It does for CSS layout what Pretext does for text measurement: it lifts a DOM-dependent computation out into standalone arithmetic.
That combination of CSS Grid, Chromium fidelity, and pure synchronous JavaScript doesn't exist anywhere else:
| outlay | Yoga (yoga-layout) |
Taffy (taffy-layout) |
Satori (HTML → SVG) | Takumi (HTML → image) | jsdom | Headless Chromium / Playwright | |
|---|---|---|---|---|---|---|---|
| CSS Grid | ✅ | ❌ | ✅ | ❌ | ✅ (Taffy) | ❌ computes no layout | ✅ |
| Flexbox | ✅ | ✅ | ✅ | ✅ (Yoga) | ✅ | ❌ | ✅ |
| Pure JS, no WASM | ✅ | ❌ WASM | ❌ WASM | ❌ Yoga WASM | ❌ Rust (native/WASM) | ✅ | ❌ |
| Synchronous | ✅ | ❌ async init | ❌ async init | ❌ | ❌ async API | ✅ | ❌ |
| Matches browser layout | ✅ 0.5px, fixture-verified | ❌ own model | ❌ own model | ❌ | ❌ own model (Taffy) | ❌ every box is 0×0 | ✅ it is the browser |
outlay returns inspectable layout boxes, each carrying a position, size, box-model edges, and a baseline. You feed those boxes to whatever consumes them, whether that's an SVG or canvas scene graph, a PDF or report generator, a diagramming or editor surface, or layout assertions in a test. outlay is the layout layer rather than a full image renderer: Takumi and Satori handle raster output, image loading, and font shaping, and they reach for a Rust, WASM, or native engine to do it. outlay's contribution is narrow but unique: Chromium-matched Flexbox and CSS Grid as plain synchronous JavaScript, with no engine to instantiate.
Headless rendering is one such use. You solve a Grid, paint the boxes to SVG, and get an OG image or a server-rendered card with no browser in the loop. The card below is a non-uniform fr Grid with row and column spans and text wrapped at the fr-resolved width, solved and painted headlessly in about 3ms (see the source and Headless rendering below):
npm install outlayThe package ships as ES modules only, with no CommonJS build.
import { solveLayout } from "outlay";
const root = {
id: "root",
width: 400,
height: 200,
children: [
{ id: "sidebar", width: 100 },
{ id: "main", flexGrow: 1 },
],
};
const { boxes } = solveLayout(root);
boxes.get("sidebar"); // { x: 0, y: 0, width: 100, height: 200, ... }
boxes.get("main"); // { x: 100, y: 0, width: 300, height: 200, ... }Every field is optional. Nodes without an id get a collision-safe auto id, and
results are also keyed by the input node objects themselves:
const sidebar = { width: 100 };
const { nodes } = solveLayout({ width: 400, height: 200, children: [sidebar] });
nodes.get(sidebar); // same ResolvedBox, no ids involvedIf a layout already exists in your head as markup, outlay/html converts an
HTML snippet with inline styles into a LayoutNode tree:
import { parseHTML } from "outlay/html";
const tree = parseHTML(`
<div style="display: flex; width: 400px; height: 200px; gap: 8px">
<div id="sidebar" style="width: 100px"></div>
<div style="flex: 1"></div>
</div>
`);
const { boxes } = solveLayout(tree);The converter is strict by design: anything it accepts maps 1:1 onto the
supported vocabulary, and anything else (percentages, em, calc(), classes,
unknown properties, text content) throws an HTMLParseError naming the
offending declaration and element. (Text content and paint styles are accepted
by the outlay/render wrapper, which turns the same
markup into a finished SVG.) Two deliberate mappings are worth knowing: elements
without a display declaration become outlay's default (flex, not CSS's
block), and elements without box-sizing get an explicit "content-box" (the
browser default) so converted trees match what the markup renders. Treat it as a
porting and experimentation tool; for programmatic layout, build LayoutNode
trees directly.
The demos are live at alechemy.github.io/outlay. They include a layout explorer that renders the solver next to real browser CSS with a live match badge, plus animated transitions, drag-and-drop reorder, virtual scrolling, text-driven layout, a nested dashboard with a solver-vs-native toggle, and the headless OG-image generator behind the card above. To run them locally, start npm start and open /demos/index.html.
| Property | Default |
|---|---|
display |
"flex" |
boxSizing |
"border-box" |
padding |
0 |
margin |
0 |
border |
0 |
children |
[] |
flexGrow |
0 |
flexShrink |
1 |
padding, margin, and border accept a single number (uniform) or a partial { top?, right?, bottom?, left? } object. Unspecified sides default to zero.
root:LayoutNode— the tree to solveoptions.debug:boolean— when true, returns atraceobject with intermediate algorithm state (flex phases plus per-container grid track sizes, offsets, and placements)- Returns:
boxes: Map<string, ResolvedBox>— keyed by node idnodes: Map<LayoutNode, ResolvedBox>— the same boxes keyed by the input node referencescontentSize: { width, height }— the union extent of all border boxes (the scrollable size; e.g. total content height for a virtual scroller)
The solver itself never validates; it assumes a well-formed tree and stays fast. validateTree is the development-time companion. Run it in tests or behind a dev flag to catch the mistakes that would otherwise produce a silently wrong layout:
- errors: input outside the supported vocabulary — duplicate
ids (result boxes are keyed by id), percentage or CSS-string sizes ("50%","100px"), unsupported enum values, malformed track lists or grid placements,NaNdimensions - warnings: supported input that hits a documented divergence from browser CSS — a
display: "block"container with no definite height (resolves to content-height 0), vertical margins in block flow (no margin collapse),baselinealignment in grid (treated asstart),flexBasis: "content"with a definite main size (treated as"auto", so the size wins),orderon a grid child (ignored; grid places in document order), fit-content boundary cases (afit-contenttrack insiderepeat(), or afit-contentcross size in a wrap container), an aspect-ratio item that may land in an implicit stretched auto track, plus typo detection for unknown property names ("flex-direction"→flexDirection)
Each issue carries { nodeId, path, severity, message }, where path locates the node (e.g. root.children[2]).
import { solveLayout, validateTree } from "outlay";
if (process.env.NODE_ENV !== "production") {
const issues = validateTree(tree);
if (issues.length > 0) throw new Error(issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
}
const { boxes } = solveLayout(tree);interface LayoutNode {
id?: string; // auto-assigned when omitted
// Box model
width?: number | "auto" | "min-content" | "max-content" | "fit-content" | `${number}%`;
height?: number | "auto" | "min-content" | "max-content" | "fit-content" | `${number}%`;
minWidth?: number | "min-content" | "max-content";
maxWidth?: number | "min-content" | "max-content";
minHeight?: number | "min-content" | "max-content";
maxHeight?: number | "min-content" | "max-content";
aspectRatio?: number; // width / height, applied to the box-sizing box
padding?: number | Partial<BoxSides>;
margin?: number | Partial<MarginBoxSides>; // supports "auto" per-side
border?: number | Partial<BoxSides>;
boxSizing?: "content-box" | "border-box";
// Flex container
display?: "flex" | "grid" | "block" | "none";
flexDirection?: "row" | "column" | "row-reverse" | "column-reverse";
flexWrap?: "nowrap" | "wrap" | "wrap-reverse";
justifyContent?:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly";
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
alignContent?:
| "flex-start"
| "flex-end"
| "center"
| "stretch"
| "space-between"
| "space-around"
| "space-evenly";
gap?: number | { row: number; column: number };
// Flex item
flexGrow?: number;
flexShrink?: number;
flexBasis?: number | "auto" | "content" | `${number}%`;
alignSelf?:
| "auto"
| "flex-start"
| "flex-end"
| "center"
| "stretch"
| "baseline";
order?: number;
// Positioning
position?: "static" | "relative" | "absolute" | "fixed";
top?: number;
right?: number;
bottom?: number;
left?: number;
// Content
children?: LayoutNode[];
measureContent?: (availableWidth: number) => {
width: number;
height: number;
};
}interface ResolvedBox {
id: string;
parentId?: string; // input-tree parent (undefined for the root)
x: number; // border-box position relative to root's content-box origin
y: number;
width: number; // content-box dimensions
height: number;
padding: BoxSides;
border: BoxSides;
margin: BoxSides; // includes resolved "auto" margins
borderBoxWidth: number;
borderBoxHeight: number;
outerWidth: number; // borderBoxWidth + margin left + right
outerHeight: number;
baseline?: number; // border-box top to first baseline
}This callback is for leaf nodes whose content size is determined externally, such as text. The solver calls it with the available content width, and it returns the intrinsic { width, height } of the content at that width. The solver probes it at 0 (min-content) and Infinity (max-content) as well as at resolved widths, so return the real content width, not the available width; grid track sizing and min-width: auto floors depend on it.
See Text for the Pretext adapter.
The engine does no line breaking itself. A text leaf is a node with a measureContent callback that reports the wrapped { width, height } for a given available width. The solver measures each text item at its resolved width (the flex main size, or the grid column width once tracks resolve), so wrapped heights drive flex cross sizes and grid auto rows, and the widest word (measureContent(0).width) floors min-width: auto and intrinsic tracks.
Any measurer that honors that contract works, and two ship with the package:
In a browser or worker, outlay/pretext wraps Pretext, an optional peer dependency you install with npm install @chenglou/pretext:
import { text } from "outlay/pretext";
const label = text("The quick brown fox", { font: "16px Arial", lineHeight: 20 });
// → a LayoutNode leaf; spread extra props: text("…", { font, lineHeight, flexGrow: 1 })text() runs Pretext's one-time canvas measurement pass, so it needs an OffscreenCanvas or DOM canvas and throws in bare Node.
In Node (tests, servers), outlay/text provides the same greedy line breaker the fixture suite verifies against Chromium, driven by precomputed per-word advances:
import { measureFromWordWidths, textNode } from "outlay/text";
const measure = measureFromWordWidths("The quick brown fox", wordMetricsTable);
const label = textNode(measure, { id: "label" });Capture the advances once (in a browser, or from font metrics) and commit them; measureFromAdvances takes a raw number[] if you manage words yourself.
From a font file, outlay/font parses TTF/OTF metrics directly, so no browser is needed even for the capture step:
import { readFileSync } from "node:fs";
import { parseFont, measureText } from "outlay/font";
import { textNode } from "outlay/text";
const inter = parseFont(readFileSync("Inter-Regular.ttf"));
const label = textNode(
measureText(inter, "The quick brown fox", { size: 16, lineHeight: 20 }),
);The font parser has no dependencies of its own; it reads the head, cmap, hhea, and hmtx tables straight out of a DataView. Advances are unshaped and unkerned: they are the per-glyph sums Chromium produces with font-kerning: none and ligatures disabled, verified against Chromium to 0.015px. With default shaping the browser comes out narrower on kerned pairs such as AV and To, so use captured advances or Pretext when that matters.
Things to keep in mind:
- Match the CSS wrapping mode. Pretext models
overflow-wrap: break-word(it breaks inside long words at narrow widths), so render the same text withoverflow-wrap: anywherefor the browser to agree at widths narrower than a word. Withoverflow-wrap: normalthe min-content is the widest word instead. - Quantize like the engine for exact agreement. Chromium stores accumulated line widths and intrinsic text widths as LayoutUnit (1/64px, floored), so at knife-edge widths a word fits where raw float accumulation says it doesn't. Both shipped measurers apply this quantization; if you write your own, floor the running line width to 1/64 before each fit comparison and floor the widths you return. The solver itself stays quantization-free; the contract lives entirely in the
measureContentimplementation.
The pages/demos/text-layout.html demo wires this adapter into a live card grid and checks the solver against the browser at 0.5px tolerance.
Solving positions is only half the job; outlay/svg paints them. renderToSvg
takes a solved tree and a per-node style callback (fills, strokes, rounded
corners, and line-broken text) and returns an SVG string:
import { solveLayout } from "outlay";
import { renderToSvg } from "outlay/svg";
const result = solveLayout(tree);
const svg = renderToSvg(tree, result, {
style: (node) => styles.get(node.id), // { fill?, stroke?, radius?, text? }
});Text paints through the same numbers it was measured with: breakLines (from
outlay/text) shares its quantized fit test with measureFromAdvances, so the
painted lines are exactly the lines the solver sized, and each line carries
textLength, so any SVG viewer reproduces the measured widths even when it
substitutes a fallback font.
The card at the top of this README is this pipeline end to end.
pages/demos/og-image/generate.ts builds a
grid tree, measures its text from a committed Inter TTF via outlay/font,
solves, and paints, all in about 3ms with no browser in the loop. That layout,
non-uniform fr columns with spans and wrapped text driving track sizes, is the
part Satori and Yoga can't express.
For debugging or documenting a layout, renderDebugSvg draws every resolved
box with depth colors, ids, and sizes, plus dashed grid-track outlines when
given a debug trace:
import { renderDebugSvg } from "outlay/svg";
const result = solveLayout(tree, { debug: true });
fs.writeFileSync("layout.svg", renderDebugSvg(tree, result, { trace: result.trace }));When the layout already exists as markup, outlay/render composes the whole
pipeline in one call, in the shape of Satori but Grid-capable:
import { readFileSync } from "node:fs";
import { htmlToSvg } from "outlay/render";
const svg = htmlToSvg(
`<div style="display: grid; width: 800px; grid-template-columns: 1fr 1fr; gap: 16px;
padding: 24px; background: #101321; border-radius: 16px;
font-family: Inter; font-size: 15px; line-height: 22px; color: #e6e9f2">
<div style="grid-column: 1 / span 2; font-size: 28px; line-height: 34px; font-weight: 700">Weekly report</div>
<div style="background: #171c2c; border-radius: 10px; padding: 16px">Signups grew 14% week over week.</div>
<div style="background: #171c2c; border-radius: 10px; padding: 16px">Median solve time held at 1.7ms.</div>
</div>`,
{ fonts: { Inter: readFileSync("Inter-Regular.ttf") } },
);Text properties (font-family, font-size, font-weight, line-height,
color, text-align) inherit down the tree like CSS, and the paint vocabulary
is solid backgrounds, border-radius, and border-color. Everything else keeps
parseHTML's strictness, so unsupported CSS throws with the element's path, and
htmlToLayout returns the intermediate { tree, styles } when you want to solve
or inspect before painting.
SVG is the supported output. Rasterizing to PNG (for OG images proper) takes any
SVG rasterizer, whether sharp, resvg, or a browser screenshot; outlay
deliberately stops at the vector.
Because solveLayout is synchronous and browser-free, you can assert a component's
layout (overlaps, overflow, breakpoint column counts) inside a plain unit test, and
sweep hundreds of viewport widths in a few milliseconds. The outlay/testing subpath
ships the primitives:
import { sweep, assertNoOverlaps, overflowsX } from "outlay/testing";
const widths = Array.from({ length: 49 }, (_, i) => 320 + i * 20); // 320…1280
const failures = sweep(widths, buildCardGrid, (result) => {
assertNoOverlaps(result, cardIds);
if (overflowsX(result, "page")) throw new Error("content crosses the page edge");
});
expect(failures).toEqual([]); // each failure names the width that brokesweep(widths, buildTree, invariant) solves buildTree(width) at every width and
collects the widths where invariant throws. assertNoOverlaps(result, ids?) throws on
the first overlapping pair (siblings only when ids is omitted); overflowsX /
overflowsY report whether any descendant escapes a container's border box.
jsdom computes no layout, WASM engines need async init, and Playwright is heavy for a
question as small as "does this overflow at 375px", so a synchronous solver fits. The
full worked example, with those primitives plus a Node-safe text measurer, a responsive
card grid, and a regression guard, is in
examples/layout-assertions/; run it with
npm run test:example.
The compatibility matrix states this precisely, sorting each behavior into verified, supported but unverified, an intentional divergence, or a non-goal. In brief:
- Flexbox layout (row, column, reverse, wrap)
flex-grow,flex-shrink,flex-basiswith iterative clamping- Percentage
width/height/flexBasison flex children, resolved against the containing block's content box — including parents sized by the solver itself (flex-grown, stretched, or auto-main containers, where percentages re-resolve once the main size is determined) justify-content: all 6 valuesalign-items/align-self(includingbaseline) /align-content(all 7 values)gap(single value and{ row, column }, including wrapped lines)min-width/max-width/min-height/max-heighton both axes, includingmin-content/max-contentkeywords (keyword heights assume width-independent content — see coverage boundaries)- Multi-line wrapping (
wrap,wrap-reverse) - Nested flex containers with indefinite size resolution
min-content/max-content/fit-contentintrinsic sizing on container widths/heights and on flex itemsaspectRatioon flex and grid items (transferred sizes, transferred automatic minimums, stretch precedence)- Width-dependent content via
measureContent(e.g. text): items are measured at their resolved main size, so wrapped heights drive cross sizes, and the widest word floorsmin-width: autoand feeds line breaking (see Text) display: blockcontainers with children, nested anywhere in a flex treeposition: absoluteandposition: fixedmargin: autocentering (both axes)display: noneorderproperty (flex)content-boxandborder-boxsizing
There is no overflow property, because outlay never paints or scrolls. The one layout-relevant effect of overflow: hidden in CSS, releasing a flex item's automatic minimum size so it can shrink past its content, is expressed with minWidth: 0 / minHeight: 0 on the shrinking child, exactly as in CSS flexbox practice.
CSS Grid (display: "grid"):
- Track sizing: fixed px,
fr(content-based minimums),auto,minmax(),min-content/max-content,fit-content(limit)(as{ fitContent: px }),repeat(fixed-count,auto-fill, andauto-fitwith empty-track collapse) - Placement: explicit lines (positive and negative),
span n, sparse auto-placement (rowandcolumnflow),densepacking - Implicit tracks via
gridAutoRows/gridAutoColumns gap(single value and{ row, column })- Alignment:
justifyItems/justifySelf,alignItems/alignSelf(flex vocabulary;flex-start/flex-endbehave asstart/end),justifyContent/alignContentdistribution, auto margins - Grid and flex compose: grid inside flex, flex inside grid, nested grids, including intrinsic sizing of nested grids
- Text in grid cells: auto rows sized by wrapped text at the resolved column width; text
measureContentfeeds min-content (widest word) and max-content (single line) track contributions
Grid exclusions for v1 are percentage tracks (the caller resolves them), named lines and grid-template-areas (the caller resolves them to line numbers), subgrid, masonry, and grid baseline alignment.
A few behaviors are accepted but sit outside the verified fixture set, so treat them with care: aspect-ratio items in implicit stretched auto tracks, fit-content tracks inside repeat(), fit-content cross sizes in a wrap container, and aspect-ratio combined with auto margins. validateTree flags the ones it can detect statically.
- No CSS parsing, cascade, or selector matching — the caller provides resolved values
- No raster output — the core solver produces boxes;
outlay/svgis a thin optional painter over the result, and PNG/PDF is the caller's rasterizer - No rich inline layout (bidi, shaping, mixed inline formatting) — word-level greedy breaking ships in
outlay/text/outlay/font; use Pretext for browser-grade measurement - No floats, no table layout
- No right-to-left direction or writing modes — layout is left-to-right,
horizontal-tb - No block-flow margin collapsing, and no auto-height for
display: blockcontainers (give block containers a definite height)
4,450 fixtures across 35 tiers pass, all measured against Chromium getBoundingClientRect() ground truth at a tolerance of 0.5px per property per node. That claim is scoped to the supported CSS subset, the vocabulary the generator emits, not to browser CSS as a whole. The compatibility matrix lays out what's verified, what's supported but unverified, where outlay intentionally diverges from browser CSS, and what's a deliberate non-goal.
| Tree size | Depth | Time |
|---|---|---|
| 100 nodes | 2 levels | ~0.06ms |
| 1,000 nodes | 3 levels | ~1.1ms |
| 10,000 nodes | 5 levels | ~14ms |
Measured on Apple Silicon. Run npm run bench to reproduce.
Every solveLayout call solves the whole tree from scratch; there is no incremental relayout or dirty-marking, so interactive callers at very large node counts should throttle solves or solve only the affected subtree.
MIT