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?