Headless flex layout engine for terminal UIs. Pure TypeScript, zero runtime dependencies.
Pilates is a flex layout engine designed for the terminal: integer cell coordinates, CJK / emoji / wide-char awareness, ANSI escape passthrough, and unbundled from any UI framework. Use it directly to compute layouts, or wrap the included renderer to produce styled strings.
import { render } from '@pilates/render';
process.stdout.write(
render({
width: 80,
height: 6,
flexDirection: 'row',
children: [
{ flex: 1, border: 'rounded', title: 'Logs', children: [{ text: 'user logged in' }] },
{ width: 20, border: 'single', title: 'Status', children: [{ text: 'ok', color: 'green', bold: true }] },
],
}),
);
// โญโ Logs โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎโโ Status โโโโโโโโโโ
// โuser logged in โโok โ
// โ โโ โ
// โ โโ โ
// โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏโโโโโโโโโโโโโโโโโโโโ
Terminal UIs in JavaScript are dominated by Ink, which couples two distinct concerns into one package: a WASM flex layout engine and a React reconciler. If you want the layout half, you have to take all of React. Pilates separates them:
@pilates/core โ the engine. Imperative Node API, returns integer cell
coordinates. Pure TypeScript, zero runtime dependencies. Handles
CJK / emoji / wide-char widths, integer-cell rounding, the CSS Flexbox
freeze loop, and absolute positioning. Validated cell-for-cell against a
reference WASM flexbox implementation across 33 oracle fixtures.@pilates/render โ the out-of-box renderer. Declarative POJO tree โ
painted ANSI string with borders, titles, colors, and text wrap. Uses core
internally; depends only on it.@pilates/diff โ cell-level frame diffing + minimal ANSI redraw
sequences for live TUIs. Pairs with @pilates/render.@pilates/react โ optional React reconciler on top of the same engine,
for consumers who want JSX and hooks. Independent of the core / render /
diff stack โ you don't pay for it if you don't import it.@pilates/widgets โ interactive widgets (TextInput, Select,
Spinner) built on @pilates/react. For wizard-style CLI flows.| Package | Status | What |
|---|---|---|
@pilates/core |
2.0.1 |
Engine: imperative Node API, returns layout boxes. |
@pilates/render |
1.0.2 |
Out-of-box: declarative tree โ painted string. |
@pilates/diff |
0.2.1 |
Cell-level frame diff + minimal ANSI redraw. |
@pilates/react |
0.4.1 |
React reconciler โ author terminal UIs with JSX, hooks, mouse, focus, scroll. |
@pilates/widgets |
0.1.0-rc.4 |
Interactive widgets (TextInput, Select, Spinner, MultiSelect, Tabs, Table, ProgressBar, TextArea) for @pilates/react. |
Eleven runnable examples live under examples/ โ six built
on the imperative @pilates/render API, five built on @pilates/react.
Imperative (@pilates/render):
| Example | What it shows |
|---|---|
| chat-log | Two-pane chat layout: scrolling messages + status sidebar. Wide-char & emoji passthrough. |
| dashboard | System-monitor layout: status header, four stat tiles in a row, metrics strip. |
| gallery | Grid of cards that wraps to multiple rows on a narrow container. |
| modal | Confirm-action modal floating over a list โ exercises absolute positioning. |
| progress-table | Multi-row progress dashboard with bars and color-coded status. |
| split-pane | Editor-style: header + 3-pane body (files / editor / outline) + status footer. |
React (@pilates/react + @pilates/widgets):
| Example | What it shows |
|---|---|
| react-build-dashboard | Flagship demo. Interactive build-pipeline dashboard: <ScrollView> ร 2, mouse, useFocus, keyboard nav, animation, <ProgressBar> + <Spinner> widgets, all stitched together. |
| react-counter | Minimal reconciler example: counter incrementing every 250ms, demonstrating the diff-based redraw loop. |
| react-dashboard | React port of dashboard with a live tick counter on the header. |
| react-modal | React port of modal: centered confirmation dialog over a scrollable list. |
| react-wizard | Multi-step TextInput โ Select โ Spinner wizard exercising every @pilates/widgets component. |
pnpm install
# imperative
pnpm --filter @pilates-examples/chat-log dev
pnpm --filter @pilates-examples/progress-table dev
# react
pnpm --filter @pilates-examples/react-counter dev
pnpm --filter @pilates-examples/react-wizard dev
# flagship
pnpm --filter @pilates-examples/react-build-dashboard dev
import { Node, Edge } from '@pilates/core';
const root = Node.create();
root.setFlexDirection('row');
root.setWidth(80);
root.setHeight(24);
root.setPadding(Edge.All, 1);
const main = Node.create();
main.setFlex(1);
const sidebar = Node.create();
sidebar.setWidth(20);
root.insertChild(main, 0);
root.insertChild(sidebar, 1);
root.calculateLayout();
main.getComputedLayout(); // { left:1, top:1, width:58, height:22 }
sidebar.getComputedLayout(); // { left:59, top:1, width:20, height:22 }
You'd then paint to the terminal yourself โ or pass the same shape via the
declarative API to @pilates/render to skip the painting:
import { render } from '@pilates/render';
process.stdout.write(
render({
width: 80,
height: 24,
flexDirection: 'row',
padding: 1,
children: [{ flex: 1 }, { width: 20 }],
}),
);
| Category | Properties |
|---|---|
| Direction | flexDirection (row / column / -reverse), flexWrap (nowrap / wrap / wrap-reverse) |
| Sizing | width, height, minWidth, minHeight, maxWidth, maxHeight |
| Flex | flex (shorthand), flexGrow, flexShrink, flexBasis |
| Spacing | padding / margin per edge, gap (row + column) |
| Alignment | justifyContent, alignItems, alignSelf, alignContent (all CSS values) |
| Position | positionType (relative / absolute), position per edge |
| Visibility | display (flex / none) |
| Render-only | border (5 styles), borderColor, title, color, bgColor, bold, italic, underline, dim, inverse, wrap |
Out of v1: aspectRatio, RTL/LTR direction inheritance, baseline alignment,
input handling, animations, scroll containers, style inheritance.
Pure-TypeScript layout, validated cell-for-cell against WASM Yoga.
Across the 9 scenarios in our bench suite, the pure-TS engine is
faster than WASM Yoga on each โ including the structural-mutation
workload (append + remove a row per frame) Yoga led on through
mid-2026. Numbers are median latency from pnpm bench (Node 22,
win32-x64, ~5s tinybench window with bootstrap CI95; a hand-picked
suite, not a universal claim โ real workloads will differ):
| Scenario | Pilates core | yoga-layout (WASM) | Pilates speedup |
|---|---|---|---|
| tiny (10 nodes) | 4.5ยตs | 19.0ยตs | 4.2ร faster |
| realistic (~100) | 121ยตs | 328ยตs | 2.7ร faster |
| stress (~1000) | 601ยตs | 1.94ms | 3.2ร faster |
| big (~5000) | 3.32ms | 9.17ms | 2.8ร faster |
| huge (~10000) | 8.62ms | 18.5ms | 2.1ร faster |
| hot-relayout (1k persistent, mutate one leaf/frame) | 16.3ยตs | 83.0ยตs | 5.1ร faster |
| hot-relayout + boundaries (same + explicit-sized rows) | 15.8ยตs | 77.8ยตs | 4.9ร faster |
| hot-relayout (text mutation, fixed-size table) | 8.9ยตs | 90.6ยตs | 10ร faster |
| hot-structural (append + remove a row / frame) | 71.3ยตs | 118.3ยตs | 1.7ร faster |
The hot-relayout and hot-structural patterns โ building a tree once and mutating-and-relaying out per frame โ are the workloads Yoga's WASM compute advantage traditionally won on. The Spineless incremental layout engine (an attribute-grammar dependency graph + priority-queue recomputation; refined through phases 8โ17 with a typed-array runtime, linear-recurrence main-axis positions, and fold-default input elimination) flips that: a single leaf mutation re-evaluates only the fields actually downstream of the change, and structural mutations patch only the affected subtree.
For trees of pure fixed-size cells (e.g. a data table with one cell's
text length changing per frame), the direct @pilates/core (spineless)
runtime mutation goes through in ~0.2ยตs โ 380ร faster than the
Yoga round-trip. That path is @internal for now; the public
calculateLayout ships the engine and is what every other Pilates
consumer uses.
WASM Yoga's compute kernel is genuinely fast in isolation, but every
setProperty / Node.create crosses the JSโWASM boundary; that
marshalling cost dominates at TUI tree sizes (10โ10k nodes), and the
Spineless engine's incremental recompute then beats WASM's per-frame
full layout. Pure-TS Pilates pays no marshalling cost.
Reproduce with pnpm bench. Full numbers + scenario shapes in
bench/RESULTS.md.
Every flex feature is verified cell-for-cell against a reference WASM flexbox implementation:
justifyContent / alignItems / alignSelf /
alignContent values, flexWrap, flexWrap: wrap-reverse, every absolute
positioning anchor)@xterm/headless per CI run, plus a fixture set of pinned agreement
cases and documented divergences (where modern terminals render wider
than xterm.js's Unicode-11 tables)fast-check over layout invariants โ
non-overflow, sibling non-overlap, reproducibility โ across randomly
generated treesflexShrink: 0 in core (React Native convention, not CSS's 1)
โ declared widths stay declared. The render layer flips this to 1 for text
leaves so wrapped text fits its container.[100, flex:1, flex:1, flex:1] โ [34, 33, 33]).@pilates/core@2.0.1 is on npm. Core algorithm + flex pipeline are
feature-complete, validated cell-for-cell against WASM Yoga, and faster
than Yoga on each of the 9 scenarios in the bench suite (see Performance
above) โ powered by the Spineless incremental engine. The React layer
ships mouse, scroll, focus management, typed errors, and layout devtools.
Issues, discussions, and PRs welcome. Start with CONTRIBUTING.md for setup, the test loop, and what the maintainer expects from layout-algorithm changes (oracle-fixture coverage). By participating you agree to follow the Code of Conduct. Security issues: see SECURITY.md for the private disclosure channel.
MIT ยฉ Zhijie Wang.