The app has been deliberately plain for seven lessons so that this one could land as a single readable diff: what polish actually costs. Pieces glide, captures dissolve, the board cascades in, the drawer turns to glass — and when you finish, npm run test:all still reports 19 + 28 + 7, because none of it changed a single behavior the suites pin down. Checkpoint: lesson-13.

Pieces that glide: the View Transitions API

The headline feature rewrites the one-line updateGameState from lesson 7 into the animation brain. The API’s contract: name an element in the old DOM, name its counterpart in the new DOM, and the browser animates between them. For a moved piece:

clearNames();
fromPiece.style.viewTransitionName = 'moving-piece';
if (toPiece) toPiece.style.viewTransitionName = 'captured-piece';

const transition = startViewTransition(async () => {
  this.applyGameState();
  await this.updateComplete;
  const dest = this.renderRoot
    .querySelectorAll<HTMLElement>('.square')[this.lastMove!.to]
    ?.querySelector<HTMLElement>('.piece');
  if (dest) dest.style.viewTransitionName = 'moving-piece';
});
transition.finished.finally(clearNames);

Everything routes through a guard first: the API exists, there is a last move, the component has rendered before, and the user hasn’t asked for reduced motion — otherwise fall back to plain applyGameState(). This is why lesson 8’s scheduleCpuMove consulted the engine instead of component state: inside a view transition, state application is asynchronous, and that guard was written for today.

The animation styles live in index.html, not the component, and the comment in the source says why: ::view-transition-* pseudo-elements attach to the document root, which shadow-DOM styles can’t reach. The capture animation is a three-property keyframe — fade, scale up, blur — that reads as a piece being taken rather than deleted.

Choreography in pure CSS

Four smaller effects, each one technique. The board’s entrance staggers diagonally: every square carries style="--d:${rowIndex + colIndex}" and CSS turns it into a wave with animation-delay: calc(var(--d) * 22ms). New history rows fade up via @starting-style, the modern answer to “animate an element that just appeared.” The check highlight becomes a pulsing inset glow. And the piece-name tooltips from lesson 6’s data-piece attribute finally render: gated behind @media (hover: hover) and (pointer: fine) so touch devices never see them, with a 300 ms transition-delay so they don’t flash during normal mousing.

Every animation in the file answers to reduced motion: the entrance wave and pulse sit inside no-preference blocks, the check ring gets a static fallback, and a catch-all collapses remaining transitions to 0.01 ms.

The drawer turns to glass, the layout makes room

The lesson-9 dialog gets the full treatment: translucent color-mix background with backdrop-filter: blur(16px), a slide-in from off-canvas, and the pair of properties that make <dialog> animatable at all — transition: ... overlay 250ms allow-discrete, display 250ms allow-discrete plus a @starting-style block. Its sections cascade in with staggered delays.

Layout goes responsive with container queries: past 760 cqi the panel docks beside the board (@container (inline-size > 760px)), chosen over media queries so the component responds to its space, not the viewport’s. One genuinely subtle rule handles wide screens — when the drawer opens, .app:has(dialog[open]) .game-container translates the game left to re-center it. The source comment documents two traps found the hard way: :has() must be evaluated inside the shadow tree, and the dialog must not be a descendant of the translated element, because a transformed ancestor becomes a fixed element’s containing block and drags the drawer along with it.

Build it

File Action What goes in it
src/chess-board.ts modify The animated updateGameState; --d on squares; tooltip, entrance, check-pulse, thinking-pulse, drawer-glass, container-query, and reduced-motion CSS
index.html modify The ::view-transition styles and capture-out keyframes

Done when: pieces glide and captures dissolve; with reduced motion enabled in your OS, nothing moves; and npm run test:all still reports 19 + 28 + 7 passed.

Answer key: lesson-12...lesson-13.

Challenge: re-skin it in sixty seconds

Every color in the component derives from --hue: 255 and --board-hue: 75. Change the two numbers, reload, and you’ve re-themed the app; wire them to a pair of range inputs in index.html if you want a live theme mixer. The exercise is noticing how much design a two-variable token system actually buys.

That’s the course — the game is done. The bonus lesson stays in Rust and answers the question lesson 8 left open: what does it take to build an opponent that deserves the thinking delay?