A small, MIT-licensed, zero-dependency chess board renderer for the web, with first-party React bindings.
quadrum draws a chess board and the pieces on it, animates moves, handles selection, click-to-move, dragging and hand-drawn arrows and circles. It does not know the rules of chess: legal destinations are handed in by the consumer, which keeps it usable with any rules engine (chess.js, a server, a variant) and keeps the library small.
Status: early. Published on npm and in use, but still pre-1.0 — the API is settled in shape and may still shift in detail before it is frozen.
The obvious existing choice, chessground, is GPL-3.0, which many applications cannot ship. quadrum is a clean-room MIT alternative — the rules that were followed, and which of them you can verify yourself, are written down in CLEANROOM.md. It also fixes a handful of structural problems that force chessground consumers to write workarounds:
| Problem | quadrum's answer |
|---|---|
| Piece positions come from a cached bounding rect, so a resize silently misaligns clicks until the consumer forces a redraw | Pieces are laid out in percentages (12.5% + translate(x*100%, y*100%)). Resizing is handled by the browser; there is nothing to invalidate |
| Setting a new position silently wipes the user's arrows | update({ position }) never touches user marks. Clearing them is the consumer's explicit policy |
| Toggling "view only" updates state but leaves input dead until a redraw | Pointer handlers are bound once; the lock is a guard inside the handler, so it always works |
| Arrow colours are cached by brush key, so a theme swap leaves stale colours | Pen colour is read at render time |
| Arrow opacity applies to the stem but not the head | Opacity is applied to a group wrapping both |
| Destination hints are drawn imperatively at selection time | They are part of the normal declarative render pass |
Measured against chessground 10.1.1, installed as a
dev-only dependency of the benchmark app and never shipped. That is the @lichess-org/chessground
package — lichess moved publishing there at v10, and the unscoped chessground name is deprecated
on npm and frozen at 9.2.1.
| Scenario | quadrum | chessground 10.1.1 | Ratio |
|---|---|---|---|
| Mount a full board | 2.58 ms | 3.23 ms | 0.80× — quadrum wins ✅ |
| 100 position updates, animation off | 10.46 ms | 10.76 ms | 0.97× — parity |
| 100 position updates, animation on | 2.12 ms | 1.38 ms | 1.54× — chessground wins |
| Engine arrow re-draw, per tick | 28.02 ms | 27.10 ms | 1.03× — parity |
| Drag latency, p95 | 19.14 ms | 19.56 ms | 0.98× — quadrum wins ✅ |
| Resize storm, 50 resizes | 0.07 ms | 1.00 ms | quadrum ≈ 0.07 ms (below timer resolution) — does no measurable work here ✅ |
| Retention after teardown | 0 | 0 | 1.00× — parity |
| Bundle size, min+brotli | 11.6 kB | 11.9 kB | 0.98× — quadrum wins ✅ |
Medians. Measured 2026-09-17 on linux/x64 (4 vCPU AMD EPYC 7763 64-Core Processor), headless Chromium 151.0.7922.34, CPU throttled 4×, 31 repetitions interleaved. quadrum 0.3.1 @ 60f735f vs chessground 10.1.1. "Parity" means the 95% confidence intervals overlap — a difference too small to claim.
- CPU throttle rate: 4
- Headless has no real vsync; frame-derived metrics are advisory
- Position-replay workload: three real games spliced to 200 half-moves (see apps/bench/src/data/game.ts)
Absolute milliseconds on a throttled shared runner are not desktop numbers; the ratios are the durable part. The benchmarks are written and run by quadrum's author — full statement of interest, methodology, every scenario, dispersion and raw samples: apps/bench/README.md.
| Package | What it is |
|---|---|
quadrum |
The renderer. Framework-agnostic, zero dependencies |
quadrum-react |
React bindings: a controlled <Board> component and a useBoard hook |
Subpath entries: quadrum/fen (FEN placement read/write), quadrum/mobility
(a premove mobility table), quadrum/assets/quadrum.css (structural CSS).
import { createBoard } from "quadrum";
import "quadrum/assets/quadrum.css";
const board = createBoard(document.getElementById("board")!, {
position: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
orientation: "white",
moves: {
targets: new Map([["e2", ["e3", "e4"]]]),
onPlayed: (from, to) => console.log(from, to),
},
});
board.update({ position: nextFen, lastMove: ["e2", "e4"] });React:
import { Board } from "quadrum-react";
import "quadrum/assets/quadrum.css";
<Board
position={fen}
orientation="white"
targets={legalDests}
lastMove={lastMove}
onMove={(from, to) => play(from, to)}
/>;The shipped CSS is structural only — no colours, no board squares, no piece art. That is deliberate: theming is the application's job, and quadrum's DOM is plain and light-DOM so ordinary CSS can reach it.
<div class="qd-wrap interactive" data-orientation="white">
<qd-board>
<qd-square class="recent" data-square="e4"></qd-square>
<qd-piece class="white rook" data-square="h1"></qd-piece>
</qd-board>
<svg class="qd-marks" viewBox="0 0 800 800">…</svg>
<svg class="qd-badges" viewBox="0 0 800 800">…</svg>
<qd-coords class="ranks">…</qd-coords>
<qd-coords class="files">…</qd-coords>
<qd-overlay></qd-overlay>
</div>
Paint the board itself as the background of qd-board, and piece art as
background-image on qd-piece.white.rook and friends.
State classes: qd-square carries target (+capture / +friendly), recent,
active, in-check, hover; qd-piece carries held (dragging), gliding
(animating), vanishing (captured), trace (drag origin), appearing.
pnpm install
pnpm test # vitest, jsdom
pnpm typecheck # tsc across both packages
pnpm test:e2e # playwright, real browser (see below)
pnpm dev # serve apps/demo at http://localhost:5173Commits follow Conventional Commits
(type(scope): subject), with the allowed types and a 72-character subject limit set in
commitlint.config.js. pnpm install installs a commit-msg hook that checks the
message as you commit; CI re-checks every commit in a PR and the PR title, since a
squash merge keeps the title and discards the commits.
These messages are the release input. release-please reads every commit that lands on
main: the type picks the version bump, the subject becomes the changelog line,
and the paths it touched decide which package it belongs to. So a subject written
carelessly is published carelessly, and a chore: where a fix: belonged ships nothing
at all. See Releasing for the mapping.
e2e/ drives the demo app in Chromium with Playwright. It is shallow and wide: one or
two tests per feature across movement, orientation, targets, marks (arrows, circles,
pens), premoves, chess960 castling and the promotion picker.
Every gesture is a real one — page.mouse presses, moves and releases at real pixel
coordinates, and clicks on the demo's own controls. Nothing in the suite calls quadrum's
API or pokes React state, because the layer being tested is pointer handling and
percentage layout, and jsdom can observe neither.
pnpm test:e2e # headless; starts the demo on :5273 itself
pnpm test:e2e:ui # Playwright's UI mode, for writing or debugging a specRequires the browser once: pnpm exec playwright install chromium. The suite also runs
as its own CI job on every PR.
apps/demo is the demo and the e2e fixture: a board with free / targeted / premove
modes, chess960 castling, a promotion picker, arrows and circles, and toggles for lock,
drag, off-board removal and mark behaviour, plus readouts for placement, last move, move
count, marks and the premove queue. It is the only place quadrum runs in a real browser,
so run it whenever you change rendering, layout or input.
Every control carries an accessible name, and the demo's readouts are data-testid
nodes — both exist so the e2e suite can drive and read the app the way a person would.
pnpm dev # dev server with HMR
pnpm --filter quadrum-demo build # production build into apps/demo/dist
pnpm --filter quadrum-demo preview # serve that buildThe demo authors every board visual itself in src/board-chrome.css — checkerboard,
piece glyphs (Unicode, no image assets), square decorations, coordinates, cursors —
because quadrum's own CSS is structural only. Copy that file as the starting point for a
new consumer.
pnpm build bundles both packages with tsup (ESM only) and emits declarations with
tsc --emitDeclarationOnly; the output lands in each package's dist/. The published
tarballs are that dist/ (JS, declarations and sourcemaps) plus core's
assets/quadrum.css — no src/. Sourcemaps keep their embedded sourcesContent, so a
consumer still debugs the original TypeScript without it being shipped twice.
In-repo, nothing depends on dist/ being fresh: quadrum and quadrum-react are mapped
to their own src/ by paths in tsconfig.base.json and by a matching resolve.alias
in vitest.config.ts and each app's Vite config. So pnpm typecheck, pnpm test and
pnpm dev all work against TypeScript source with no dist/ present, and can never read
a stale build. The one exception is deliberate: packages/react/tsconfig.build.json
clears paths, so its declaration emit resolves quadrum to core's built dist/*.d.ts
the way a consumer does.
Releases are driven by release-please, and
there is nothing to author by hand. Every commit that lands on main is read as a
Conventional Commit; release-please-config.json maps it to a bump and a changelog
section:
| Commit | Bump | Appears in the changelog as |
|---|---|---|
feat: |
minor | Features |
fix: |
patch | Bug fixes |
perf: |
patch | Performance |
build: |
patch | Packaging |
revert: |
patch | Reverts |
refactor: docs: test: ci: chore: |
patch | hidden |
! or a BREAKING CHANGE: footer |
minor (see below) | its own ⚠ Breaking section |
Which package a commit releases is decided by path, not by the scope in the subject:
only a commit touching packages/core/** can release quadrum. A fix(bench): on the
benchmark app releases nothing. That is the check changesets could only approximate by
asking a human to remember, and it is the reason this repo's perf(core): history needs
no annotation to produce a correct changelog.
Note the consequence of the hidden rows: they still bump the patch version, but their
subject never reaches a reader. A packaging change that consumers can observe — something
leaving the tarball, an export condition disappearing — belongs under build:, not ci:,
even when the diff is mostly workflow files.
.github/workflows/release.yml keeps a chore: release X.Y.Z PR open with the pending
CHANGELOG.md and package.json edits, and rewrites it on every push. Merging it tags
[email protected] and [email protected], creates the GitHub releases, and only then runs
the publish job — type-check, test, build, then pnpm publish per package, core first
because react peer-depends on it, over trusted publishing (OIDC — no token).
Two settings keep 0.x releases from over-bumping, and are worth understanding before changing either.
bump-minor-pre-major: true. Pre-1.0, a breaking change bumps the minor, not the
major. Without it a single ! commit ships 1.0.0 — a version this project has not
earned and cannot take back. The companion bump-patch-for-minor-pre-major is left
false on purpose: a feat: is worth a minor even at 0.x, and demoting it to a patch
would make the version say nothing.
quadrum-react peer-depends on quadrum as >=0.1.0 <1, not ^0.1.0. A caret on a
0.x version only admits 0.1.x, so a routine 0.1.0 → 0.2.0 minor on core would fall out
of the binding's declared range and read as a breaking change to quadrum-react. The
widened range keeps every 0.x minor in range. The <1 bound is deliberate: the real 1.0.0
does fall out of range, so that one transition is still flagged, which is correct. After
1.0.0 the problem disappears, since ^1.x admits every 1.x minor. The node-workspace
plugin does not touch this — it rewrites internal dependencies, and leaves
peerDependencies alone unless explicitly told otherwise, which it is not.
The linked-versions plugin holds the two packages on one version number, the same
guarantee changesets' linked gave. It is stricter in one way worth knowing: the pair is
released together, so a core-only patch also republishes quadrum-react at the new
version. That is the price of the pair always being installable as a matched set, and it
is cheap.
.release-please-manifest.json is the source of truth for the current versions and is
written by the bot — do not hand-edit it. bootstrap-sha in the config bounds the first
history scan to the last changesets-era release (chore: version packages 0.2.2); it is
inert once release-please has cut a release of its own.
Everything below is account and repository configuration — none of it lives in the repo, and the release workflow cannot succeed until it is done. It is a one-time list.
1. Let Actions open pull requests. Repo Settings → Actions → General → Workflow permissions → tick "Allow GitHub Actions to create and approve pull requests".
This defaults to off, and it is the first thing that breaks. release.yml already
requests pull-requests: write, but that permission is not sufficient — the repo toggle
overrides it. Without it release-please pushes its release branch and then fails with
GitHub Actions is not permitted to create or approve pull requests.
2. Claim the names on npm. An npm account with 2FA enabled, and the names quadrum
and quadrum-react unregistered.
3. Publish 0.1.0 by hand, once. Trusted publishing is configured on a package's
settings page, and a package that has never been published has no settings page — so the
first release cannot be automated. Core goes first, since quadrum-react peer-depends on
it:
npm login
pnpm build
cd packages/core && npm publish
cd ../react && npm publish4. Point each package at this workflow. On npmjs.com, for each of quadrum and
quadrum-react: Settings → Trusted publishers → GitHub Actions, with repository
yoavniran/quadrum and workflow release.yml.
After this every later release is automatic, and no NPM_TOKEN secret is needed —
OIDC replaces it. Do not add one. Optionally then set the packages to "Require
two-factor authentication and disallow tokens": trusted publishing keeps working,
because it does not authenticate with a token.
Trusted publishing needs npm ≥ 11.5.1 and Node ≥ 22.14 — release.yml installs
npm@latest rather than trusting whatever the runner image ships.
MIT — see LICENSE.