<chess-view> a chessboard for any page

One script tag and you've got a chessboard. Paste in a game, and your readers can play through it. No dependencies, no build step, nothing else to download.

Free and open source, MIT licensed · GitHub · npm · built with tabnas, a parsing engine

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.

[Event "F/S Return Match"] [Site "Belgrade, Serbia JUG"] [Date "1992.11.04"] [White "Fischer, Robert J."] [Black "Spassky, Boris V."] [Result "1/2-1/2"] 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Nb8 10. d4 Nbd7 11. c4 c6 12. cxb5 axb5 13. Nc3 Bb7 14. Bg5 b4 15. Nb1 h6 16. Bh4 c5 17. dxe5 Nxe4 18. Bxe7 Qxe7 19. exd6 Qf6 20. Nbd2 Nxd6 21. Nc4 Nxc4 22. Bxc4 Nb6 23. Ne5 Rae8 24. Bxf7+ Rxf7 25. Nxf7 Rxe1+ 26. Qxe1 Kxf7 27. Qe3 Qg5 28. Qxg5 hxg5 29. b3 Ke6 30. a3 Kd6 31. axb4 cxb4 32. Ra5 Nd5 33. f3 Bc8 34. Kf2 Bf5 35. Ra7 g6 36. Ra6+ Kc5 37. Ke1 Nf4 38. g3 Nxh3 39. Kd2 Kb5 40. Rd6 Kc5 41. Ra6 Nf2 42. g4 Bd3 43. Re6 1/2-1/2
<!-- 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.

AttributeValuesWhat it does
orientationwhite, blackWhich side is at the bottom.
plya numberWhich move to open at. 0 is the starting position.
gamea numberWhich game of a multi-game database to show.
themeauto, darkauto follows the reader's system setting.
sourcehidden, view, editShow the notation itself, read-only or editable.
commentaryinline, panel, hiddenWhere the annotator's prose goes.
controlsvisible, hiddenThe navigation buttons.
notationvisible, hiddenThe whole side panel.
tagsvisible, hiddenThe players-and-event header.
coordinatesvisible, hiddenThe 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.

1. d4 d5 2. c4 {The Queen's Gambit.} e6 3. Nc3 Nf6 *

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.)

[Event "Opera Game"] [Site "Paris"] [Date "1858.??.??"] [White "Morphy, Paul"] [Black "Duke of Brunswick and Count Isouard"] [Result "1-0"] 1. e4 e5 2. Nf3 d6 {Philidor's Defence. Solid enough, but it shuts in the dark-squared bishop, and that bishop never does get out.} 3. d4 Bg4 {Pinning the knight rather than resolving the centre.} 4. dxe5 Bxf3 {Forced: after 4... dxe5 5. Qxd8+ Kxd8 Black has lost the right to castle.} 5. Qxf3 dxe5 6. Bc4 Nf6 7. Qb3 {One move, two attacks: the queen hits b7 while the bishop already looks at f7.} Qe7 8. Nc3 {Declining the b7 pawn — 8. Qxb7 Qb4+ trades queens, and White would rather keep them on.} c6 9. Bg5 b5 10. Nxb5 $1 {A piece for the open lines it buys.} cxb5 11. Bxb5+ Nbd7 12. O-O-O {Castling as a developing move: the rook arrives on the file of the pinned knight.} Rd8 13. Rxd7 Rxd7 14. Rd1 {Everything White owns now bears on d7.} Qe6 15. Bxd7+ Nxd7 16. Qb8+ $3 {Deflection. The knight has to leave the d-file, and the file is the game.} Nxb8 17. Rd8# 1-0

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.

[Event "Immortal Game"] [White "Anderssen, Adolf"] [Black "Kieseritzky, Lionel"] [Date "1851.06.21"] [Result "1-0"] 1. e4 e5 2. f4 {The King's Gambit: sharp, and out of fashion for a century.} exf4 3. Bc4 Qh4+ 4. Kf1 b5 $6 {The Bryan Counter-gambit.} (4... d6 {is the modern reply.} 5. Nc3) 5. Bxb5 Nf6 6. Nf3 Qh6 7. d3 Nh5 8. Nh4 Qg5 9. Nf5 c6 10. g4 Nf6 11. Rg1 $1 {A rook offered, and the attack begins.} cxb5 12. h4 Qg6 13. h5 Qg5 14. Qf3 Ng8 15. Bxf4 Qf6 16. Nc3 Bc5 17. Nd5 $3 Qxb2 18. Bd6 $3 Bxg1 19. e5 Qxa1+ 20. Ke2 Na6 21. Nxg7+ Kd8 22. Qf6+ $1 Nxf6 23. Be7# 1-0

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.

[SetUp "1"] [FEN "8/6P1/8/8/8/1k6/8/K7 w - - 0 51"] 51. g8=Q+ {The pawn queens with check along the long diagonal.} Kb4 52. Qb8+ Ka4 53. Qb2 {The queen cuts the king off; the white king walks up.} 1-0

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>
[White "Morphy, Paul"] [Black "Duke of Brunswick and Count Isouard"] 1. e4 e5 2. Nf3 d6 3. d4 Bg4 4. dxe5 Bxf3 5. Qxf3 dxe5 6. Bc4 Nf6 7. Qb3 Qe7 8. Nc3 c6 9. Bg5 b5 10. Nxb5 cxb5 11. Bxb5+ Nbd7 12. O-O-O Rd8 13. Rxd7 Rxd7 14. Rd1 Qe6 15. Bxd7+ Nxd7 16. Qb8+ Nxb8 17. Rd8# 1-0

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.moveThe move currently shown, or undefined.
el.plyHow far into the current line the view is.
el.sourceThe 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 zz

1. e4 (e5

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:

1. e4 e5 2. Nf3 Nc6 3. Qxh8 {No queen can reach h8 here.} 1-0

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.

[Event "Rated Blitz game"] [TimeControl "180+0"] 1. e4 { [%clk 0:03:00] [%eval 0.17] } e5 { [%clk 0:02:58] } 2. Nf3 { [%clk 0:02:57] [%eval 0.21] Book. } Nc6 { [%clk 0:02:55] } 3. Bb5 { [%clk 0:02:52] } a6 { [%clk 0:02:49] } 1/2-1/2

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.

pgngameitemgameitemgamegametagmovetextRESREStag[tagbody]tagbodyTGNSTmovetextelementelementSANMVNNAGCMTRMKravrav(movetext)moveSANTokensCMT — CMT tokenMVN — text matching /\d+(?![-/])(?:[ \t]*\.+)?/NAG — text matching /\$\d+/RES — text matching /(?:1-0|0-1|1\/2-1\/2|\*)(?![A-Za-z0-9_+#=:/-])/RMK — RMK tokenSAN — text matching /(?:(?<castle>O-O-O|O-O|0-0-0|0-0)|(?<piece>[KQRBNP])(?<dfile>[a-h])?(?<drank>[1-8])?(?<pcapture>x)?(?<pto>[a-h][1-8])|(?<pfile>[a-h])(?:x(?<pxfile>[a-h]))?(?<prank>[1-8])(?:=?(?<promotion>[QRBN]))?)(?<check>\+\+|[+#])?(?<annotation>!!|\?\?|!\?|\?!|!|\?)?(?![A-Za-z0-9_+#=:-])/ST — quoted string (e.g. "text")TGN — text matching /[A-Za-z0-9_]+/Ignored tokensCM — commentLN — newline (line break)SP — whitespace (spaces or tabs)
The chess grammar as a syntax diagram. Rounded boxes are tokens, square ones are rules.

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.