CSS Flex/Grid layout without the browser. No DOM. No WASM.
v0.2 adds a full interactive text stack on top of the pure-canvas renderer: text selection, clipboard copy, Ctrl+F search, hyperlinks, and screen-reader accessibility — with zero visible DOM layout and O(viewport) DOM node count regardless of total items.
🚀 Live Demo — 100k-item benchmark + interactive text selection, search, and links
A pure TypeScript 2D layout engine. Give it a tree of boxes with flex/grid rules; get back exact pixel positions for every box. Works in Node, Bun, Deno, Cloudflare Workers, browser — anything that runs JS.
- The browser is a constraint, not a requirement.
getBoundingClientRectforces synchronous reflows. For server-rendered layouts, virtual lists, canvas renderers, and PDF engines, the DOM is overhead you don't need. - Yoga is great, but it ships WASM. That is 300+ kB before your first layout call, requires async initialization, and does not run everywhere.
- LayoutSans is the missing layer after Pretext. Pretext tells you how big text is. LayoutSans tells you where everything goes. Together they replace browser layout with pure math.
npm install layout-sans @chenglou/pretext| LayoutSans | DOM layout | Yoga WASM | |
|---|---|---|---|
| 100 boxes | 0.27 ms | 8.0 ms | 0.80 ms |
| 10,000 boxes | 4.82 ms | 800 ms | 8.0 ms |
| 100,000 var-height | 46 ms | crashes | 85 ms |
| buildIndex() at 100k | < 15 ms | — | — |
| Hit-test query (R-Tree) | < 0.5 ms | — | — |
| Sub-glyph cursor resolve | < 0.1 ms | — | — |
| Bundle size | ~17 kB gz | browser only | 300+ kB gz |
| Node / Bun / Deno | ✅ | ❌ | WASM only |
| Cloudflare Workers | ✅ | ❌ | ❌ |
| Async init required | none | ❌ | ✅ |
| Zero dependencies | ✅ | — | ❌ |
import { createLayout } from 'layout-sans'
const boxes = createLayout({
type: 'flex', direction: 'row', width: 800, height: 600, gap: 16,
children: [{ type: 'box', flex: 1 }, { type: 'box', width: 240 }],
}).compute()
// [
// { nodeId: '0', x: 0, y: 0, width: 800, height: 600 },
// { nodeId: '0.0', x: 0, y: 0, width: 544, height: 600 },
// { nodeId: '0.1', x: 560, y: 0, width: 240, height: 600 },
// ]import { createLayout, InteractionBridge, attachMouseHandlers,
paintSelection, paintSearchHighlights, paintFocusRing } from 'layout-sans'
import * as pretext from '@chenglou/pretext'
// 1. Wait for web fonts — glyph widths are read at compute() time.
await document.fonts.ready
// 2. Build engine + spatial index
const engine = createLayout(root).usePretext(pretext)
const boxes = engine.compute()
await engine.buildIndex()
// 3. Mount bridge (clipboard, search, shadow a11y tree)
const bridge = new InteractionBridge(canvas, engine, {
searchUI: true,
onScrollTo: (y) => { scrollY = y; repaint() },
requestRepaint: repaint,
onSelectionChange: (text) => console.log('selected:', text),
})
// 4. Attach mouse handlers (selection drag, link click, double-click word-select)
const detach = attachMouseHandlers({ canvas, engine, getScrollY: () => scrollY, requestRepaint: repaint })
// 5. RAF loop — paint canvas, then sync bridge
function loop() {
paintCanvasFrame()
const sel = engine.selection.get()
if (sel) paintSelection(ctx, sel, recordMap, engine.textLineMap,
engine.getOrderedTextNodeIds(), scrollY, CH, '#6c7aff55')
if (bridge.search.isOpen)
paintSearchHighlights(ctx, bridge.search.matches, bridge.search.activeIndex,
scrollY, CH, 'rgba(255,220,0,.4)', 'rgba(255,160,0,.7)')
bridge.sync(scrollY) // always AFTER painting
requestAnimationFrame(loop)
}engine.compute() reads real glyph widths via ctx.measureText. If the fonts are still downloading, widths are computed against the system fallback font and stored incorrectly in textLineMap. Selection rects and search highlights will land at shifted positions when the real font paints.
await document.fonts.ready // module context
document.fonts.ready.then(initEngine) // non-async contextWhen the canvas has tabindex="0" and a parent has overflow: hidden, the browser's focus ring appears as an inset border, misaligning getBoundingClientRect() and every subsequent hit-test.
canvas { outline: none; }overflow: hidden creates an implicit scroll container. Any .focus() call without this flag can silently scroll the container, drifting the canvas coordinate system.
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = containerWidth * dpr
canvas.height = containerHeight * dpr
canvas.style.width = containerWidth + 'px'
canvas.style.height = containerHeight + 'px'
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)Calling sync() before painting can trigger a layout recalculation that shifts the canvas position before getBoundingClientRect() is read.
const engine = createLayout(root, { width?: number, height?: number })
engine.usePretext(pretextModule) // chainable; call before compute()
const boxes = engine.compute() // BoxRecord[]interface BoxRecord {
nodeId: string
x: number
y: number
width: number
height: number
nodeType: string // 'text' | 'heading' | 'link' | 'box' | ...
textContent?: string
href?: string
target?: string
}Builds the packed R-Tree spatial index. Call once after compute(). Returns a Promise. Safe to schedule via requestIdleCallback.
interface InteractionOptions {
searchUI?: boolean // default true
onLinkClick?: (href: string, target: string) => boolean
onSelectionChange?: (text: string) => void
onScrollTo?: (y: number) => void
requestRepaint?: () => void
}
bridge.sync(scrollY) // call every frame after painting
bridge.rebuild() // call after engine.compute() is re-run
bridge.destroy() // call on unmountconst detach = attachMouseHandlers({
canvas,
engine,
getScrollY: () => number,
getContentOffsetX?: () => number, // default 0
requestRepaint: () => void,
onLinkClick?: (href, target) => boolean,
})
detach() // removes all listenersCall all three before drawing text glyphs so highlights sit beneath them.
paintSelection(ctx, sel, recordMap, textLineMap, orderedIds, scrollY, viewportH, color)
paintSearchHighlights(ctx, matches, activeIndex, scrollY, viewportH, inactiveColor, activeColor)
paintFocusRing(ctx, record, scrollY, color)engine.selection.get()
engine.selection.onChange(fn) // returns unsubscribe fn
engine.setSelection(startId, startChar, endId, endChar)
engine.clearSelection()
await engine.copySelectedText() // writes to OS clipboardbridge.search.openPanel()
bridge.search.search(query, { caseSensitive?, wholeWord? })
bridge.search.nextMatch() / prevMatch() / goToMatch(index)
bridge.search.closePanel()
bridge.search.isOpen // boolean
bridge.search.matches // SearchMatch[]
bridge.search.activeIndex // numberAll text/heading node IDs in document order. Pass to paintSelection and use for select-all.
Full plain text of the layout tree in document order.
{
type: 'flex'
direction?: 'row' | 'column'
gap?: number; rowGap?: number; columnGap?: number
justifyContent?: 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'
alignItems?: 'flex-start' | 'center' | 'flex-end' | 'stretch'
wrap?: boolean
width?: number; height?: number
padding?: number; paddingTop?: number; paddingRight?: number; paddingBottom?: number; paddingLeft?: number
margin?: number; marginTop?: number; marginRight?: number; marginBottom?: number; marginLeft?: number
children?: Node[]
}Flex children may add: flex, flexShrink, flexBasis, alignSelf.
{ type: 'box', width?: number, height?: number, flex?: number }
{
type: 'text'
content: string
font?: string // CSS font string — must match the loaded face
lineHeight?: number
width?: number
}
{
type: 'heading'
level: 1 | 2 | 3 | 4 | 5 | 6
content: string; font?: string; lineHeight?: number; width?: number
}
{
type: 'link'
href: string
target?: '_blank' | '_self' | '_parent' | '_top'
rel?: string // auto-set to 'noopener noreferrer' when target='_blank'
aria?: { label?: string }
children?: Node[]
}{
type: 'grid'
columns?: number; rows?: number
gap?: number; rowGap?: number; columnGap?: number
children?: Node[]
}{
type: 'absolute'
top?: number; right?: number; bottom?: number; left?: number
width?: number; height?: number
children?: Node[]
}{
type: 'magazine'
columnCount: number
columnGap?: number
content?: string
children?: TextNode[]
font?: string; lineHeight?: number
width: number; height?: number
}| Metric | Budget |
|---|---|
engine.compute() |
< 5 ms |
engine.buildIndex() |
< 15 ms (idle callback) |
| Mousemove hit-test (R-Tree) | < 0.5 ms |
| Sub-glyph char resolution | < 0.1 ms |
| Selection repaint | < 1 ms |
bridge.sync() per frame |
< 2 ms |
| DOM node count total | ≤ 700 |
| Canvas frame time | < 3 ms |
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
| Canvas 2D | all | all | all |
navigator.clipboard.writeText() |
66+ | 63+ | 13.1+ |
requestIdleCallback |
47+ | 55+ | setTimeout fallback |
document.fonts.ready |
35+ | 41+ | 10+ |
| Shadow Semantic Tree / aria-live | all | all | all |
Minimum: Chrome 66, Firefox 63, Safari 13.1.
npm run build
npm run demo:serve # serves demo/index.html on localhost:3000| Demo | What it shows |
|---|---|
demo/index.html |
Unified demo: 100k benchmark + interactive text (selection, copy, search, links, a11y) |
demo/basic-flex.ts |
5-line flex row (Node) |
demo/magazine.ts |
Multi-column text flow (Node) |
demo/virtualization.ts |
100,000 variable-height items (Node) |
npm run bench| Scenario | LayoutSans | vs DOM | vs Yoga WASM |
|---|---|---|---|
| 100 flex boxes | 0.27 ms | 30× | 3× |
| 10,000 flex boxes | 4.82 ms | 166× | 2× |
| 100,000 var-height | 46 ms | ∞ | 2× |
| buildIndex() at 100k | 11 ms | — | — |
| queryPoint() p95 at 100k | < 0.5 ms | — | — |
| resolvePixelToCursor() p95 | < 0.1 ms | — | — |
v0.2 — current
- Canvas text selection + OS clipboard (desktop & mobile)
- O(log n) spatial hit-testing via packed R-Tree
- Interactive hyperlinks (mouse + Tab + keyboard)
- Full-text search (Ctrl+F) with canvas highlighting
- Virtualized shadow semantic tree (VoiceOver, NVDA, JAWS)
- Mobile long-press with native teardrop selection handles
- O(viewport) DOM node count regardless of total item count
v0.3
- Named grid template areas
- CSS
aspect-ratio - Enhanced ARIA role/label per record
v0.4
- RTL layout
- Full CSS grid (template columns/rows, named lines, span)
- Baseline alignment
MIT
- Pretext by @_chenglou — the pure-math text measurement layer that makes LayoutSans possible.
- Yoga by Meta — the production flexbox engine that inspired LayoutSans's API design.
