Design Lounge
Install

Nº 640 of 677

Tessel sliding tile puzzle

A playable 4×4 sliding puzzle of an SVG harbour scene: spring-snapped tiles, tap, swipe or arrow keys, peek, pause, a solved ripple and a result card.

Preview

Open full size
390 × 844 · 17.0 KB · 257 lines

Use it

Free to use · credit appreciated

1 Once, in your project · Node 22.20+

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Tessel sliding tile puzzle" (piece minigame-tile-slide) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

220 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

A complete phone mini-game: Tessel, a sliding number puzzle where fifteen tiles each carry a slice of an original flat SVG picture (Harbour Light: a striped lighthouse on a cliff, a low sun, a sailboat on a dusk sea) and their number. Stone paper ground, ink frame, one green for “in place”, one monospace family for everything. The layout follows iOS 26 conventions without glass: a quiet header, a hairline HUD, the board pushed down into the thumb zone, two 56px buttons under it. The details worth copying are the spring snap (tiles overshoot by a hair and settle in 260ms), number chips that turn green when a tile is home, and a 15-segment progress bar that fills as you go, so a hard puzzle always shows you how close you are.

Reference behaviour

  1. Initial state: a full shuffle (220 random legal moves from solved, so it is always solvable), MOVES 0, TIME 0:00, BEST 1:58. One tile next to the gap has a 3px cream ring and nudges 8px towards the gap every 1.4s. The line under the board reads “Slide a tile into the gap”.
  2. Tap a tile in the same row or column as the gap: it and every tile between it and the gap slide one cell towards the gap together. Each tile moved adds 1 to MOVES. Taps on tiles not in line with the gap do nothing.
  3. Swipe on the board (24px or more; the larger axis wins): the tile on the far side of the gap moves into it in the swipe direction. A swipe never also counts as a tap.
  4. Keyboard: ArrowLeft moves the tile right of the gap left, ArrowRight the tile left of the gap right, ArrowUp the tile below up, ArrowDown the tile above down. Space pauses, P toggles peek, Escape resumes.
  5. The first move starts the timer and removes the hint; the line becomes “Tap, swipe or use the arrow keys”.
  6. After every move: each tile in its home cell shows a green number chip; the progress card reads “N of 15 tiles in place” and N of its 15 segments turn green.
  7. Peek (toggle, aria-pressed): the solved picture covers the board, labelled “Solved picture”, fading in over 200ms. Tap again to hide. The timer keeps running.
  8. Pause (top-right, 44px): an ink panel covers the board so the picture cannot be studied, with Resume, Restart this shuffle and New easy shuffle (24 moves). The timer stops; Resume or Escape continues it.
  9. Shuffle deals a new full shuffle and resets moves and time.
  10. Solving: the hidden 16th tile fades in to complete the picture, every tile pulses to 1.07 in a diagonal ripple (55ms per step from top-left), number chips fade away after 600ms, and the line reads “Solved in 24 moves”.
  11. 700ms later the result card rises from the bottom (520ms iOS sheet curve): “Picture restored” (or “New best time” in green), “Harbour Light, done.”, Moves, Time, Best, and Play again, which takes focus. Play again deals a new full shuffle.

Structure

390 × 844 (54 safe top, 34 safe bottom, 23px gutters)
┌──────────────────────────────────────┐
│ 54 safe area                         │
│ Tessel                         [ ‖ ] │  header 48, pause 44×44
│ Nº 12 · Harbour Light                │
│ ════════════════════════════════════ │  1px ink rule
│ MOVES      │ TIME       │ BEST       │  hud: 3 cols, 26px numbers
│ 0          │ 0:00       │ 1:58       │
│ ──────────────────────────────────── │  1px line rule
│ ┌────┐ Harbour Light                 │  progress card, 56px thumbnail
│ │pic │ 0 of 15 tiles in place        │
│ └────┘ ▭▭▭▭▭▭▭▭▭▭▭▭▭▭▭              │  15 segments × 6px
│            (flex space)              │
│ ┌──────────────────────────────────┐ │
│ │ ┌──┐┌──┐┌──┐┌──┐                 │ │  board 344×344, ink frame, r18, pad 6
│ │ │ 7││11││  ││ 6│  tiles 80×80    │ │  pitch 83 (3px gutters)
│ │ └──┘└──┘└──┘└──┘                 │ │
│ │  … 4 rows …                      │ │
│ └──────────────────────────────────┘ │
│       Slide a tile into the gap      │  note, 13px
│ ┌────────────────┐ ┌───────────────┐ │
│ │  ◉ Peek        │ │  ↻ Shuffle    │ │  56px buttons, 10px gap
│ └────────────────┘ └───────────────┘ │
└──────────────────────────────────────┘
Result card: left/right 12, bottom 34, r22, slides over the controls and the board's lower half.
  • <main class="app"> flex column, max-width:430px, padding:54px 23px 34px.
  • <header>: <h1>Tessel</h1>, sub-line, pause <button aria-label="Pause">.
  • <section class="hud">: three cells, the time cell role="timer".
  • <section class="prog">: an <svg><use href="#pic"/></svg> thumbnail, the title, the count text, 15 <i> segments.
  • .boardwrap (344×344) holds .board[role=group] with .cells, the .peek overlay and the .paused role="dialog" panel.
  • Tiles: 16 <button class="t"> (the 16th is aria-hidden and invisible until solved). Each holds <svg viewBox="col*83 row*83 80 80"><use href="#pic"/></svg> plus an <i> number chip.
  • .controls: Peek and Shuffle buttons. .result card with aria-live="polite".
  • The picture is one <g id="pic"> (332×332) in a hidden <svg><defs> with two linear gradients (sky, sea).

