https://github.com/baselashraf81/layout-sans
Pure TypeScript Flex/Grid layout engine โ no DOM, no WASM. Full interactive text stack: selection, clipboard, Ctrl+F search, links, and screen-reader a11y. Works in Node, Bun, Deno, Cloudflare Workers, and the browser.
https://github.com/baselashraf81/layout-sans
canvas engine pretext
Last synced: 2 months ago
JSON representation
Pure TypeScript Flex/Grid layout engine โ no DOM, no WASM. Full interactive text stack: selection, clipboard, Ctrl+F search, links, and screen-reader a11y. Works in Node, Bun, Deno, Cloudflare Workers, and the browser.
- Host: GitHub
- URL: https://github.com/baselashraf81/layout-sans
- Owner: BaselAshraf81
- Created: 2026-03-29T23:55:14.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-04-04T04:17:13.000Z (2 months ago)
- Last Synced: 2026-04-04T06:25:58.913Z (2 months ago)
- Topics: canvas, engine, pretext
- Language: TypeScript
- Homepage: https://baselashraf81.github.io/layout-sans/demo/interactive-text.html
- Size: 12.3 MB
- Stars: 45
- Watchers: 1
- Forks: 2
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Funding: .github/FUNDING.yml
- License: LICENSE.txt
Awesome Lists containing this project
README
# LayoutSans
**CSS Flex/Grid layout without the browser. No DOM. No WASM.**
[](https://www.npmjs.com/package/layout-sans)
[](LICENSE)
[](https://bundlephobia.com/package/layout-sans)
---
๐ **[Live Demo](https://baselashraf81.github.io/layout-sans/demo/interactive-text.html)** โ 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.
**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.
---
## Why
- **The browser is a constraint, not a requirement.** `getBoundingClientRect` forces 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](https://github.com/chenglou/pretext).** Pretext tells you *how big* text is. LayoutSans tells you *where everything goes*. Together they replace browser layout with pure math.
---
## Install
```sh
npm install layout-sans
npm install @chenglou/pretext # peer dep for text nodes and v0.2 interaction
```
---
## Comparison
| | **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 | โ
| โ | โ |
---
## Quick start
```ts
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 },
// ]
```
---
## v0.2 interactive text
```ts
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)
}
```
---
## v0.2 requirements
### 1. Wait for web fonts before `engine.compute()`
`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.
```js
await document.fonts.ready // module context
document.fonts.ready.then(initEngine) // non-async context
```
### 2. `outline: none` on the canvas element
When 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.
```css
canvas { outline: none; }
```
### 3. `preventScroll: true` on `.focus()` calls inside the canvas-wrap
`overflow: hidden` creates an implicit scroll container. Any `.focus()` call without this flag can silently scroll the container, drifting the canvas coordinate system.
### 4. Canvas DPR setup
```js
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)
```
### 5. `bridge.sync()` after painting, never before
Calling `sync()` before painting can trigger a layout recalculation that shifts the canvas position before `getBoundingClientRect()` is read.
---
## API reference
### Core
#### `createLayout(root, options?)`
```ts
const engine = createLayout(root, { width?: number, height?: number })
engine.usePretext(pretextModule) // chainable; call before compute()
const boxes = engine.compute() // BoxRecord[]
```
#### `BoxRecord`
```ts
interface BoxRecord {
nodeId: string
x: number
y: number
width: number
height: number
nodeType: string // 'text' | 'heading' | 'link' | 'box' | ...
textContent?: string
href?: string
target?: string
}
```
---
### Interactive (v0.2+)
#### `engine.buildIndex()`
Builds the packed R-Tree spatial index. Call once after `compute()`. Returns a `Promise`. Safe to schedule via `requestIdleCallback`.
#### `new InteractionBridge(canvas, engine, options?)`
```ts
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 unmount
```
#### `attachMouseHandlers(opts)`
```ts
const detach = attachMouseHandlers({
canvas,
engine,
getScrollY: () => number,
getContentOffsetX?: () => number, // default 0
requestRepaint: () => void,
onLinkClick?: (href, target) => boolean,
})
detach() // removes all listeners
```
#### Paint helpers
Call all three **before** drawing text glyphs so highlights sit beneath them.
```ts
paintSelection(ctx, sel, recordMap, textLineMap, orderedIds, scrollY, viewportH, color)
paintSearchHighlights(ctx, matches, activeIndex, scrollY, viewportH, inactiveColor, activeColor)
paintFocusRing(ctx, record, scrollY, color)
```
#### `engine.selection`
```ts
engine.selection.get()
engine.selection.onChange(fn) // returns unsubscribe fn
engine.setSelection(startId, startChar, endId, endChar)
engine.clearSelection()
await engine.copySelectedText() // writes to OS clipboard
```
#### `bridge.search`
```ts
bridge.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 // number
```
#### `engine.getOrderedTextNodeIds()`
All text/heading node IDs in document order. Pass to `paintSelection` and use for select-all.
#### `engine.extractText()`
Full plain text of the layout tree in document order.
---
### Node types
#### `FlexNode`
```ts
{
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`.
#### `BoxNode` ยท `TextNode` ยท `HeadingNode` ยท `LinkNode`
```ts
{ 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[]
}
```
#### `GridNode`
```ts
{
type: 'grid'
columns?: number; rows?: number
gap?: number; rowGap?: number; columnGap?: number
children?: Node[]
}
```
#### `AbsoluteNode`
```ts
{
type: 'absolute'
top?: number; right?: number; bottom?: number; left?: number
width?: number; height?: number
children?: Node[]
}
```
#### `MagazineNode`
```ts
{
type: 'magazine'
columnCount: number
columnGap?: number
content?: string
children?: TextNode[]
font?: string; lineHeight?: number
width: number; height?: number
}
```
---
## Performance budget (v0.2, 100,000 items, Chrome 120, M1 MacBook Pro)
| 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 |
---
## Browser compatibility
| 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.
---
## Demos
```sh
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) |
---
## Benchmarks
```sh
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 | โ | โ |
---
## Roadmap
**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
---
## Support
[](https://ko-fi.com/V7V01X2WY5)
---
## License
MIT
---
## Acknowledgements
- **[Pretext](https://github.com/chenglou/pretext)** by [@_chenglou](https://x.com/_chenglou) โ the pure-math text measurement layer that makes LayoutSans possible.
- **[Yoga](https://github.com/nicolo-ribaudo/yoga-layout)** by Meta โ the production flexbox engine that inspired LayoutSans's API design.