Put a chess game on a page
The game is the markup. You write the notation between the tags, and that's it — that's the whole integration.
So you've got a chess game you want to put in front of people. Maybe it's
your club's game of the month, maybe it's the one where you finally beat
your brother-in-law. <chess-view> takes it in
PGN — the format every chess
site and every database exports — and draws it.
Your readers step through with the buttons, the arrow keys, or by clicking any move they fancy. Comments, variations and annotation glyphs all come through, because the parser keeps them. It's about 37 kB over the wire, it fetches nothing else, and it's just as happy on a plain HTML page as inside a framework.
Reading the notation is done by tabnas, a parsing engine where grammars are data rather than code. You don't need to know any of that to use the board, so I've put it at the end — skip ahead if you're curious, ignore it entirely if you're not.
<!-- that board, in full --> <script src="https://cdn.jsdelivr.net/npm/@tabnas/chess-view@0.1.3"></script> <chess-view>1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 1/2-1/2</chess-view>
Install
Grab it from a CDN. Pin the version! The same URL without one follows whatever the latest release happens to be, which is lovely right up until the morning it isn't:
<script src="https://cdn.jsdelivr.net/npm/@tabnas/chess-view@0.1.3"></script>
<!-- or as a module -->
<script type="module"
src="https://cdn.jsdelivr.net/npm/@tabnas/chess-view@0.1.3/dist/chess-view.mjs"></script>
Or from npm, where it has no dependencies at all. None. The parser is bundled in, and so are the TypeScript declarations:
npm install @tabnas/chess-view
// importing registers the element
import '@tabnas/chess-view'
Every release ships the integrity hashes of the files it published, in
dist/sri.json, so you can put an integrity
attribute on that pinned <script> and know you got what you
asked for.
Server-side rendering? That's fine. Importing the package on a server does nothing at all — it doesn't touch the DOM, and it skips registration entirely when there's no custom element registry to register with. (This was a bug once. It isn't now.)
The options
Ten attributes, and that's the whole surface — no config object, no init call, nothing to wire up. The last four are subtractive: each one takes a piece of the UI away, and the rest closes up around the gap.
| Attribute | Values | What it does |
|---|---|---|
orientation | white, black | Which side is at the bottom. |
ply | a number | Which move to open at. 0 is the starting position. |
game | a number | Which game of a multi-game database to show. |
theme | auto, dark | auto follows the reader's system setting. |
source | hidden, view, edit | Show the notation itself, read-only or editable. |
commentary | inline, panel, hidden | Where the annotator's prose goes. |
controls | visible, hidden | The navigation buttons. |
notation | visible, hidden | The whole side panel. |
tags | visible, hidden | The players-and-event header. |
coordinates | visible, hidden | The file and rank labels. |
source="edit" — let the reader type
Go on, type in it. The board re-parses on every keystroke.
Half-typed notation is the normal state of an editor, not a failure, so the board holds its position while you're mid-move instead of blinking out and back. Delete a bracket and see what it tells you.
commentary="panel" — prose in a box of its own
Plenty of parsers throw comments away. This one keeps them — both of the forms the standard defines. Inline, they read the way an annotated game reads on paper. In a panel, they follow the position instead, which is far better when there's a lot of them. (The notes below are mine, written for this page.)
orientation="black", and variations
Click a move inside a variation and you'll follow that variation — the arrow keys then walk it rather than the mainline. Home brings you back.
theme="dark", and starting from a position
Not every game starts at move one. A FEN tag says where play
begins, and the move numbers and the side to move come from the game
itself. Leave theme off entirely and the board follows your
reader's system setting, which is usually what you want.
Everything off, plus ply — just a diagram
Turn off the notation, the controls and the coordinates and what's left is a diagram, opened at whichever move you name. The arrow keys still work, mind — hiding the buttons hides the buttons, and nothing else.
<chess-view notation="hidden" controls="hidden" coordinates="hidden" ply="33"> 1. e4 e5 2. Nf3 d6 … </chess-view>
Controls
Buttons for start, previous, next, end and flip — and the same from the keyboard whenever the component has focus: ← → Home End f. Or just click any move in the notation and you're there.
From JavaScript
Want to do something when the reader moves through the game? There are two
events, and they both bubble, so you can listen on document
and forget about where the board actually is:
document.addEventListener('chess-move', (e) => {
e.detail.ply // how far in, 0 is the start
e.detail.move?.san // 'Nf3', or undefined at the start
})
// only when source="edit"
document.addEventListener('chess-source', (e) => {
if (e.detail.ok) save(e.detail.source)
else console.log(e.detail.error)
})
And on the element itself:
el.move | The move currently shown, or undefined. |
el.ply | How far into the current line the view is. |
el.source | The notation being shown. Settable. |
el.goto(n) | Show the nth move of the current line. |
el.load() | Re-read the source. Called automatically on change. |
If you're writing TypeScript, the declarations ship in the package and
they're wired into the editor's own model of HTML. So
querySelector('chess-view') gives you a
ChessViewElement with no cast, and that
e.detail above is typed. Nice.
Styling
The board lives in a shadow root, so your page styles can't reach in and break it — but that cuts both ways, and you probably do want to change the colours. Everything you'd reasonably want is a custom property, and the parts are exposed:
chess-view {
--size: 30rem; /* board width */
--board-light: #eeeed2;
--board-dark: #769656;
--accent: #4a7c59; /* the highlighted move */
}
chess-view::part(notation) { /* … also board, controls, moves,
commentary, source, editor, wrap */ }
When the notation is wrong
Sooner or later somebody pastes in something that isn't a game. When that happens, the message you get is written for a person holding a PGN file, not for a person debugging a grammar. You get the whole word that failed, where to find it, and — when that's the real problem — the bracket you forgot to close.
1. e4 zz
1. e4 (e5
What it will not do
Let's be clear about the boundaries. This reads notation and plays it onto a board. It is not a chess engine. It won't evaluate a position, it won't suggest a move, you can't drag the pieces, and it won't tell you a game was drawn by repetition or the fifty-move rule. If you need any of that, you need something else (and you can feed this one's output straight into it).
What about a move that's perfectly good notation but simply isn't legal here? That's a different kind of wrong from a syntax error, and it gets reported as one. The line stops, and the board says why:
Markup the standard never defined
Export a game from lichess or chess.com and you'll find [%clk]
markup in the comments. Is that in the 1994 standard? Nope. It comes from a
2001 supplement, which
also asks that these commands be stripped out before display — otherwise
every move of your game reads [%clk 0:03:00] where the
annotation should be. So they are stripped, and the handful that actually
mean something to a reader come back as a little chip instead.
So how does it read the notation?
If you just wanted a chessboard, you've got one — you can stop here with my blessing. This last part is for anyone wondering how the notation actually gets read, or who might need to teach a parser a different language next week.
Everything above runs on a grammar for PGN, and that grammar runs on tabnas. Here's the interesting bit: the grammar is data. It's a table that says, for each rule, which token opens which alternative and what to do about it. Nobody generated a parser from it, and nobody sat down and hand-wrote a recursive-descent function.
Don't take my word for it. Here's the whole rule that reads one element of a game — a move, a move number, a glyph, a comment, or a nested variation:
# A movetext element (PGN spec 8.2). rule: element: open: [ { s: '#SAN' a: '@move' g: 'elem,move' } { s: '#MVN' a: '@number' g: 'elem,number' } { s: '#NAG' a: '@nag' g: 'elem,nag' } { s: '#CMT' a: '@brace-comment' g: 'elem,comment' } { s: '#RMK' a: '@line-comment' g: 'elem,comment' } { s: '#OP' p: rav b: 1 u: { rav: true } g: 'elem,rav' } ] rule: element: close: [ { s: '#ELEM' r: element b: 1 g: 'elem,next' } # another element { s: '#EEND' b: 1 g: 'elem,end' } # hand back up ]
s is the token that picks the alternative, a is
an action, p is a rule to descend into, r one to
become, and b is how many tokens to hand back. That's most of
the vocabulary, honestly. And because the whole thing is data, you can
print it, diff it, generate it — or hand it to an agent and let it write
the grammar for you.
One table, two languages
The grammar lives in one file. The build embeds the same compiled JSON into the TypeScript package and the Go module — so the two implementations can't quietly drift apart, which is exactly the sort of drift you'd never notice until a user reports that one of them parses their file and the other doesn't.
TypeScript
import { parseGame } from '@tabnas/chess'
const game = parseGame('1. e4 e5 *')
game.moves[0].san // 'e4'
game.moves[0].piece // 'P'
Go
import chess "github.com/tabnas/chess/go"
db, _ := chess.Parse("1. e4 e5 *")
db[0].Moves[0].San // "e4"
db[0].Moves[0].Piece // "P"
And they're held to it. There's a shared set of test cases — plain tab-separated text — that both suites run against the same expected JSON. 351 assertions on the TypeScript side, the identical corpus on the Go side. Move one runtime and not the other, and the build tells you before it lands.
What comes back is plain data
No classes, no cycles, nothing to unwrap before you can use it. Every field is something the notation actually said — and nothing it didn't.
Which means the parser won't tell you which knight played
Nf3. It can't! It has no board. It records the piece and the
destination, because that's what was written down, and guessing the rest
would be making things up. Working out the actual knight is the component's
job, and it does that with a legal move generator checked against published
perft counts.
parseGame('1. e4 {Best by test.} e5 (1... c5) 1-0')
{
tags: {},
moves: [
{ san: 'e4', piece: 'P', to: 'e4', number: 1, side: 'w',
comments: [ { kind: 'brace', text: 'Best by test.' } ] },
{ san: 'e5', piece: 'P', to: 'e5', number: 1, side: 'b',
variations: [ { moves: [ { san: 'c5', piece: 'P', to: 'c5', … } ] } ] }
],
result: '1-0'
}
The error messages belong to the grammar
Remember those error messages further up? Here's where they come from. An engine that knows nothing about chess can only tell you that a character matched no active rule, which is true and useless. A grammar can do better, because it knows what the language is made of — so the grammar sets the wording, not the engine:
error: {
unexpected: 'not chess notation: {src}',
unterminated_comment: 'this comment is never closed',
unprintable: 'a tag value cannot contain a line break',
}
The diagram is generated from the grammar
One more consequence of the table being data: another tool can read it
back. Nobody drew this railroad diagram. @tabnas/railroad
generated it from the very same grammar the parser above is running, which
means it can't go stale.
Take it further
tabnas/chess
This component, the grammar behind it, both runtimes and the shared fixtures.
tabnas.dev
The engine itself: why grammars are data, how to write one, and a playground that runs the real parser.
Install
@tabnas/chess-view @tabnas/chess chess/go
Chess is just one grammar out of a set — JSON, JSONC, JSON5, jsonic, YAML and the rest are all the same shape of thing. So whichever language you need to read next, the work is a table, and you get to start from one that already runs.