Tokens

:root {
  /* colour */
  --bg: #E9E4D8;        /* stone paper */
  --card: #F4F0E6;      /* buttons, progress card, result card */
  --ink: #1E1E24;       /* text, frame, chips, primary button */
  --ink-2: #55535C;     /* labels (5.9:1 on --bg) */
  --line: #CFC8B8;      /* hairlines, empty segments */
  --frame: #1E1E24;     /* board */
  --home: #276B4D;      /* in-place chip, filled segments, result tag */
  --accent: #C8402E;    /* focus ring; lighthouse stripes in the picture */
  --chip: #F4EFE6;      /* chip text, hint ring, paused panel button */
  /* picture */
  --sky-top: #F6C77A; --sky-bottom: #EE8A5B; --sun: #FCE6AE; --hill: #B5604E; --hill-2: #8F4A44;
  --sea-top: #2E5F80; --sea-bottom: #173A52; --glint: #F3B276; --wave: #4A7EA0; --cliff: #2E2C2A; --grass: #5E7A44;
  /* type */
  --mono: "Space Mono", ui-monospace, monospace;
  --fs-title: 22px; --fs-hud: 26px; --fs-result: 24px; --fs-result-num: 22px;
  --fs-button: 15px; --fs-body: 13px; --fs-chip: 12px; --fs-label: 11px;
  /* board geometry */
  --board: 344px; --board-pad: 6px; --pitch: 83px; --tile: 80px;
  /* radii */
  --r-board: 18px; --r-tile: 9px; --r-chip: 5px; --r-button: 14px; --r-icon: 12px; --r-result: 22px;
  /* shadow */
  --board-shadow: 0 18px 30px -18px rgba(30,30,36,.6);
  /* motion */
  --spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --std: cubic-bezier(0.2, 0.7, 0.2, 1);
  --sheet: cubic-bezier(0.32, 0.72, 0, 1);
  --t-snap: 260ms; --t-ripple: 520ms; --ripple-step: 55ms; --t-overlay: 200ms; --t-result: 520ms; --result-delay: 700ms;
}

Typography

One family, Space Mono, at two weights.

RoleSize / lineWeightTrackingCase
Title “Tessel”22px / 1700−0.02emsentence
HUD numbers26px / 1.1, tabular7000—
HUD labels11px4000.14emupper
Paused heading28px / 17000sentence
Result heading24px / 1.17000sentence
Result numbers22px / 1.27000—
Buttons15px / 17000sentence
Tile chip12px / 20px7000—
Body, note, sub-line12–13px / 1.44000sentence

Motion

WhatTriggerPropertyFrom → toDurationEasingDelayReduced motion
Tile snapmovetransform translateold cell → new cell (slight overshoot)260ms--spring090ms --std, no overshoot
Hint nudgefirst frametranslate0 → 8px towards gap → 01400ms loop--std—ring only, no nudge
Chip homemovechip backgroundink → green200msdefault0same
Segmentsmovebackgroundline → green200msdefault0same
Peektoggleopacity0 → 1200ms--std0same
Pause paneltoggleopacity0 → 1200ms--std0same
Last tilesolveopacity0 → 1500ms--std0same
Ripplesolvescale1 → 1.07 → 1520ms--spring(row + col) × 55msoff
Chips outsolveopacity1 → 0300msdefault600mssame
Result cardsolvetranslateY120% → 0520ms--sheet700ms200ms fade
Button press:activescale1 → 0.97120ms--std0same

The timer runs on requestAnimationFrame, adds min(100, dt) per frame, and stops requesting frames when paused or solved.

States

  • Ready: board visible, hint ring on one movable tile, timer at 0:00 and not running.
  • Running: timer counting, note shows the controls line.
  • Tile in place: green chip; out of place: ink chip. Both 4.5:1+ with cream text.
  • Peeking: Peek button inverted (ink fill, cream text), aria-pressed="true".
  • Paused: ink panel over the board, pause button’s label flips to “Resume”, timer frozen. Moves are ignored.
  • Solved: 16th tile shown, ripple, chips gone, input ignored, result card up.
  • New best: result tag reads “New best time”, HUD BEST updates.
  • Focus-visible: 3px --accent outline at 3px offset on every button.
  • Hidden layers (peek, pause, result) are visibility:hidden when closed, not only transparent or translated.

