Why plain TypeScript
The bet behind the library — what dropping the native core buys, and what it costs.
If you want spec-grade flexbox and CSS Grid in a JavaScript project today, your options each carry a tax:
- Yoga (Meta) is the battle-tested standard, but every published release is flexbox-only — CSS Grid exists as an open, unmerged pull request (facebook/yoga#1865) — and the npm package is C++ compiled to a base64-encoded WebAssembly blob behind an async loader, with asm.js as the synchronous fallback. Sound engineering for its goals; real friction for bundlers, edge runtimes, and quick starts.
- The Rust engines have excellent flexbox, grid, and block algorithms, but they are crates serving Rust UI toolkits. Using one from JavaScript means a WASM build and a binding layer, with the allocation and lifetime bookkeeping that implies.
- Hand-rolled layout math is where many canvas and PDF projects actually land, and it is a slow leak: each new alignment mode or percentage edge case is another divergence from what CSS-trained intuition expects.
bento-layout fills the specific gap those leave: one plain-TypeScript
package where flexbox, grid, and block all work, verified against a
browser, that installs and runs with the ceremony of lodash. The engines
above are better choices when their strengths are your constraints — Yoga
when you are in the React Native ecosystem, a Rust crate when you are
writing Rust. This is the choice for when you are writing TypeScript and
want layout to be a small, boring dependency.
What the absence of a native core buys
The bet is that for most JavaScript projects that need layout outside the DOM, the engine's job is to be correct and invisible. The incumbent engines are excellent native code first and JavaScript citizens second; bento-layout takes the other route — accept the managed-runtime tax and in exchange be a library that feels like it was written for TypeScript, because it was.
- No loading problem. No async WASM instantiation, no top-level await, no bundler configuration, no sync-vs-async API fork. It is an ES module.
- No lifetime problem. WASM linear memory cannot be garbage collected
from JS, which is why bindings grow
free()methods and node registries. Plain objects dissolve that whole class of bug — there is nothing to leak. - No boundary problem. Every measure callback in a WASM engine crosses the JS↔native boundary twice; here a measure function is just a function call. Styles are structural TypeScript types checked at compile time, not enums marshalled through a binding.
- Debuggable to the bottom. A wrong position is a breakpoint in readable TypeScript, not a wall at a compiled frame.
What it costs
Parity with native performance is not the target, and chasing it would
trade away the plain-TypeScript simplicity that is the point. On tree
shapes the Rust engines also benchmark, this one currently measures
roughly 6× slower than native — while still laying out on the
order of a hundred thousand to a million nodes per second on a laptop,
depending on tree shape. For the UI trees this library is for, layout is
not the frame budget. The repository's BENCHMARKS.md keeps the current
measurements, methodology, and their limits.
An engine, not a port
Correctness here is anchored to a browser rather than to another implementation: conformance is defined by a pinned Chrome, and the browser-oracle fuzzer and WPT scoreboard exist to keep that claim measured rather than asserted. Where any other engine and the browser disagree, the browser wins — which is what lets this one fix inherited bugs instead of faithfully reproducing them. How correctness is verified tells that story in full.
The API is likewise designed for TypeScript idioms — GC lifetimes, structural types, per-property merge — not for symmetry with any existing engine. Matching another implementation's API is a non-goal; correctness compatibility with the browser is the goal.
Acknowledgements
Thanks to Taffy (MIT), a major reference while this engine was being built.