@withremyinc/cassowary-layout
v0.1.0
Published
An ergonomic TypeScript implementation of the Cassowary linear constraint solving algorithm, for layout and UI.
Maintainers
Readme
@withremyinc/cassowary-layout
An ergonomic TypeScript implementation of the Cassowary linear constraint solving algorithm, for layout and UI.
- ESM-only
- Node 18+
- Zero runtime dependencies
- Incremental dual-simplex solver with edit variables and priorities
- Consistently faster than
kiwion the same problem (see Benchmarks) - Ergonomic, JavaScript-friendly expression API
Cassowary is useful for layout systems and any problem that needs linear constraints with priorities. Required constraints must always hold; weaker constraints can be violated when the full system cannot satisfy every preference.
Installation
npm install @withremyinc/cassowary-layoutpnpm add @withremyinc/cassowary-layoutyarn add @withremyinc/cassowary-layoutQuick start
import {
Solver,
Variable,
REQUIRED,
STRONG,
WEAK,
} from "@withremyinc/cassowary-layout";
const solver = new Solver();
const windowWidth = new Variable("windowWidth");
const box1Left = new Variable("box1.left");
const box1Right = new Variable("box1.right");
const box2Left = new Variable("box2.left");
const box2Right = new Variable("box2.right");
solver.addConstraints([
windowWidth.ge(0, REQUIRED),
box1Left.eq(0, REQUIRED),
box2Right.eq(windowWidth, REQUIRED),
box2Left.ge(box1Right, REQUIRED),
box1Left.le(box1Right, REQUIRED),
box2Left.le(box2Right, REQUIRED),
box1Right.minus(box1Left).eq(50, WEAK),
box2Right.minus(box2Left).eq(100, WEAK),
]);
solver.addEditVariable(windowWidth, STRONG);
solver.suggestValue(windowWidth, 300);
console.log(solver.getValue(box1Right)); // 50
console.log(solver.getValue(box2Left)); // 200
console.log(solver.getValue(box2Right)); // 300Building expressions
Create variables with new Variable(name) or variable(name):
const left = new Variable("left");
const right = new Variable("right");
const width = new Variable("width");Build linear expressions with methods on Variable, Term, and Expression:
right.minus(left).eq(width); // right - left == width
right.minus(left).ge(10); // right - left >= 10
right.minus(left).eq(50, WEAK); // preferred widthYou can also use free functions:
import { eq, ge, le } from "@withremyinc/cassowary-layout";
solver.addConstraints([
eq(left, 0),
eq(right.minus(left), width),
ge(width, 10),
le(width, 100),
]);Strengths
Standard strengths are exported as constants and through the strength helper:
import {
REQUIRED,
STRONG,
MEDIUM,
WEAK,
strength,
} from "@withremyinc/cassowary-layout";
const custom = strength.create(1, 0, 500, 1);Solver API
Solver supports:
addConstraint(constraint)/addConstraints(constraints)removeConstraint(constraint)hasConstraint(constraint)addEditVariable(variable, strength)suggestValue(variable, value)removeEditVariable(variable)hasEditVariable(variable)fetchChanges()for changed variable/value pairsgetValue(variable)for direct value lookupreset()to reuse a solver instance
Errors are typed classes such as DuplicateConstraintError, UnsatisfiableConstraintError, and UnknownEditVariableError, all extending CassowaryError.
Benchmarks
The solver is consistently faster than @lume/kiwi (the maintained JavaScript port of the C++ kiwi) on the same problem — and produces the identical layout.
The workload builds a resizable row of 120 boxes (602 constraints) and then repeatedly suggests a new window width, re-solving each time. Both solvers are driven through the exact same constraints and edits; the checksum of all box positions is identical, so this is also a correctness cross-check.
| Scenario | cassowary-layout | @lume/kiwi | Speedup | | --- | --- | --- | --- | | Build (120 boxes, 602 constraints) | ~8.1 ms | ~12.0 ms | ~1.5× | | Per-frame edits (re-solve + read every frame) | ~20 µs/edit (~50k/s) | ~31 µs/edit (~32k/s) | ~1.5× | | Batched edits (suggest many, read once) | ~1.9 µs/edit (~520k/s) | ~28 µs/edit (~35k/s) | ~15× |
The batched case is dramatically faster because suggestValue() is lazy: it records the requested value and the system is solved once on the next read (fetchChanges() / getValue()), collapsing many edits into a single dual-simplex pass. kiwi re-solves on every suggestValue(). For a per-frame UI loop that reads results each frame, the ~1.5× advantage applies; for batch layout passes, the gap is an order of magnitude.
Numbers above are medians from pnpm bench:kiwi on Node 24 (Apple Silicon) and are illustrative — run it on your own hardware:
pnpm bench:kiwiDevelopment
pnpm install
pnpm typecheck
pnpm test
pnpm buildRun the benchmarks (each builds the package first, then runs against dist/):
pnpm bench # deterministic layout workload with smoke thresholds
pnpm bench:kiwi # head-to-head comparison against @lume/kiwiThe benchmark drives a resizable row of boxes through many edit-variable suggestions and enforces broad smoke-test thresholds to catch algorithmic regressions without being flaky across machines.
Changelog
See CHANGELOG.md.
Releasing
For the full checklist, see RELEASING.md.
Quick version:
- Bump the package version with
pnpm version patch(orminor/major). - Push the commit and matching tag with
git push --follow-tags. - GitHub Actions publishes that tag to npm.
The publish workflow expects an NPM_TOKEN repository secret and uses npm provenance.
Acknowledgements
This package is a TypeScript port and reimagining of prior Cassowary work:
cassowary-rsby Dylan Ede — the Rust implementation this port started from.kiwiby the Nucleic Development Team — an efficient C++ implementation whose dual-simplex approach informed the solver.
Both are based on the seminal Cassowary paper (Badros, Borning, and Stuckey, 2001).
License
MIT.
Copyright © 2026 Remy, Inc.
See LICENSE.md for the project license and NOTICES.md for the cassowary-rs and kiwi license notices.