Accessibility

  • The board is role="group" labelled “Sliding puzzle, 4 by 4”. Tiles are buttons with tabindex="-1" and labels like “Tile 7” or “Tile 1, in place”, so the screen-reader user plays with the arrow keys instead of tabbing through 15 stops.
  • Tab order: Pause, Peek, Shuffle; inside the paused panel focus goes to Resume; on solve, focus moves to Play again.
  • Keys: arrows move, Space pauses, P peeks, Escape resumes.
  • A polite live region announces “Paused. Timer stopped at 0:42.” and “Resumed.”; the result card is itself aria-live.
  • Contrast: --ink-2 on --bg 5.9:1; cream on --home 5.5:1; cream on ink 15:1.
  • Hit targets: tiles 80×80, pause 44×44, buttons 56px tall.
  • The board has touch-action:none so a swipe never scrolls the page.

Responsive rules

  • At 360 wide: drop --pitch to 75px and --tile to 72px (board 312px); the HUD keeps three columns.
  • Shorter than 800: the app keeps min-height:800px; the progress card’s margin-bottom:auto absorbs any slack, so on tall phones the board sits lower, nearer the thumb.
  • Tablet: the column caps at 430px and centres; the board does not grow past 344px.

Acceptance checklist

Always

  • Shuffle by legal random moves from solved; never by permuting the array.
  • Tapping a tile in line with the gap slides the whole run; moves count every tile moved.
  • Swipe (24px threshold) and all four arrow keys work, with the directions in step 4.
  • Tiles snap with a spring overshoot in 260ms; reduced motion uses 90ms without overshoot.
  • Each tile shows its own slice of one picture plus its number; chips turn green in place.
  • The progress bar has 15 segments and matches the in-place count.
  • Timer is rAF-driven, capped at 100ms per frame, starts on the first move, stops on pause and solve.
  • Peek, pause, restart, new easy shuffle and Play again all work and reset what they should.
  • Solve shows the 16th tile, a diagonal ripple, then the result card with focus on Play again.
  • Closed overlays are visibility:hidden; scrollWidth stays 390.

This demo

  • Title “Tessel”, sub-line “Nº 12 · Harbour Light”, BEST starts at 1:58.
  • Picture: lighthouse with two red bands on a left cliff, sun at (236,150) r46, sailboat at the lower right.
  • Easy shuffle is 24 moves; full shuffle is 220.

Implementation notes

  1. One picture, sixteen windows. Draw the scene once as a <g> in <defs>, then give each tile its own viewBox into it. The 3px gutters are simply never shown, which reads as a cut picture.
const c = (k - 1) % 4, r = Math.floor((k - 1) / 4);
tile.innerHTML = `<svg viewBox="${c * 83} ${r * 83} 80 80" aria-hidden="true"><use href="#pic"/></svg><i>${k}</i>`;
tile.style.setProperty('--x', cell % 4); tile.style.setProperty('--y', Math.floor(cell / 4));
.t { transform: translate(calc(var(--x) * 83px), calc(var(--y) * 83px));
     transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1); }
  1. Sliding a run. Walk from the gap towards the tapped cell, swapping as you go. This handles one tile and three tiles with the same code.
function tap(cell) {
  let g = pos.indexOf(0); const gr = g >> 2, gc = g & 3, cr = cell >> 2, cc = cell & 3;
  if (cell === g || (gr !== cr && gc !== cc)) return;
  const step = gr === cr ? (cc < gc ? -1 : 1) : (cr < gr ? -4 : 4);
  while (g !== cell) { const n = g + step; pos[g] = pos[n]; pos[n] = 0; g = n; moves++; }
}
  1. A timer that cannot run away. Use rAF with a capped delta, and only request the next frame while running. A background tab returns with one 100ms step, not a 40-second jump.
function loop(t) {
  const dt = Math.min(100, t - last); last = t; ms += dt; timerEl.textContent = fmt(ms);
  if (running && !paused && !won) raf = requestAnimationFrame(loop);
}
// on resume: last = performance.now(); raf = requestAnimationFrame(loop);

Common mistakes: random permutations (half are unsolvable), swipe handlers that also fire the tile’s click, and a pause panel that is transparent instead of opaque, which lets players study the board for free.

Details

Palette
Type
Space Mono
Motion
Subtle motion
Build
Multi-step
Designed bySusan AcharyaKathmandu

Studied

Tailwind CSS docs

A control. It studies this site for quiet, not for a show.

All sources

Install once. Ask for anything.

The MCP server gives your agent 600+ pieces, the themes and the review tools. It installs the skill too. Free, no account.

claude mcp add -s user design-lounge -- npx -y design-lounge-mcp@latest

Claude Code. For Cursor, Codex, VS Code, ChatGPT and the skill alone, see Install.