Lightweight React chessboard with low overhead interaction primitives inspired by chessground.
Built for https://chessiro.com, but can be used by all
- Zero runtime dependencies
- TypeScript-first API
- Drag, click-move, arrows, marks, premoves, promotion, overlays
- Built for controlled usage in analysis and coaching apps
npm install chessiro-canvasYour container must define width, and board height follows width (square board).
import { ChessiroCanvas, INITIAL_FEN } from 'chessiro-canvas';
export default function App() {
return (
<div style={{ width: 520 }}>
<ChessiroCanvas position={INITIAL_FEN} />
</div>
);
}ChessiroCanvas is a controlled component. For playable boards, your onMove must update
position after validating the move (typically via chess.js or chessops).
ChessiroCanvas ships with embedded default SVG pieces and renders them by default with no asset hosting setup.
<ChessiroCanvas position={fen} />Piece license note:
- Bundled default piece artwork is generated from
react-chessboarddefaults (MIT license). - You can replace it any time via
pieceSet.path.
Use pieceSet.path only when you want to override with your own hosted piece set.
<ChessiroCanvas
position={fen}
pieceSet={{
id: 'alpha',
name: 'Alpha',
path: '/pieces/alpha', // expects /pieces/alpha/wp.svg ... /bk.svg
}}
/>If pieces appear as broken images, upgrade to the latest package version.
Use squareVisuals to customize legal dots, capture rings, premove hints, marks, and check overlay.
<ChessiroCanvas
position={fen}
dests={dests}
squareVisuals={{
legalDot: 'rgba(30, 144, 255, 0.55)',
legalDotOutline: 'rgba(255, 255, 255, 0.95)',
legalCaptureRing: 'rgba(30, 144, 255, 0.8)',
premoveDot: 'rgba(155, 89, 182, 0.55)',
premoveCaptureRing: 'rgba(155, 89, 182, 0.75)',
selectedOutline: 'rgba(255, 255, 255, 1)',
markOverlay: 'rgba(244, 67, 54, 0.6)',
markOutline: 'rgba(244, 67, 54, 0.9)',
}}
/><ChessiroCanvas
position={fen}
showMargin={true}
marginRadius={16}
boardRadius={14}
arrowVisuals={{
lineWidth: 0.2,
opacity: 1,
markerWidth: 5,
markerHeight: 5,
}}
notationVisuals={{
fontFamily: 'JetBrains Mono, monospace',
fontSize: 11,
onBoardFontSize: 11,
opacity: 0.95,
}}
promotionVisuals={{
panelColor: 'rgba(20, 24, 36, 0.98)',
titleColor: '#f2f6ff',
optionBackground: 'rgba(255, 255, 255, 0.08)',
optionTextColor: '#f2f6ff',
cancelTextColor: '#cbd5e1',
}}
overlayVisuals={{
background: 'rgba(2, 6, 23, 0.85)',
color: '#f8fafc',
borderRadius: '6px',
fontSize: '11px',
}}
/>boardRadius controls inner board corners, and marginRadius controls outer margin corners.
Both work independently, so you can style rounded inner + outer frames together.
For notation sizing, notationVisuals.fontSize and notationVisuals.onBoardFontSize accept either number or CSS string.
npm install chess.js chessiro-canvasimport { useMemo, useState } from 'react';
import { Chess } from 'chess.js';
import { ChessiroCanvas, type Dests, type Square } from 'chessiro-canvas';
export function ChessJsBoard() {
const [chess] = useState(() => new Chess());
const [fen, setFen] = useState(() => chess.fen());
const dests = useMemo<Dests>(() => {
const map = new Map<Square, Square[]>();
const moves = chess.moves({ verbose: true });
for (const move of moves) {
const from = move.from as Square;
const to = move.to as Square;
const current = map.get(from);
if (current) current.push(to);
else map.set(from, [to]);
}
return map;
}, [fen]);
return (
<ChessiroCanvas
position={fen}
turnColor={chess.turn()}
movableColor={chess.turn()}
dests={dests}
onMove={(from, to, promotion) => {
const result = chess.move({ from, to, promotion });
if (!result) return false;
setFen(chess.fen());
return true;
}}
/>
);
}Important for chess.js users:
chess.jsmutates the sameChessinstance in place.- Do not key
useMemo/useEffectoff thechessobject reference for legal moves, check square, turn state, etc. - Key derived UI state from
fen(or move history), becausefenchanges on every accepted move.
Correct dependency pattern:
const [chess] = useState(() => new Chess());
const [fen, setFen] = useState(() => chess.fen());
const dests = useMemo(() => {
const map = new Map();
for (const move of chess.moves({ verbose: true })) {
const list = map.get(move.from) ?? [];
list.push(move.to);
map.set(move.from, list);
}
return map;
}, [fen]); // <- use fen, not [chess]npm install chessops chessiro-canvasimport { useMemo, useState } from 'react';
import { Chess } from 'chessops/chess';
import { chessgroundDests } from 'chessops/compat';
import { parseFen, makeFen } from 'chessops/fen';
import { parseUci } from 'chessops/util';
import { ChessiroCanvas, INITIAL_GAME_FEN } from 'chessiro-canvas';
export function ChessopsBoard() {
const [pos, setPos] = useState(() =>
Chess.fromSetup(parseFen(INITIAL_GAME_FEN).unwrap()).unwrap(),
);
const fen = useMemo(() => makeFen(pos.toSetup()), [pos]);
const dests = useMemo(() => chessgroundDests(pos), [pos]);
const turn = pos.turn === 'white' ? 'w' : 'b';
return (
<ChessiroCanvas
position={fen}
turnColor={turn}
movableColor={turn}
dests={dests}
onMove={(from, to, promotion) => {
const uci = `${from}${to}${promotion ?? ''}`;
const move = parseUci(uci);
if (!move || !pos.isLegal(move)) return false;
const next = pos.clone();
next.play(move);
setPos(next);
return true;
}}
/>
);
}INITIAL_FEN is piece-placement only (UI-friendly). For engine integrations, use INITIAL_GAME_FEN so castling rights are present.
- FEN-based board rendering
- Built-in default piece set shipped with the package
- Click-to-move and drag-to-move
- Legal move dots and capture rings
- Premoves with optional external event hooks
- Right-click arrows and marks
- Last-move, check, and custom square highlights
- Move-quality badge support
- Promotion chooser
- Text overlays with custom renderer
- Keyboard callbacks (
ArrowLeft,ArrowRight,Home,End,F,X,Escape) - Theme, piece set, and custom piece renderer support
| Prop | Type | Default | Notes |
|---|---|---|---|
position |
string |
start position | FEN (piece placement or full FEN; placement is parsed) |
orientation |
'white' | 'black' |
'white' |
Board orientation |
interactive |
boolean |
true |
Disables move interactions when false |
turnColor |
'w' | 'b' |
undefined |
Needed for turn-aware move/premove flow |
movableColor |
'w' | 'b' | 'both' |
undefined |
Restricts which side can move |
onMove |
(from, to, promotion?) => boolean |
undefined |
Return true to accept move |
dests |
Map<Square, Square[]> |
undefined |
Legal destinations per square |
lastMove |
{ from: string; to: string } | null |
undefined |
Last move highlight |
check |
string | null |
undefined |
King-in-check square |
premovable |
PremoveConfig |
undefined |
Enables premove and callbacks |
arrows |
Arrow[] |
[] |
Controlled arrows |
onArrowsChange |
(arrows) => void |
undefined |
Arrow updates |
markedSquares |
string[] |
internal | Controlled marks |
onMarkedSquaresChange |
(squares) => void |
undefined |
Mark updates |
arrowBrushes |
Partial<ArrowBrushes> |
default set | Override arrow colors |
arrowVisuals |
Partial<ArrowVisuals> |
undefined |
Customize arrow width, opacity, marker size, and arrow margin |
snapArrowsToValidMoves |
boolean |
true |
Queen/knight snap behavior |
theme |
BoardTheme |
built-in theme | Board colors |
pieceSet |
PieceSet |
bundled default pieces | Optional custom piece asset path config |
pieces |
Record<string, () => ReactNode> |
undefined |
Custom piece renderer map |
showMargin |
boolean |
true |
Margin frame for notation |
marginThickness |
number |
24 |
Margin px |
marginRadius |
number | string |
4 |
Outer margin frame corner radius |
boardRadius |
number | string |
0 |
Board corner radius (works with or without margin) |
showNotation |
boolean |
true |
Coordinate labels |
notationVisuals |
Partial<NotationVisuals> |
undefined |
Customize notation font family, size, weight, color, and offsets |
highlightedSquares |
Record<string, string> |
{} |
Arbitrary square background colors |
squareVisuals |
Partial<SquareVisuals> |
undefined |
Customize legal/premove indicators, marks, selected outline, and check overlay |
moveQualityBadge |
MoveQualityBadge | null |
undefined |
Badge icon on square |
allowDragging |
boolean |
true |
Drag interaction toggle |
allowDrawingArrows |
boolean |
true |
Right-click arrows/marks toggle |
showAnimations |
boolean |
true |
Piece animation toggle |
animationDurationMs |
number |
200 |
Piece animation length |
blockTouchScroll |
boolean |
false |
Prevent scrolling on touch interaction |
overlays |
TextOverlay[] |
[] |
Text overlays |
overlayRenderer |
(overlay) => ReactNode |
undefined |
Custom overlay renderer |
overlayVisuals |
Partial<OverlayVisuals> |
undefined |
Customize default overlay bubble style (when overlayRenderer is not provided) |
onSquareClick |
(square) => void |
undefined |
Square click callback |
onClearOverlays |
() => void |
undefined |
Called when board clears current ply overlays |
promotionVisuals |
Partial<PromotionVisuals> |
undefined |
Customize promotion dialog backdrop, panel, option buttons, and text colors |
onPrevious onNext onFirst onLast onFlipBoard onShowThreat onDeselect |
callbacks | undefined |
Keyboard callback hooks |
className |
string |
undefined |
Wrapper class |
style |
CSSProperties |
undefined |
Wrapper style |
INITIAL_FENINITIAL_GAME_FENreadFen(fen)/writeFen(pieces)premoveDests(square, pieces, color)preloadPieceSet(path)DEFAULT_ARROW_BRUSHES
import { useMemo } from 'react';
import { ChessiroCanvas, type Dests, type Square } from 'chessiro-canvas';
function Board({ fen, legalMovesByFrom, onMove }) {
const dests = useMemo<Dests>(() => {
const map = new Map<Square, Square[]>();
for (const [from, toList] of Object.entries(legalMovesByFrom)) {
map.set(from as Square, toList as Square[]);
}
return map;
}, [legalMovesByFrom]);
return (
<div style={{ width: 560 }}>
<ChessiroCanvas position={fen} dests={dests} onMove={onMove} />
</div>
);
}import { useState } from 'react';
import { ChessiroCanvas, type Arrow } from 'chessiro-canvas';
function AnalysisBoard({ fen }: { fen: string }) {
const [arrows, setArrows] = useState<Arrow[]>([]);
const [marks, setMarks] = useState<string[]>([]);
return (
<div style={{ width: 560 }}>
<ChessiroCanvas
position={fen}
arrows={arrows}
onArrowsChange={setArrows}
markedSquares={marks}
onMarkedSquaresChange={setMarks}
/>
</div>
);
}<ChessiroCanvas
position={fen}
theme={{
id: 'wood',
name: 'Wood',
darkSquare: '#8B5A2B',
lightSquare: '#F0D9B5',
margin: '#5C3B1F',
lastMoveHighlight: '#E7C15D',
selectedPiece: '#A86634',
}}
pieceSet={{
id: 'alpha',
name: 'Alpha',
path: '/pieces/alpha',
}}
/>Latest benchmark file: benchmarks/latest.json
Latest browser benchmark file: benchmarks/browser/latest.json
Run Node benchmark:
npm run benchmarkRun browser benchmark (Playwright + Chromium, local vs origin/main vs react-chessboard):
npm run benchmark:browserQuick browser benchmark:
npm run benchmark:browser:quickMethod:
- Environment: Node
v25.6.1, macOS arm64, Apple M4 (10 cores), 16 GB RAM - 8 measured rounds + 2 warmup rounds
- 300 position updates per round
- Position updates replay multiple real move-playthrough scenarios (castling, captures, endgames, promotion)
- Same board size (
640px) and animations disabled for both libraries - Metrics: mount wall time, update wall time, React Profiler update duration, bundle gzip
- Harnesses:
scripts/benchmark.mjs(Node) andscripts/benchmark-playwright.mjs(browser)
Run a subset of scenarios:
BENCH_SCENARIOS=italian-castling,sicilian-captures npm run benchmark
BENCH_SCENARIOS=italian-castling,sicilian-captures npm run benchmark:browserResults (generated on 2026-02-24 UTC):
| Metric | chessiro-canvas | react-chessboard | Delta |
|---|---|---|---|
| Mount wall time (mean) | 3.13 ms | 14.23 ms | 78.0% faster |
| Update wall time (mean, 300 renders) | 277.42 ms | 733.11 ms | 62.2% faster |
| Update wall per render (mean) | 0.92 ms | 2.44 ms | 62.2% faster |
| React Profiler update duration (mean) | 0.22 ms | 1.33 ms | 83.4% faster |
| Bundle ESM gzip | 31.41 KB | 37.38 KB | 16.0% smaller |
Notes:
- Numbers will vary by machine, Node version, and benchmark config.
- This benchmark is for relative comparison under the same harness, not an absolute browser FPS claim.
npm install
npm run dev
npm run docs:dev
npm run build
npm run typecheck
npm run benchmarkMIT