bento-layout

Getting started

From install to a working text-measuring ASCII renderer, step by step.

In this tutorial you will build a small layout from nothing: a screen frame with a header, body, and footer, text that wraps and reports its own height, and — at the end — a tiny renderer that paints the whole tree in your terminal. Along the way you will use every core piece of the library: building a tree, laying it out, reading positions back, changing styles, and measuring content.

You need Node 18 or newer and a terminal. The tutorial takes about fifteen minutes. It assumes you know TypeScript and have met flexbox before; nothing else.

Set up

Create a project and install the library, plus tsx to run TypeScript directly:

mkdir hello-layout && cd hello-layout
npm init -y
npm install bento-layout
npm install -D tsx

There is no other setup — no loader, no async initialization, no engine to instantiate. Create a file called layout.ts; you will run it after each step with:

npx tsx layout.ts

First boxes

Put this in layout.ts:

import { LayoutNode, computeLayout } from 'bento-layout';

const left = LayoutNode.make({ flexGrow: 1 });
const right = LayoutNode.make({ flexGrow: 1 });
const root = LayoutNode.make({ width: 400, height: 300 }, [left, right]);

computeLayout(root, { width: 'max-content', height: 'max-content' });

console.log(left.layout.size);
console.log(right.layout.location);

Run it. You should see:

{ width: 200, height: 300 }
{ x: 200, y: 0 }

Three things just happened. LayoutNode.make(style, children) built a tree from plain style objects — camelCase CSS properties, numbers for pixels. computeLayout walked the tree and wrote a position and size into every node. And the layout getter read the result back: two flexGrow: 1 children split a 400px row evenly, so each is 200 wide and the second starts at x: 200.

The second argument to computeLayout is the space the root is laid into. 'max-content' means "no constraint — size to content"; since our root has an explicit width and height, it simply uses those.

Space: gaps and padding

Give the root a gap between its children and padding around them. Change the root line to:

const root = LayoutNode.make(
  { width: 400, height: 300, columnGap: 10, padding: 16 },
  [left, right],
);

Run again:

{ width: 179, height: 268 }
{ x: 205, y: 16 }

Every number moved. The padding pushed both children in by 16 (that is the y), the gap took 10 more from the middle, and the remaining 358px split into 179 each: 16 + 179 + 10 = 205.

padding: 16 is a shorthand: one value sets all four sides. An object form, padding: { top: 8, bottom: 8 }, would set only the sides it names. Positions are relative to the parent's border box — that is why x includes the padding but nothing outside the root.

An app frame

Now build something with a shape you will recognize: a fixed header and footer with a body that takes whatever is left. Replace the whole file:

import { LayoutNode, computeLayout } from 'bento-layout';

const header = LayoutNode.make({ height: 60 });
const body = LayoutNode.make({ flexGrow: 1 });
const footer = LayoutNode.make({ height: 40 });

const frame = LayoutNode.make(
  { flexDirection: 'column', width: 320, height: 480 },
  [header, body, footer],
);

computeLayout(frame, { width: 'max-content', height: 'max-content' });

console.log('header', header.layout.size);
console.log('body  ', body.layout.size, body.layout.location);
console.log('footer', footer.layout.location);
header { width: 320, height: 60 }
body   { width: 320, height: 380 } { x: 0, y: 60 }
footer { x: 0, y: 440 }

The body got 480 − 60 − 40 = 380 pixels — flexGrow: 1 claims the leftover space. All three children are 320 wide without asking: in a column, the cross axis is horizontal, and flex items stretch across it by default.

Here is the same frame drawn by the engine. Change the header's height, or give the footer flexGrow: 1 too, and watch the body give up its space:

<Layout>
<Node style={{flexDirection: 'column', width: '320px', height: '480px'}}>
  <Node style={{height: '60px'}} />
  <Node style={{flexGrow: 1}} />
  <Node style={{height: '40px'}} />
</Node>
</Layout>

Change a style and lay out again

Make the header taller. Add below the last console.log:

header.setStyle({ height: 96 });
computeLayout(frame, { width: 'max-content', height: 'max-content' });

console.log('body now', body.layout.size, body.layout.location);
body now { width: 320, height: 344 } { x: 0, y: 96 }

setStyle merges per property — it changed height and touched nothing else. Styles never take effect on their own: the next computeLayout call is what recomputes the tree, and each call is a full, from-scratch layout. Call it once per frame in which something changed, not once per mutation.

Text that measures itself

The engine positions boxes; it knows nothing about glyphs, images, or anything else inside a box. Leaves bridge that gap with a measure function: the engine calls it with the constraints it has resolved so far, and the function answers with a content size.

Add a fake text measurer — a fixed-width font, eight pixels per character:

import { LayoutNode, computeLayout, type MeasureFunction } from 'bento-layout';

const CHAR_W = 8;
const LINE_H = 18;
const text =
  'Layout without a browser: flexbox, grid, and block in plain TypeScript.';

