Study the Opera Game · Play a game
<chess-game> displays an annotated replay or a local two-player board.
<chess-note> adds Markdown commentary to a replay position. This literate
source pairs the implementation with explanations and working examples.
The wood and green felt take inspiration from Deep Green, Joachim Bondo’s chess app.
chess-note attaches a title and Markdown commentary to a position. Its ply
counts half-moves: 0 is the starting position, 1 follows White's first move,
and 2 follows Black's reply. A game shows the latest note at or before its
selected position.
<article class="pmx-chess-note" data-chess-note-ply="{{ ply }}">
<h3>{{ title }}</h3>
<div class="pmx-chess-note-body">
<slot />
</div>
</article>
.pmx-chess-note {
display: grid;
gap: 0.35rem;
}
.pmx-chess-note h3 {
margin: 0;
font-size: 1rem;
}
.pmx-chess-note-body > :first-child {
margin-block-start: 0;
}
.pmx-chess-note-body > :last-child {
margin-block-end: 0;
}
<chess-note ply="3" title="2. Nf3 · Develop with a threat">
The knight joins the center and attacks e5. Black must answer the threat
while White prepares to bring out another piece.
</chess-note>
2. Nf3 · Develop with a threat
By default, chess-game replays a PGN (Portable Game Notation) move list
alongside its score and notes. Set mode="play" for a board where two people
can make moves. Both modes use the same board and pieces.
<section
id="{{ id }}"
class="pmx-chess-game"
data-chess-game
data-chess-id="{{ id }}"
data-pmx-beats="explicit"
>
<div id="{{ id }}-stage" class="pmx-chess-game-stage" data-chess-stage>
<div class="pmx-chess-game-board-panel">
<div class="pmx-chess-board-frame">
<div
class="pmx-chess-board"
data-chess-board
role="grid"
aria-label="Annotated chess game board"
aria-rowcount="8"
aria-colcount="8"
></div>
</div>
<div class="pmx-chess-game-controls" aria-label="Chess game controls">
<button
type="button"
data-chess-action="first"
aria-label="First position"
>
First
</button>
<button
type="button"
data-chess-action="prev"
aria-label="Previous move"
>
Prev
</button>
<button type="button" data-chess-action="next" aria-label="Next move">
Next
</button>
<button
type="button"
data-chess-action="last"
aria-label="Last position"
>
Last
</button>
</div>
<p class="pmx-chess-game-status" aria-live="polite">
<strong data-chess-ply-label>Starting position</strong>
<span data-chess-status>Loading chess game</span>
</p>
</div>
<aside class="pmx-chess-game-analysis">
<div class="pmx-chess-game-moves" data-chess-moves aria-label="Moves">
<span class="pmx-chess-move-number">0.</span>
<button
class="pmx-chess-game-start"
type="button"
data-chess-game-move
data-chess-ply="0"
data-pmx-beat="{{ id }}:ply-0"
data-pmx-play-target="#{{ id }}-stage"
>
Starting position
</button>
</div>
<div class="pmx-chess-game-note" data-chess-note-panel>
Select a move to see analysis.
</div>
</aside>
</div>
<div hidden data-chess-content>
<slot />
</div>
</section>
The board pairs pale maple with dark walnut. Local textures supply the grain; layered highlights and shadows create the frame and recessed playing surface. A fine green felt texture surrounds the board and replay controls. The analysis panels use the site's theme colors.
.pmx-chess-game {
--chess-wood: #6b442e;
--chess-wood-dark: #322016;
--chess-light: #ead8b7;
--chess-dark: #79563d;
--chess-accent: #648779;
--chess-maple: url("./assets/maple.webp");
--chess-walnut: url("./assets/walnut.webp");
--chess-light-square-image: linear-gradient(#f4e6cae0, #f4e6cabc), var(--chess-maple);
--chess-dark-square-image: linear-gradient(#8e6c4d80, #8e6c4d60), var(--chess-walnut);
display: block;
margin-block: 1.5rem;
}
.pmx-chess-game-stage {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(12rem, 1fr);
gap: clamp(1rem, 3vw, 1.5rem);
align-items: start;
}
.pmx-chess-game-board-panel,
.pmx-chess-game-analysis {
display: grid;
gap: 0.8rem;
min-inline-size: 0;
}
.pmx-chess-game-board-panel {
padding: clamp(10px, 1.5vw, 18px);
border-radius: 16px;
background: linear-gradient(#09271b33, #061a1466), url("./assets/felt.webp");
background-size: auto, 320px;
background-color: #153b2c;
border: 1px solid #183e30;
box-shadow: inset 0 1px #ffffff18, inset 0 0 18px #04130c44, 0 12px 24px -18px #0008;
}
.pmx-chess-game-board-panel .pmx-chess-game-controls {
background: #061c1466;
border-color: #ffffff1c;
}
.pmx-chess-game-board-panel .pmx-chess-game-controls button { color: #edf4ee; }
.pmx-chess-game-board-panel .pmx-chess-game-controls button:hover:not(:disabled) {
background: #ffffff14;
}
.pmx-chess-game-board-panel .pmx-chess-game-status { color: #bed1c5; }
.pmx-chess-game-board-panel .pmx-chess-game-status strong { color: #f0f5ef; }
.pmx-chess-board-frame {
padding: clamp(12px, 2vw, 20px);
border: 1px solid #3c281b;
border-radius: 10px;
background: linear-gradient(135deg, #d1a17033, #130b0644), var(--chess-walnut);
background-size: auto, 480px;
box-shadow: inset 0 1px 1px #e4c5a480, inset 0 0 0 4px #39241766,
0 3px 0 #2e1d14, 0 7px 10px #0002, 0 22px 32px -16px #0006;
}
.pmx-chess-board {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
grid-template-rows: repeat(8, minmax(0, 1fr));
aspect-ratio: 1;
overflow: hidden;
border: 1px solid color-mix(in oklch, black 34%, var(--chess-wood-dark));
border-radius: 2px;
background: var(--chess-dark);
box-shadow: 0 0 0 1px #d9b78780, 0 0 0 3px #21130bb3;
}
.pmx-chess-square {
position: relative;
display: grid;
place-items: center;
min-inline-size: 0;
min-block-size: 0;
color: #2f2118;
background-color: var(--chess-light);
background-image: var(--chess-light-square-image);
background-size: auto, 240px 240px;
}
.pmx-chess-square[data-square-color="dark"] {
color: #f9ecd2;
background-color: var(--chess-dark);
background-image: var(--chess-dark-square-image);
}
.pmx-chess-square[data-last-move="true"] {
box-shadow: inset 0 0 0 999px
color-mix(in oklch, var(--chess-accent) 22%, transparent);
}
.pmx-chess-square::before,
.pmx-chess-square::after {
position: absolute;
z-index: 2;
font-family: var(--pmx-font-mono);
font-size: clamp(0.48rem, 1.5vw, 0.66rem);
line-height: 1;
opacity: 0.62;
pointer-events: none;
}
.pmx-chess-square::before {
content: attr(data-rank-label);
inset-block-start: 0.25rem;
inset-inline-start: 0.25rem;
}
.pmx-chess-square::after {
content: attr(data-file-label);
inset-block-end: 0.25rem;
inset-inline-end: 0.25rem;
}
.pmx-chess-piece {
position: relative;
z-index: 1;
inline-size: 92%;
block-size: 92%;
object-fit: contain;
pointer-events: none;
filter: drop-shadow(
1px 3px 1.5px #160e0a66
);
transform: translateY(-1.5%);
}
.pmx-chess-game-controls {
display: flex;
gap: 4px;
padding: 4px;
border: 1px solid var(--pmx-color-border);
border-radius: 12px;
background: var(--pmx-color-surface);
}
.pmx-chess-game-controls button,
.pmx-chess-game-moves button {
box-sizing: border-box;
border: 1px solid transparent;
border-radius: 8px;
color: var(--pmx-color-fg);
background: transparent;
cursor: pointer;
font: inherit;
}
.pmx-chess-game-controls button {
flex: 1;
min-block-size: 2.75rem;
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
font-weight: 600;
}
.pmx-chess-game-controls button:hover:not(:disabled),
.pmx-chess-game-moves button:hover:not(:disabled) {
background: color-mix(
in oklch,
var(--chess-accent) 14%,
var(--pmx-color-surface)
);
}
.pmx-chess-game button:focus-visible {
outline: 2px solid var(--pmx-color-focus);
outline-offset: 2px;
}
.pmx-chess-game-controls button:disabled { opacity: 0.35; cursor: default; }
.pmx-chess-game-status {
display: grid;
gap: 0.1rem;
margin: 0;
color: var(--pmx-color-muted);
}
.pmx-chess-game-status strong {
color: var(--pmx-color-fg);
}
.pmx-chess-game-moves {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) minmax(0, 1fr);
gap: 2px 6px;
align-items: center;
max-block-size: 20rem;
overflow: auto;
padding: 8px;
border: 1px solid var(--pmx-color-border);
border-radius: 12px;
background: var(--pmx-color-bg);
scrollbar-width: thin;
scrollbar-color: var(--pmx-color-border) transparent;
}
.pmx-chess-score-heading {
position: sticky;
top: -8px;
z-index: 1;
padding: 10px 8px;
background: var(--pmx-color-bg);
border-bottom: 1px solid var(--pmx-color-border);
color: var(--pmx-color-muted);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
}
.pmx-chess-move-number {
color: var(--pmx-color-muted);
font-family: var(--pmx-font-mono);
font-size: 0.78rem;
padding-inline: 8px;
font-variant-numeric: tabular-nums;
}
.pmx-chess-game-moves button {
min-inline-size: 0;
min-block-size: 2.5rem;
padding: 8px;
text-align: start;
font-family: var(--pmx-font-mono);
font-size: 0.88rem;
border-radius: 6px;
}
.pmx-chess-game-moves .pmx-chess-game-start {
grid-column: span 2;
font-family: var(--pmx-font-body);
font-size: 0.8rem;
}
.pmx-chess-game-moves button[aria-current="step"] {
border-color: transparent;
font-weight: 650;
background: color-mix(
in oklch,
var(--chess-accent) 24%,
var(--pmx-color-surface)
);
}
.pmx-chess-game-note {
order: -1;
min-block-size: 7rem;
padding: 1rem;
font-size: 0.92rem;
line-height: 1.6;
border: 1px solid var(--pmx-color-border);
border-radius: 12px;
background: var(--pmx-color-surface);
}
.pmx-chess-game-note > :first-child {
margin-block-start: 0;
}
.pmx-chess-game-note > :last-child {
margin-block-end: 0;
}
.pmx-chess-game[data-chess-mode="play"] > .pmx-chess-game-stage { opacity: 1; }
.pmx-chess-game[data-chess-mode="play"] .pmx-chess-game-controls { grid-template-columns: 1fr 1fr; }
.pmx-chess-game[data-chess-mode="play"] [data-chess-action][hidden] { display: none; }
.pmx-chess-game[data-chess-mode="play"] .pmx-chess-square { cursor: pointer; }
.pmx-chess-game-moves button:disabled { cursor: default; }
.pmx-chess-square[aria-selected="true"] { box-shadow: inset 0 0 0 3px #f2d08a; }
.pmx-chess-square[data-legal="true"] { box-shadow: inset 0 0 0 4px #73a88b; }
.pmx-chess-square:focus-visible { outline: 3px solid #fff; outline-offset: -4px; z-index: 3; }
.pmx-chess-promotion {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.pmx-chess-promotion[hidden] { display: none; }
.pmx-chess-promotion button {
min-height: 44px;
flex: 1;
border: 1px solid #ffffff44;
border-radius: 8px;
color: #fff;
background: #163b2e;
cursor: pointer;
}
@media (max-width: 47.5rem) {
.pmx-chess-game { margin-block: 1rem; }
.pmx-chess-game-stage {
grid-template-columns: 1fr;
gap: 0.8rem;
}
.pmx-chess-board-frame {
padding: 0.55rem;
border-radius: 0.55rem;
box-shadow:
inset 0 0 0 0.18rem color-mix(in oklch, white 16%, transparent),
inset 0 0 0.8rem color-mix(in oklch, black 24%, transparent),
0 0.8rem 1.4rem color-mix(in oklch, black 18%, transparent);
}
.pmx-chess-game-controls {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.35rem;
}
.pmx-chess-game-controls button {
min-inline-size: 0;
padding-inline: 0.25rem;
}
.pmx-chess-game-status {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: baseline;
}
.pmx-chess-game-status span {
text-align: end;
}
.pmx-chess-game-analysis {
gap: 0.65rem;
}
.pmx-chess-game-note {
order: -1;
min-block-size: 0;
}
.pmx-chess-game-moves {
max-block-size: 13rem;
padding: 0.5rem;
}
.pmx-chess-game-moves button {
min-block-size: 2.75rem;
}
html[data-pmx-play-active] .pmx-chess-game-moves {
display: none;
}
html[data-pmx-play-active] .pmx-chess-game {
padding-block-end: calc(4.5rem + env(safe-area-inset-bottom));
}
}
The browser parses the PGN, renders its positions, and synchronizes the selected move with its note. Piece movement uses a short animation; reduced-motion preferences switch positions immediately.
The board creates its 64 HTML square elements once, then updates the pieces, labels, and highlights inside them as the position changes. Images of unchanged pieces stay in place, and the commentary is replaced only when a different note applies. Keeping these elements lets the browser preserve keyboard focus and avoids rebuilding the board after every move or selection.
const PIECES = {
wp: ["wP.svg", "White pawn"],
wn: ["wN.svg", "White knight"],
wb: ["wB.svg", "White bishop"],
wr: ["wR.svg", "White rook"],
wq: ["wQ.svg", "White queen"],
wk: ["wK.svg", "White king"],
bp: ["bP.svg", "Black pawn"],
bn: ["bN.svg", "Black knight"],
bb: ["bB.svg", "Black bishop"],
br: ["bR.svg", "Black rook"],
bq: ["bQ.svg", "Black queen"],
bk: ["bK.svg", "Black king"],
}
const board = $("[data-chess-board]")
const stage = $("[data-chess-stage]")
const movesList = $("[data-chess-moves]")
const status = $("[data-chess-status]")
const plyLabel = $("[data-chess-ply-label]")
const notePanel = $("[data-chess-note-panel]")
const content = $("[data-chess-content]")
const gameId = (el.dataset.chessId || "").trim()
function fail(message) {
if (status) status.textContent = message
el.dataset.chessError = "true"
}
if (!gameId) {
fail("Chess game requires an id for stable Player Beats.")
} else {
const orientation = (attrs.orientation || "white").toLowerCase()
const piecesBase = attrs.pieces || "./assets/pieces/cburnett"
const chessModule = attrs["chess-module"] || "./assets/chess-1.4.0.js"
const playable = attrs.mode === "play"
if (playable) {
el.dataset.chessMode = "play"
el.dataset.pmxBeat = gameId
board.setAttribute("aria-label", "Playable chess board")
}
const initialPly = Number.parseInt(attrs["initial-ply"] || "0", 10) || 0
const stageId = stage.id || gameId + "-stage"
const playTarget = "#" + CSS.escape(stageId)
stage.id = stageId
function squaresForBoard() {
const files =
orientation === "black"
? ["h", "g", "f", "e", "d", "c", "b", "a"]
: ["a", "b", "c", "d", "e", "f", "g", "h"]
const ranks =
orientation === "black"
? ["1", "2", "3", "4", "5", "6", "7", "8"]
: ["8", "7", "6", "5", "4", "3", "2", "1"]
return ranks.flatMap(function (rank, row) {
return files.map(function (file, column) {
return { square: file + rank, file, rank, row, column }
})
})
}
function pieceKey(piece) {
return piece.color + piece.type
}
function squareColor(square) {
const file = square.charCodeAt(0) - 97
const rank = Number(square.slice(1)) - 1
return (file + rank) % 2 === 0 ? "dark" : "light"
}
function pieceUrl(key) {
const filename = PIECES[key][0]
const base = piecesBase.endsWith("/") ? piecesBase : piecesBase + "/"
return new URL(base + filename, location.href).href
}
function moveLabel(ply, moves) {
if (ply === 0) return "Starting position"
const move = moves[ply - 1]
const number = move.before.split(" ")[5]
return (move.color === "w" ? number + ". " : number + "... ") + move.san
}
function payloadText(element) {
if (!element) return ""
if (element.content && typeof element.content.textContent === "string") {
return element.content.textContent
}
return element.textContent || ""
}
import(new URL(chessModule, location.href).href)
.then(async function (module) {
if (!el.isConnected) return
const Chess = module.Chess
const pgnElement = content && content.querySelector("[data-chess-pgn]")
const parser = new Chess()
try {
if (playable && attrs.fen) parser.load(attrs.fen)
else if (!playable) parser.loadPgn(payloadText(pgnElement).trim(), { strict: false })
} catch (error) {
fail((playable ? "Could not load position: " : "Could not parse PGN: ") + error.message)
return
}
let moves = parser.history({ verbose: true })
const startFen = moves[0] ? moves[0].before : parser.fen()
let positions = [{ fen: startFen }].concat(
moves.map(function (move) {
return { fen: move.after, move }
}),
)
const notes = Array.from(
content ? content.querySelectorAll("[data-chess-note-ply]") : [],
)
.map(function (note) {
return {
ply: Number.parseInt(note.dataset.chessNotePly || "0", 10) || 0,
content: note.cloneNode(true),
}
})
.sort(function (left, right) {
return left.ply - right.ply
})
const squares = squaresForBoard()
let ply = Math.max(0, Math.min(moves.length, initialPly))
let pieceAnimation = null
let input = null
let renderedNote = null
function settlePieceAnimation() {
if (!pieceAnimation) return
const animation = pieceAnimation
pieceAnimation = null
try {
animation.finish()
} catch {
animation.cancel()
}
delete el.dataset.chessAnimating
}
cleanup(function () {
if (pieceAnimation) pieceAnimation.cancel()
})
function preparePieceAnimation(nextPly) {
if (
Math.abs(nextPly - ply) !== 1 ||
matchMedia("(prefers-reduced-motion: reduce)").matches
) {
return null
}
const move = nextPly > ply ? moves[nextPly - 1] : moves[ply - 1]
if (!move) return null
const from = nextPly > ply ? move.from : move.to
const to = nextPly > ply ? move.to : move.from
const piece = board.querySelector(
'[data-square="' + from + '"] .pmx-chess-piece',
)
if (!piece) return null
return { from: piece.getBoundingClientRect(), to }
}
function startPieceAnimation(prepared) {
if (!prepared || typeof Element.prototype.animate !== "function") return
const piece = board.querySelector(
'[data-square="' + prepared.to + '"] .pmx-chess-piece',
)
if (!piece) return
const target = piece.getBoundingClientRect()
const x = prepared.from.left - target.left
const y = prepared.from.top - target.top
if (Math.abs(x) < 0.5 && Math.abs(y) < 0.5) return
el.dataset.chessAnimating = "true"
const animation = piece.animate(
[
{
transform:
"translate(" + x + "px, " + y + "px) translateY(-1.5%)",
},
{ transform: "translate(0, 0) translateY(-1.5%)" },
],
{
duration: 360,
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
},
)
pieceAnimation = animation
const finish = function () {
if (pieceAnimation === animation) pieceAnimation = null
delete el.dataset.chessAnimating
}
animation.onfinish = finish
animation.oncancel = finish
}
function renderBoard() {
const position = positions[ply]
const chess = new Chess(position.fen)
const lastMove = ply > 0 ? moves[ply - 1] : null
squares.forEach(function (entry, index) {
const piece = chess.get(entry.square)
const square = board.children[index] || document.createElement("div")
square.className = "pmx-chess-square"
square.dataset.square = entry.square
square.dataset.squareColor = squareColor(entry.square)
square.setAttribute("role", "gridcell")
square.setAttribute("aria-rowindex", String(entry.row + 1))
square.setAttribute("aria-colindex", String(entry.column + 1))
if (entry.column === 0) square.dataset.rankLabel = entry.rank
if (entry.row === 7) square.dataset.fileLabel = entry.file
if (
lastMove &&
(lastMove.from === entry.square || lastMove.to === entry.square)
) {
square.dataset.lastMove = "true"
} else {
delete square.dataset.lastMove
}
const currentImage = square.querySelector("img")
if (piece) {
const key = pieceKey(piece)
const image = currentImage || document.createElement("img")
image.className = "pmx-chess-piece"
image.dataset.chessPieceImage = key
const src = pieceUrl(key)
if (image.src !== src) image.src = src
image.alt = PIECES[key][1]
image.draggable = false
if (!currentImage) square.append(image)
square.setAttribute(
"aria-label",
entry.square + ", " + PIECES[key][1],
)
} else {
currentImage?.remove()
square.setAttribute("aria-label", entry.square + ", empty")
}
if (!square.parentNode) board.append(square)
})
input?.paint()
}
function createMoveButton(movePly, label) {
const button = document.createElement("button")
button.type = "button"
button.textContent = label
button.dataset.chessGameMove = ""
button.dataset.chessPly = String(movePly)
if (!playable) button.dataset.pmxBeat = gameId + ":ply-" + movePly
button.dataset.pmxBeatLabel = label
button.setAttribute("aria-label", moveLabel(movePly, moves))
button.disabled = playable
button.dataset.pmxPlayTarget = playTarget
if (movePly === 0) button.className = "pmx-chess-game-start"
return button
}
function renderMoves() {
const fragment = document.createDocumentFragment()
for (const label of ["№", "White", "Black"]) {
const heading = document.createElement("span")
heading.className = "pmx-chess-score-heading"
heading.textContent = label
heading.setAttribute("aria-hidden", "true")
fragment.append(heading)
}
const zero = document.createElement("span")
zero.className = "pmx-chess-move-number"
zero.textContent = "0."
fragment.append(zero, createMoveButton(0, "Starting position"))
moves.forEach(function (move, index) {
const movePly = index + 1
if (move.color === "w" || index === 0) {
const number = document.createElement("span")
number.className = "pmx-chess-move-number"
number.textContent = move.before.split(" ")[5] + "."
fragment.append(number)
if (move.color === "b") fragment.append(document.createElement("span"))
}
fragment.append(createMoveButton(movePly, move.san))
})
movesList.replaceChildren(fragment)
}
function renderNote() {
const note = notes
.filter(function (candidate) {
return candidate.ply <= ply
})
.pop()
if (note === renderedNote) return
renderedNote = note
notePanel.hidden = !note
notePanel.replaceChildren(...(note ? [note.content.cloneNode(true)] : []))
}
function revealCurrentMove(button) {
const listRect = movesList.getBoundingClientRect()
const buttonRect = button.getBoundingClientRect()
const top = buttonRect.top - listRect.top + movesList.scrollTop
const bottom = top + buttonRect.height
if (top < movesList.scrollTop) movesList.scrollTop = top
else if (bottom > movesList.scrollTop + movesList.clientHeight) {
movesList.scrollTop = bottom - movesList.clientHeight
}
}
function renderState() {
renderBoard()
renderNote()
const label = moveLabel(ply, moves)
if (plyLabel) plyLabel.textContent = label
if (status) {
const currentPosition = playable ? parser : new Chess(positions[ply].fen)
const side = currentPosition.turn() === "w" ? "White" : "Black"
status.textContent = currentPosition.isCheckmate()
? (currentPosition.turn() === "w" ? "Black" : "White") + " wins · Checkmate"
: currentPosition.isStalemate() ? "Draw · Stalemate"
: currentPosition.isDraw() ? "Draw"
: side + " to move" + (currentPosition.isCheck() ? " · Check" : "")
}
const current = movesList.querySelector(
'[data-chess-ply="' + ply + '"]',
)
Array.from(
movesList.querySelectorAll("[data-chess-game-move]"),
).forEach(function (button) {
button.setAttribute("aria-current", button === current ? "step" : "false")
})
if (current) revealCurrentMove(current)
el.querySelectorAll("[data-chess-action]").forEach(function (button) {
const first = ["first", "prev"].includes(button.dataset.chessAction)
button.disabled = playable ? moves.length === 0 : first ? ply === 0 : ply === moves.length
})
}
function applyPly(value) {
const nextPly = Math.max(0, Math.min(moves.length, value))
if (nextPly === ply) return
settlePieceAnimation()
const prepared = preparePieceAnimation(nextPly)
ply = nextPly
renderState()
startPieceAnimation(prepared)
}
function requestPly(value) {
const target = Math.max(0, Math.min(moves.length, value))
const button = movesList.querySelector(
'[data-chess-ply="' + target + '"]',
)
if (button) button.click()
}
function refreshPlay(history = true) {
if (history) {
moves = parser.history({ verbose: true })
positions = [{ fen: startFen }, ...moves.map(move => ({ fen: move.after, move }))]
ply = moves.length
renderMoves()
}
renderState()
}
if (playable) {
const { enablePlay } = await import(new URL("./assets/chess-play.js", location.href).href)
if (!el.isConnected) return
input = enablePlay({ board, chess: parser, on, cleanup, changed: refreshPlay })
const controls = el.querySelector(".pmx-chess-game-controls")
controls.querySelector('[data-chess-action="first"]').textContent = "Reset"
controls.querySelector('[data-chess-action="first"]').setAttribute("aria-label", "Reset board")
controls.querySelector('[data-chess-action="prev"]').textContent = "Undo"
controls.querySelector('[data-chess-action="prev"]').setAttribute("aria-label", "Undo move")
controls.querySelector('[data-chess-action="next"]').hidden = true
controls.querySelector('[data-chess-action="last"]').hidden = true
}
renderMoves()
renderState()
on(el, "click", function (event) {
const action = event.target.closest("[data-chess-action]")
if (action && el.contains(action)) {
if (playable) {
if (action.dataset.chessAction === "first") parser.load(startFen)
else if (action.dataset.chessAction === "prev") parser.undo()
input.clear()
refreshPlay()
return
}
if (action.dataset.chessAction === "first") requestPly(0)
if (action.dataset.chessAction === "prev") requestPly(ply - 1)
if (action.dataset.chessAction === "next") requestPly(ply + 1)
if (action.dataset.chessAction === "last") requestPly(moves.length)
return
}
const move = event.target.closest("[data-chess-game-move]")
if (!move || !movesList.contains(move)) return
if (!playable) applyPly(Number.parseInt(move.dataset.chessPly || "0", 10) || 0)
})
on(el, "player:beat-enter", function (event) {
const beat = event.target
if (!beat.matches || !beat.matches("[data-chess-game-move]")) return
applyPly(Number.parseInt(beat.dataset.chessPly || "0", 10) || 0)
})
el.dataset.chessReady = "true"
document.dispatchEvent(new Event("pmx:beats"))
})
.catch(function (error) {
fail("Could not load chess.js: " + error.message)
})
}
In replay mode, each position is a Player Beat. Move buttons use
data-pmx-beat and share a data-pmx-play-target so the board and note stay
together in Focus and Slides. Play mode uses one Beat for the whole game.
The component owns aria-current on every move button: step for the current
position and false for the others. Player preserves these authored values,
so the current position remains accessible when playback stops.
The pieces and optional chess-module URLs resolve relative to the page
using the component. The bundled rules library is chess.js@1.4.0.
Put the PGN inside <template data-chess-pgn>. Give each game a unique id
for its Player links.
<chess-game id="opening-example" pieces="./assets/pieces/wood">
<template data-chess-pgn>
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *
</template>
</chess-game>
Starting position Loading chess game
Select a piece, then a highlighted square to move. On a keyboard, Tab to the board and use arrow keys to explore; Enter or Space selects, and Escape cancels. When a pawn reaches the last rank, choose its promotion piece.
Undo takes back one move. Reset or a page refresh starts over.
The optional fen attribute accepts a FEN (Forsyth–Edwards Notation) starting
position for a puzzle or endgame.
The play input module handles selection and keyboard input; chess.js validates moves and tracks the position history.
<chess-game id="practice-example" mode="play" pieces="./assets/pieces/wood" />
Starting position Loading chess game
Import the components, add a PGN score, and place chess-note elements at
the positions you want to explain. Here is the complete Opera Game.
This file imports itself as @preview to render its own examples. On another
page, use an import like @chess below.
[@chess]: ./chess.components.md
<chess-game
id="opera-study"
orientation="white"
pieces="./assets/pieces/wood"
>
<template data-chess-pgn>
[Event "A Night at the Opera"]
[Site "Paris FRA"]
[Date "1858.??.??"]
[Round "?"]
[White "Paul Morphy"]
[Black "Duke Karl / Count Isouard"]
[Result "1-0"]
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
</template>
<chess-note ply="0" title="Bring the pieces into play">
Watch White's pieces enter the game while Black's king stays in the center.
Morphy turns that lead in development into a direct attack.
</chess-note>
<chess-note ply="7" title="4. dxe5">
White captures on e5, clearing the d-file of White's pawn. After Black
exchanges the bishop for the knight on f3, White recaptures with the queen.
</chess-note>
<chess-note ply="13" title="7. Qb3">
The queen attacks b7 and f7. Black replies with `Qe7`, placing the queen
in front of the bishop on f8.
</chess-note>
<chess-note ply="19" title="10. Nxb5">
Morphy sacrifices a knight to open a line toward the king. After `cxb5`,
the bishop takes on b5 with check, forcing Black to respond.
</chess-note>
<chess-note ply="23" title="12. O-O-O">
Castling brings the king to safety and the rook to d1. The rook now
attacks down the open d-file.
</chess-note>
<chess-note ply="29" title="15. Bxd7+">
The bishop captures the rook with check. After `Nxd7`, Morphy offers
his queen on b8 to draw the knight away from d8.
</chess-note>
<chess-note ply="33" title="17. Rd8#">
The rook delivers checkmate on d8, protected by the bishop on g5.
Those two pieces finish the attack after the queen sacrifice.
</chess-note>
</chess-game>
Starting position Loading chess game
Bring the pieces into play
4. dxe5
7. Qb3
Qe7, placing the queen
in front of the bishop on f8.
10. Nxb5
cxb5,
the bishop takes on b5 with check, forcing Black to respond.
12. O-O-O
15. Bxd7+
Nxd7, Morphy offers
his queen on b8 to draw the knight away from d8.