Component recipes
Patterns to copy. Not packaged components — concrete CSS recipes that any project re-implements in its own markup. The shared design vocabulary lives in tokens.css / chess-tokens.css; the patterns below show how to compose those tokens.
Each recipe is a pure-CSS snippet plus brief notes on when to use it. Designed to be copy-paste-and-adapt.
Raised panel
Section titled “Raised panel”The dominant surface treatment. Use for any “container that holds focused content”: a card, a modal section, a side rail.
.raised-panel { background: var(--raised-panel-bg); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm);}When to combine with overflow: hidden + nested scroll: the panel clips at the rounded corners; an inner element does the scrolling. Pattern:
<div class="raised-panel scroll-wrap"> <div class="scroll-area"> <!-- many rows here --> </div></div>.scroll-wrap { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }.scroll-area { flex: 1; min-height: 0; overflow-y: auto; }Result chip (win / loss / draw)
Section titled “Result chip (win / loss / draw)”For a chess game outcome, never just a colored dot — use a tinted pill with a colored border and matching text:
.result { display: inline-flex; align-items: center; padding: 0.15rem 0.55rem; border-radius: var(--radius-pill); border: 1px solid; font-size: 0.75rem; font-weight: 600; font-variant-numeric: tabular-nums;}.result-win { background: var(--result-win-bg); border-color: var(--result-win-border); color: var(--result-win-text); }.result-loss { background: var(--result-lose-bg); border-color: var(--result-lose-border); color: var(--result-lose-text); }.result-draw { background: var(--result-draw-bg); border-color: var(--result-draw-border); color: var(--text-subtle); }The tinted-bg + colored-border + colored-text combination is more legible than any single signal alone. Borders matter: in low contrast environments, the bg tint can wash out, but the border holds up.
Sticky-stacked table headers (the standings pattern)
Section titled “Sticky-stacked table headers (the standings pattern)”A table with a sticky column header AND sticky group rows that replace each other as you scroll. Each section in its own <tbody>; sticky group rows stick within their tbody only.
<table class="sticky-stack"> <thead> <tr><th>#</th><th>Player</th><th>Rating</th>…</tr> </thead> <tbody> <tr class="group-row"><td colspan="N">Section A</td></tr> <tr><td>1</td>…</tr> <tr><td>2</td>…</tr> </tbody> <tbody> <tr class="group-row"><td colspan="N">Section B</td></tr> … </tbody></table>.sticky-stack thead th { position: sticky; top: 0; z-index: 3; /* Opaque! transparent backgrounds let scrolled rows bleed through. */ background: color-mix(in srgb, var(--modal-bg), white 6%);}.sticky-stack .group-row td { position: sticky; top: 2rem; /* sits below the column header */ z-index: 2; background: color-mix(in srgb, var(--modal-bg), white 6%);}The trick is: per-section tbodies. Without them, all group rows stick at the same offset and stack on top of each other. With them, each group row’s containing block is its own tbody, so it un-sticks when the next tbody scrolls into view — naturally replaced.
Subtle accent stripe on alternating rows
Section titled “Subtle accent stripe on alternating rows”The PGN-move-table pattern. 3.5% of the accent color produces an almost-invisible row stripe that aids scanning without dominating.
.striped tbody tr:nth-child(odd):not(.group-row) { background: rgb(from var(--accent) r g b / 0.035);}Don’t go above 5%. The point is to feel the rhythm without seeing it.
NAG-colored move
Section titled “NAG-colored move”Move list moves take their text color from the NAG attached to them. Lichess-style — so any reader recognizes the convention.
<span class="move" data-nag="3">e4!!</span> <!-- brilliant --><span class="move" data-nag="1">Nf3!</span> <!-- good --><span class="move" data-nag="2">Bg5?</span> <!-- mistake --><span class="move" data-nag="6">Qd2?!</span> <!-- dubious -->.move[data-nag="3"] { color: var(--nag-brilliant); }.move[data-nag="1"] { color: var(--nag-good); }.move[data-nag="5"] { color: var(--nag-interesting); }.move[data-nag="6"] { color: var(--nag-dubious); }.move[data-nag="2"] { color: var(--nag-mistake); }.move[data-nag="4"] { color: var(--nag-blunder); }The NAG numeric codes follow PGN convention ($1=!, $2=?, $3=!!, $4=??, $5=!?, $6=?!).
Move-table grid (PGN with variations)
Section titled “Move-table grid (PGN with variations)”Three-column grid for the move list: move number, white move, black move. Variations indented as a fourth row that spans all columns.
.move-table { display: grid; grid-template-columns: 2rem 1fr 1fr; gap: 0; width: 100%;}.move-table .move-num { color: var(--text-faint); font-size: 0.8em; text-align: right; padding: 0.2rem 0.4rem 0.2rem 0; user-select: none;}.move-table .move { padding: 0.2rem 0.4rem; border-radius: 3px; cursor: pointer; white-space: nowrap;}.move-table .move:hover { background: rgb(from var(--accent) r g b / 0.15); }.move-table .move-current { background: rgb(from var(--accent) r g b / 0.35); }.move-table .mt-comment { grid-column: 1 / -1; color: var(--text-faint); font-style: italic; font-size: 0.85em; padding: 0.15rem 0.4rem 0.3rem 2.4rem;}Branch popover
Section titled “Branch popover”When the user navigates forward from a node with multiple children, show an inline popover listing the choices. Position above or beside the current move — never floating without anchor.
.branch-popover { position: absolute; background: var(--popup-bg); border-radius: var(--radius-sm); box-shadow: var(--shadow-md); padding: 0.4rem 0; min-width: 180px; z-index: 1000;}.branch-option { padding: 0.35rem 0.85rem; cursor: pointer; display: flex; align-items: center; gap: 0.5rem;}.branch-option-selected,.branch-option:hover { background: rgb(from var(--accent) r g b / 0.18);}.branch-option .move { font-weight: 600; }.branch-option .preview { color: var(--text-faint); font-size: 0.85em; }.branch-option.branch-main .move { font-weight: 700; }Keyboard interaction: ↑/↓ navigates, Enter or → selects, Esc or ← dismisses + goes back.
Tab bar with active gradient
Section titled “Tab bar with active gradient”The tab strip at the top of the modal. The active tab’s “indicator” is a gradient sweep that follows the active button’s position, not a hard border.
<div class="tab-host"> <div class="tab-bar"></div> <div class="tab-strip"> <button class="tab tab-active">…</button> <button class="tab">…</button> <button class="tab">…</button> </div></div>.tab-host { --active-x: 0px; --active-w: 0px; position: relative;}.tab-bar { position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: rgb(from var(--accent) r g b / 0.3); pointer-events: none; /* Active sweep: visible only over the active tab's footprint. */ background: linear-gradient(to right, transparent calc(var(--active-x)), var(--accent) calc(var(--active-x)), var(--accent) calc(var(--active-x) + var(--active-w)), transparent calc(var(--active-x) + var(--active-w))), rgb(from var(--accent) r g b / 0.15); transition: background 0.2s ease;}Update --active-x and --active-w when the active tab changes — read from tabBtn.offsetLeft / offsetWidth.
Cursor-positioned context menus
Section titled “Cursor-positioned context menus”Right-click and long-press menus position at the cursor, not anchored to an element. Avoids the “menu floats off-screen” problem on mobile.
function positionAt(menu, x, y) { const r = menu.getBoundingClientRect(); const vw = window.innerWidth, vh = window.innerHeight; let nx = x, ny = y; if (x + r.width > vw) nx = vw - r.width - 8; if (y + r.height > vh) ny = vh - r.height - 8; menu.style.left = `${Math.max(8, nx)}px`; menu.style.top = `${Math.max(8, ny)}px`;}Open by setting display/visibility first, then measuring + positioning, then unhiding. Hidden elements have zero rect.
Toolbar icon button
Section titled “Toolbar icon button”The base toolbar button — icon + optional label, hover state, active state.
.viewer-tool-btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.4rem 0.6rem; border-radius: var(--radius-xs); background: transparent; color: var(--toolbar-icon); border: 0; cursor: pointer; font-size: 0.78rem; transition: background var(--transition-fast), color var(--transition-fast);}.viewer-tool-btn:hover { background: var(--surface-btn); color: var(--text-primary); }.viewer-tool-btn.active { background: var(--surface); color: var(--accent);}.viewer-tool-btn svg { width: 18px; height: 18px; flex-shrink: 0; }.tool-label { /* hidden on narrow viewports */ }@media (max-width: 1599px) { .tool-label { display: none; }}Hide the label on narrow viewports; icon-only is fine when space is tight.
JS composition recipes
Section titled “JS composition recipes”Patterns for wiring Tabia + Motif into common chess-app UX. Tabia ships only the chess-data primitives (move tree, navigation by id, the basic goTo* walks); the UX patterns below are deliberately not in the library — they’re app-level compositions on top.
The thesis: keep Tabia pure and fast, let Motif own the opinionated UX, and document the wiring here so apps can copy-paste-and-adapt rather than rebuild from scratch.
Branch-aware Next: popover at forks
Section titled “Branch-aware Next: popover at forks”Default goToNext advances along the mainChild. At a position with multiple children, a chess app often wants to prompt the user instead — opens a popover, user picks the line. This is “branch mode.”
let branchMode = true; // app state; toggled by some key/button
function handleArrowRight(game, branchPopover) { const node = game.getCurrentNode(); if (branchMode && node && node.children.length > 1) { branchPopover.show(node.children.slice()); } else { game.goToNext(); }}
// Toggle handler (e.g., bound to a 'b' key or a toolbar button):function toggleBranchMode() { branchMode = !branchMode;}The popover (<motif-branch-popover>) accepts a list of child node ids and emits a selection event the app routes through game.goToMove(id). The state itself — whether the prompt fires at forks — lives in the app, not Tabia.
”Go to branch start”: variation-aware Home
Section titled “”Go to branch start”: variation-aware Home”The default goToStart() is the chess primitive — go to the game’s root. Many apps want a richer “Home” behavior: from inside a variation, walk back to the variation’s branch point on the mainline instead of all the way to game root. Composes from getCurrentNode + getNode:
function findVariationRoot(game) { let node = game.getCurrentNode(); while (node && node.parentId >= 0) { const parent = game.getNode(node.parentId); if (!parent) return null; if (parent.mainChild !== node.id) return node; node = parent; } return null;}
function goToBranchStart(game) { const variationRoot = findVariationRoot(game); if (variationRoot) { game.goToMove(variationRoot.parentId); // jump to the branch point } else { game.goToStart(); }}Bind to Home or the Toolbar’s nav-start button to override Tabia’s primitive:
window.addEventListener('keydown', (e) => { if (e.key === 'Home') goToBranchStart(game);});Pairs naturally with branch-mode navigation: ArrowRight prompts at forks; Home walks back to the most recent fork; ArrowLeft steps one move; End walks to the leaf of the current line.