const measureText: MeasureFunction = (known, available) => {
  const fullWidth = text.length * CHAR_W;
  const longestWord = Math.max(...text.split(' ').map((w) => w.length * CHAR_W));

  let width: number;
  if (known.width !== null) width = known.width;
  else if (available.width === 'min-content') width = longestWord;
  else if (available.width === 'max-content') width = fullWidth;
  else width = Math.min(available.width, fullWidth);

  const lines = Math.max(1, Math.ceil(fullWidth / Math.max(width, longestWord)));
  return { width, height: known.height ?? lines * LINE_H };
};

Read the two arguments in order. known is authoritative: a non-null axis has already been decided, so honour it — text given a definite width wraps to it. available matters for axes still open, and it is either a number (space remaining) or an intrinsic question: 'max-content' asks how wide the text would be on one line, 'min-content' asks for the narrowest workable width — the longest unbreakable word. Answering 'min-content' with the full one-line width is the classic mistake: the engine treats that answer as a hard floor, and the label refuses to shrink below it.

Attach it to a leaf inside the body:

const label = LayoutNode.make({ margin: 12 }).setMeasure(measureText);
body.appendChild(label);

computeLayout(frame, { width: 'max-content', height: 'max-content' });
console.log('label', label.layout.size, label.layout.location);
label { width: 296, height: 320 } { x: 12, y: 12 }

The width is right — 320 minus two 12px margins — but the height filled the whole body. That is not a bug: the body is a flex container, and a lone flex item stretches across the cross axis by default, exactly as in CSS. Both containers below hold the same 160×40 child; only the right one says alignItems: 'flex-start':

<Layout>
<Node style={{width: '420px', height: '140px', columnGap: '20px'}}>
  <Node style={{flexGrow: 1}}>
    <Node style={{width: '160px', height: '40px'}} />
  </Node>
  <Node style={{flexGrow: 1, alignItems: 'flex-start'}}>
    <Node style={{width: '160px', height: '40px'}} />
  </Node>
</Node>
</Layout>

Tell the body to align its items to the top the same way:

body.setStyle({ alignItems: { keyword: 'flex-start', safe: false } });
computeLayout(frame, { width: 'max-content', height: 'max-content' });
console.log('label', label.layout.size, label.layout.location);
label { width: 296, height: 36 } { x: 12, y: 12 }

Two lines of 18px — the text wrapped at 296px and reported its own height. (Alignment values are structured: CSS's safe center is one token, so here a keyword travels with its safe flag. The style reference lists every property's shape.)

Paint it

Positions in layout.location are relative to each node's parent, so a renderer is one recursive walk that accumulates offsets. Yours will paint boxes into a character grid — eight pixels per column, sixteen per row:

const SCALE = 8;

function paint(root: LayoutNode): string {
  const w = Math.ceil(root.layout.size.width / SCALE);
  const h = Math.ceil(root.layout.size.height / SCALE / 2);
  const grid: string[][] = Array.from({ length: h + 1 }, () =>
    Array(w + 1).fill(' '),
  );

  const draw = (node: LayoutNode, px: number, py: number): void => {
    const { location, size } = node.layout;
    const x0 = Math.round((px + location.x) / SCALE);
    const y0 = Math.round((py + location.y) / SCALE / 2);
    const x1 = Math.round((px + location.x + size.width) / SCALE) - 1;
    const y1 = Math.round((py + location.y + size.height) / SCALE / 2) - 1;

    for (let x = x0; x <= x1; x++) grid[y0][x] = grid[y1][x] = '─';
    for (let y = y0; y <= y1; y++) grid[y][x0] = grid[y][x1] = '│';
    grid[y0][x0] = '┌';
    grid[y0][x1] = '┐';
    grid[y1][x0] = '└';
    grid[y1][x1] = '┘';

    for (const child of node.children) {
      draw(child, px + location.x, py + location.y);
    }
  };

  draw(root, 0, 0);
  return grid.map((row) => row.join('')).join('\n');
}

console.log(paint(frame));
┌──────────────────────────────────────┐
│                                      │
│                                      │
│                                      │
│                                      │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ ┌───────────────────────────────────┐│
│ └───────────────────────────────────┘│
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
│                                      │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
└──────────────────────────────────────┘

There is your frame: tall header, the wrapped label sitting at the top of the body, footer at the bottom. You have written a renderer — the accumulate-and-draw walk is the same loop a canvas, SVG, or PDF back-end uses, with fillRect in place of box-drawing characters.

Where you are

You have used the whole core surface: LayoutNode.make, computeLayout, the layout getter, setStyle, appendChild, and setMeasure. That is not a starter subset — it is most of the API.

From here:

  • Measuring content — real text metrics, images, caching, and the min-content contract in depth.
  • Grid layouts — the second layout mode, with the structured track vocabulary.
  • Driving a renderer — rounding, paint order, scrolling content, and re-layout loops beyond this tutorial's sketch.
  • The API reference — every method, precisely.

On this page