Slide Puzzle
Sliding Tile Background for React
Icon tiles in a faint grid of slots around your headline, moving like a sliding puzzle. The first time it scrolls into view the tiles start scrambled and the puzzle solves itself, one cell at a time; after that a tile slides into a free slot every few seconds. Sweep the mouse fast into a tile and it's shoved along the grid with the tiles in front of it; rest on one and its name rises above it.
<SlidePuzzle />replays the demo when you let go.
import * as React from "react";
import { SlidePuzzle, type SlidePuzzleTile } from "@/components/ui/slide-puzzle";
function Glyph({ children }: { children: React.ReactNode }) {
return (
<svg viewBox="0 0 24 24" className="size-6" aria-hidden>
{children}
</svg>
);
}
// Okto, an ops assistant, and twelve of the tools it connects to. Every name and mark is made up.
const TILES: SlidePuzzleTile[] = [
{
id: "ember",
label: "Ember · Email",
href: "#ember",
icon: (
<Glyph>
<rect x="3" y="5" width="18" height="14" rx="3.2" fill="#ff5a1f" />
<path d="M6 8.6l6 4.4 6-4.4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</Glyph>
),
},
{
id: "fathom",
label: "Fathom · Postgres",
href: "#fathom",
icon: (
<Glyph>
<path d="M5 6v12c0 1.6 3.1 2.9 7 2.9s7-1.3 7-2.9V6z" fill="#2563eb" />
<ellipse cx="12" cy="6" rx="7" ry="2.9" fill="#60a5fa" />
<path d="M5 12c0 1.6 3.1 2.9 7 2.9s7-1.3 7-2.9" fill="none" stroke="#bfdbfe" strokeWidth="1.3" />
</Glyph>
),
},
{
id: "relay",
label: "Relay · Team chat",
href: "#relay",
icon: (
<Glyph>
<path d="M4 7a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v5.5a4 4 0 0 1-4 4h-5L6.5 20.5v-4.2A4 4 0 0 1 4 12.5z" fill="#10b981" />
<circle cx="8.6" cy="9.8" r="1.2" fill="#fff" />
<circle cx="12" cy="9.8" r="1.2" fill="#fff" />
<circle cx="15.4" cy="9.8" r="1.2" fill="#fff" />
</Glyph>
),
},
{
id: "cobalt",
label: "Cobalt · Payments",
href: "#cobalt",
icon: (
<Glyph>
<rect x="2.5" y="5" width="19" height="14" rx="3" fill="#4f46e5" />
<rect x="2.5" y="8.4" width="19" height="2.6" fill="#312e81" />
<rect x="5.2" y="14.2" width="5.5" height="2" rx="1" fill="#c7d2fe" />
</Glyph>
),
},
{
id: "lumen",
label: "Lumen · Warehouse",
href: "#lumen",
icon: (
<Glyph>
<circle cx="12" cy="12" r="4.6" fill="#f59e0b" />
<g stroke="#fbbf24" strokeWidth="2" strokeLinecap="round">
<path d="M12 2.8v2.2M12 19v2.2M2.8 12H5M19 12h2.2M5.5 5.5l1.6 1.6M16.9 16.9l1.6 1.6M18.5 5.5l-1.6 1.6M7.1 16.9l-1.6 1.6" />
</g>
</Glyph>
),
},
{
id: "tessera",
label: "Tessera · Docs",
href: "#tessera",
icon: (
<Glyph>
<rect x="3" y="3" width="8" height="8" rx="2.4" fill="#7c3aed" />
<rect x="13" y="3" width="8" height="8" rx="4" fill="#c084fc" />
<rect x="3" y="13" width="8" height="8" rx="4" fill="#c084fc" />
<rect x="13" y="13" width="8" height="8" rx="2.4" fill="#7c3aed" />
</Glyph>
),
},
{
id: "kiln",
label: "Kiln · Deploys",
href: "#kiln",
icon: (
<Glyph>
<path d="M12 2.4c.9 3.3 5.8 5.7 5.8 10.8a5.8 5.8 0 0 1-11.6 0c0-2.5 1.4-4.2 2.7-5.4.1 1.8 1 3 2.3 3.5-.6-3.2-.1-6.2.8-8.9z" fill="#ef4444" />
<path d="M12 13.3c.5 1.4 2.4 2.2 2.4 4.1a2.4 2.4 0 0 1-4.8 0c0-1.5 1.4-2.5 2.4-4.1z" fill="#fdba74" />
</Glyph>
),
},
{
id: "mosaic",
label: "Mosaic · Analytics",
href: "#mosaic",
icon: (
<Glyph>
<rect x="3.5" y="12" width="4.6" height="8.5" rx="1.6" fill="#f43f5e" />
<rect x="9.7" y="7.5" width="4.6" height="13" rx="1.6" fill="#fb923c" />
<rect x="15.9" y="3.5" width="4.6" height="17" rx="1.6" fill="#facc15" />
</Glyph>
),
},
{
id: "orbit",
label: "Orbit · Calendar",
href: "#orbit",
icon: (
<Glyph>
<circle cx="12" cy="12" r="7.6" fill="none" stroke="#0ea5e9" strokeWidth="2.2" />
<circle cx="12" cy="12" r="3" fill="#0ea5e9" />
<circle cx="18.4" cy="7.4" r="2.6" fill="#38bdf8" stroke="var(--slide-puzzle-tile)" strokeWidth="1.6" />
</Glyph>
),
},
{
id: "tide",
label: "Tide · Support",
href: "#tide",
icon: (
<Glyph>
<g fill="none" stroke="#06b6d4" strokeWidth="2.2" strokeLinecap="round">
<path d="M3 8c2.2 0 2.2-2 4.5-2S9.8 8 12 8s2.2-2 4.5-2S18.8 8 21 8" />
<path d="M3 13c2.2 0 2.2-2 4.5-2s2.3 2 4.5 2 2.2-2 4.5-2 2.3 2 4.5 2" opacity="0.7" />
<path d="M3 18c2.2 0 2.2-2 4.5-2s2.3 2 4.5 2 2.2-2 4.5-2 2.3 2 4.5 2" opacity="0.4" />
</g>
</Glyph>
),
},
{
id: "sprout",
label: "Sprout · CRM",
href: "#sprout",
icon: (
<Glyph>
<path d="M4.5 19.5C4.5 10.4 10.2 4.5 19.5 4.5c0 9.3-5.9 15-15 15z" fill="#65a30d" />
<path d="M5 19 13.8 10.2" stroke="#ecfccb" strokeWidth="1.6" strokeLinecap="round" />
</Glyph>
),
},
{
id: "quill",
label: "Quill · Notes",
href: "#quill",
icon: (
<Glyph>
<path d="M12 2.5 18.2 9 15 18H9L5.8 9z" fill="currentColor" />
<circle cx="12" cy="10.6" r="1.7" fill="var(--slide-puzzle-tile)" />
<path d="M12 12.4v5.4" stroke="var(--slide-puzzle-tile)" strokeWidth="1.3" />
<rect x="8.4" y="19.2" width="7.2" height="2.3" rx="1.15" fill="currentColor" />
</Glyph>
),
},
];
export function SlidePuzzleDemo() {
return (
<section className="w-full max-w-5xl overflow-hidden rounded-3xl border border-zinc-950/[0.06] bg-white text-zinc-950 dark:border-white/10 dark:bg-zinc-950 dark:text-white">
<SlidePuzzle tiles={TILES}>
<div className="flex max-w-md flex-col items-center text-center">
<span className="inline-flex items-center gap-1.5 rounded-full border border-zinc-950/10 bg-white px-2.5 py-1 text-xs font-medium text-zinc-600 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300">
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
<rect x="2" y="2" width="5" height="5" rx="1.4" fill="#ff5a1f" />
<rect x="9" y="2" width="5" height="5" rx="1.4" fill="currentColor" opacity="0.35" />
<rect x="2" y="9" width="5" height="5" rx="1.4" fill="currentColor" opacity="0.35" />
<rect x="9" y="9" width="5" height="5" rx="1.4" fill="currentColor" opacity="0.35" />
</svg>
Integrations
</span>
<h2 className="mt-4 text-balance text-3xl font-medium tracking-[-0.03em] sm:text-4xl">
Plugs into the stack you already run.
</h2>
<p className="mt-3 text-pretty text-[15px] leading-relaxed text-zinc-500 dark:text-zinc-400">
Okto reads from and writes to forty-two tools your team already pays for, each one set up in a click.
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<a
href="#integrations"
className="inline-flex h-9 items-center gap-1 rounded-full bg-[#ff5a1f] px-4 text-sm font-medium text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.25)] transition-colors hover:bg-[#f04d12] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ff5a1f]"
>
Browse integrations
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
<path d="M6 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
<a
href="#request"
className="inline-flex h-9 items-center rounded-full border border-zinc-950/10 bg-white px-4 text-sm font-medium transition-colors hover:bg-zinc-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ff5a1f] dark:border-white/10 dark:bg-zinc-900 dark:hover:bg-zinc-800"
>
Request one
</a>
</div>
</div>
</SlidePuzzle>
</section>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Slide Puzzle: Icon tiles in a faint grid of slots around your headline, moving like a sliding puzzle. The first time it scrolls into view the tiles start scrambled and the puzzle solves itself, one cell at a time; after that a tile slides into a free slot every few seconds. Sweep the mouse fast into a tile and it's shoved along the grid with the tiles in front of it; rest on one and its name rises above it. This demo, Integrations: An ops assistant's integrations section: twelve tool tiles solve themselves into place round the headline, then drift. Each one is a link. 1. Install it: npx shadcn@latest add @keyframeui/slide-puzzle-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/slide-puzzle-demo.json instead.) It adds the demo, components/slide-puzzle-demo.tsx, and the component, components/ui/slide-puzzle.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <SlidePuzzle> on its own. 3. These props set its feel. Change them before you change the file: - cell (default 64): Size of one grid cell in px, tile and gap together. Tiles are three quarters of it. A narrow box shrinks its cells, to 70% at most, so six columns fit. - solve (default true): Start scrambled and solve the puzzle the first time it scrolls into view. - push (default true): Let a fast mouse sweep shove tiles along the grid, one cell, with the line of tiles in front. - speed (default 1): How fast every slide plays, as a multiple of the default pace: the solve's moves and their spacing, each drift, a push and a press. `2` is twice as fast. - seed (default 7): Picks the layout and the scramble. Change it for a different arrangement. 4. Don't change, in components/ui/slide-puzzle.tsx: - Tiles move only by `transform`, on WAAPI tweens written straight to their elements, never through React state. An interrupted tile retargets from where it is on screen, so it never jumps. - A tile only slides one cell along the grid into an empty slot, never into the hole, and drift and the scramble keep it within `band` cells of it. `occupant` changes with `at` on every move. - The solve is the scramble played backwards, each move waiting until the tile ahead has partly left the slot, so moves overlap but two tiles never share a slot. - Homes and the scramble come from the seeded PRNG, so a layout is the same on every visit, and `speed` is read through a ref as each move starts, so changing it never re-scrambles. - Reduced motion has no solve, drift, push or bump: tiles start home and a press moves at once, while labels and the first fade-in remain. The reduced flag is false while hydrating. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/slide-puzzle.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/slide-puzzle.tsx and installs clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/slide-puzzleInstall the dependencies
npm i clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/slide-puzzle.tsx"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /** * Keep: * - Tiles move only by `transform`, on WAAPI tweens written straight to their elements, never * through React state. An interrupted tile retargets from where it is on screen, so it never * jumps. * - A tile only slides one cell along the grid into an empty slot, never into the hole, and drift * and the scramble keep it within `band` cells of it. `occupant` changes with `at` on every move. * - The solve is the scramble played backwards, each move waiting until the tile ahead has partly * left the slot, so moves overlap but two tiles never share a slot. * - Homes and the scramble come from the seeded PRNG, so a layout is the same on every visit, and * `speed` is read through a ref as each move starts, so changing it never re-scrambles. * - Reduced motion has no solve, drift, push or bump: tiles start home and a press moves at once, * while labels and the first fade-in remain. The reduced flag is false while hydrating. */ const TIMING = { /** * The solve, in ms: a move's run; the gap between starts (squeezed to fit `total`); the share of * a move before the slot it leaves can be entered; and the wait after coming into view, so the * tiles have faded in first. */ solve: { duration: 200, stagger: 55, total: 1300, clear: 0.45, delay: 280 }, /** After the solve's last move lands, before drifting starts, in ms. */ settle: 40, /** A drift: one move's run, and the default gap between moves, in ms. */ drift: { duration: 340, every: 2400 }, /** A shove or a press sliding a tile one cell, in ms. */ push: 220, /** A pressed tile with nowhere to go bumping, in ms, and its curve. */ bump: { duration: 180, easing: "ease-out" }, /** Tiles fading in the first time they're placed, in ms, and its curve. */ appear: { duration: 320, easing: "ease-out" }, /** Sliding one cell: starts and stops with friction. */ inOut: "cubic-bezier(0.65, 0, 0.35, 1)", /** A push or a press: all the speed up front. */ shove: "cubic-bezier(0.23, 1, 0.32, 1)", /** The pointer resting on a tile before its label shows, in ms. */ dwell: 120, /** After the pointer leaves, before the label goes, in ms. */ hide: 120, /** How long a tapped tile keeps its label, in ms. */ touch: 1800, /** A label showing: the tile lifting, its shadow fading in, the name rising. Tailwind classes. */ label: "duration-150 ease-out", /** How fast every slide plays, as a multiple of the default pace (the `speed` prop). */ speed: 1, /** The slowest pace `speed` is held to, as a multiple of the default pace. */ minSpeed: 0.05, } as const; /* ───────────────────────────────────────────────────────── * SLIDE PUZZLE STORYBOARD * * rest a faint grid of rounded slots fills the box, fading out * toward its edges and left out under your headline. The * headline's box, plus 10px, is a hole no tile ever enters. * The tiles sit one per slot, hugging the hole, spread * round all four sides. A narrow box shrinks its cells (to * 70% at most) so six columns fit * rules a tile only ever slides one cell, along the grid, into * an empty slot. Never diagonally, never through another * tile, never into the hole, and it drifts no more than 2 * cells out from it * solve the first time 30% of it is in view, the tiles start * scrambled (two moves each) and the puzzle solves itself: * once they've faded in, the moves play back in reverse, * 200ms each, a new one every ~55ms. A tile waits for the * one ahead of it to be 45% out of a slot before it slides * in. About 1.5s * drift then every 2.4s one tile slides into a free neighbouring * slot (340ms, in-out, like a tile with friction). It never * undoes the last move. Pauses while the pointer is over * it, off screen and on a hidden tab * push sweep the mouse fast (0.5px/ms or more) into a tile and * it's shoved one cell along the sweep, with the line of * tiles in front of it, if the slot past the line is free. * 220ms, all the speed up front. A slow pointer never * pushes * label rest on a tile for 120ms, or tab to it, and its name * rises 4px into a pill above it (below it on the top * row) while the tile lifts 2px onto a shadow. It goes * 120ms after the pointer leaves * slide a tile without a link is a button: press it and it * slides into a free neighbour, away from the hole if it * can. Arrow keys move it that way. A blocked tile bumps * touch a tap shows the label for 1.8s; no pushing * resize the grid is rebuilt from `cell` and the hole from the * headline's box, and the tiles go home at once * reduced no solve, no drift, no push: the tiles are home from the * start. Labels still fade in, and a pressed tile moves at * once * ───────────────────────────────────────────────────────── */ const PUZZLE = { tile: 0.75, // tile size, as a share of `cell`; the rest is the gap between slots radius: 0.25, // corner radius, as a share of the tile margin: 10, // px kept clear round the headline; a slot that reaches into it is part of the hole band: 2, // cells out from the hole a tile may drift; homes sit 1 out where they can, else 2 fit: { columns: 6, min: 0.7 }, // a narrow box shrinks its cells until this many columns fit, but never below this share of `cell` scramble: 2, // moves per tile the solve undoes sweep: 0.5, // px/ms the mouse must be moving to shove a tile bump: 3, // px a pressed tile with nowhere to go nudges seed: 7, } as const; const REDUCED = "(prefers-reduced-motion: reduce)"; type Dir = [number, number]; const DIRS: Dir[] = [ [1, 0], [-1, 0], [0, 1], [0, -1], ]; type Rect = { c0: number; c1: number; r0: number; r1: number }; type Grid = { width: number; // px, the box step: number; // px per cell, `cell` or less in a narrow box size: number; // px, a tile inset: number; // px from a cell's edge to its tile radius: number; // px, a tile's corners cols: number; rows: number; ox: number; // px from the left of the box to the first column oy: number; hole: Set<number>; // cell indices the headline covers around: Rect; // what distances are measured from: the hole's cells, or the middle cell key: string; // changes when the cells or the hole do }; /** Small seeded PRNG (mulberry32), so a layout is the same on every visit. */ function random(seed: number) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** Cells between a cell and the hole, counting diagonals as one (0 inside it). */ function reach(grid: Grid, c: number, r: number) { const { c0, c1, r0, r1 } = grid.around; return Math.max(c0 - c, 0, c - c1, r0 - r, 0, r - r1); } function inside(grid: Grid, c: number, r: number) { return c >= 0 && r >= 0 && c < grid.cols && r < grid.rows && !grid.hole.has(r * grid.cols + c); } /** Homes for `count` tiles: near the hole, spread apart, the same for the same seed. */ function planHomes(grid: Grid, count: number, seed: number) { const rand = random(seed); const open: number[] = []; for (let r = 0; r < grid.rows; r++) { for (let c = 0; c < grid.cols; c++) { const d = reach(grid, c, r); if (d >= 1 && d <= PUZZLE.band && inside(grid, c, r)) open.push(r * grid.cols + c); } } // Leave room to move: never fill more than 60% of the band. const n = Math.min(count, Math.floor(open.length * 0.6)); const homes: number[] = []; const jitter = new Map(open.map((i) => [i, rand()])); for (let k = 0; k < n; k++) { let best = -1; let score = -Infinity; for (const i of open) { if (homes.includes(i)) continue; const c = i % grid.cols; const r = Math.floor(i / grid.cols); let near = 4; for (const h of homes) { near = Math.min(near, Math.hypot(c - (h % grid.cols), r - Math.floor(h / grid.cols))); } // Hug the hole, keep a couple of cells from the tiles already placed, and a little luck. const s = Math.min(near, 2.4) - (reach(grid, c, r) - 1) * 1.1 + (jitter.get(i) ?? 0) * 0.9; if (s > score) { score = s; best = i; } } if (best < 0) break; homes.push(best); } return homes; } type Move = { tile: number; from: number; to: number }; /** Walk the tiles away from home, two moves each; the solve plays these backwards. */ function planScramble(grid: Grid, homes: number[], seed: number) { const rand = random(seed * 31 + 11); const at = [...homes]; const taken = new Set(at); const moved = at.map(() => 0); const last = at.map(() => -1); const moves: Move[] = []; const target = homes.length * PUZZLE.scramble; for (let tries = 0; moves.length < target && tries < target * 8; tries++) { // The tile that has moved least goes next, so every tile takes part. const least = Math.min(...moved); const pool = at.map((_, t) => t).filter((t) => moved[t] === least); const t = pool[Math.floor(rand() * pool.length)]; const c = at[t] % grid.cols; const r = Math.floor(at[t] / grid.cols); const options = DIRS.map(([dc, dr]) => [c + dc, r + dr] as const).filter( ([nc, nr]) => inside(grid, nc, nr) && !taken.has(nr * grid.cols + nc) && nr * grid.cols + nc !== last[t] && reach(grid, nc, nr) <= PUZZLE.band, ); if (!options.length) { moved[t] += 1; continue; } const [nc, nr] = options[Math.floor(rand() * options.length)]; const to = nr * grid.cols + nc; moves.push({ tile: t, from: at[t], to }); taken.delete(at[t]); taken.add(to); last[t] = at[t]; at[t] = to; moved[t] += 1; } return { start: at, moves }; } const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(REDUCED); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); }; /** prefers-reduced-motion, false on the server and in the hydration render. */ function usePrefersReducedMotion() { return React.useSyncExternalStore( subscribeReduced, () => window.matchMedia(REDUCED).matches, () => false, ); } export interface SlidePuzzleTile { /** Stable key for the tile. */ id: string; /** Its name: the label that shows on hover and focus, and what screen readers announce. */ label: string; /** What's on the tile: an inline SVG glyph or an `<img>`; it's centered and clipped to the tile. */ icon: React.ReactNode; /** Makes the tile a link. Without it the tile is a button that slides when pressed. */ href?: string; } export interface SlidePuzzleProps extends React.HTMLAttributes<HTMLDivElement> { /** The tiles, scattered round the children in slots of their own. */ tiles: SlidePuzzleTile[]; /** Your headline and calls to action, centered. Their box, plus 10px, is the hole no tile enters. */ children?: React.ReactNode; /** Size of one grid cell in px, tile and gap together. Tiles are three quarters of it. Defaults to 64. */ cell?: number; /** Start scrambled and solve the puzzle the first time it scrolls into view. Defaults to true. */ solve?: boolean; /** Slide one tile into a free neighbouring slot every so often: `true` for every 2.4s, a number of ms, or `false` to keep still. */ drift?: boolean | number; /** Let a fast mouse sweep shove tiles along the grid. Defaults to true. */ push?: boolean; /** Picks the layout and the scramble. Change it for a different arrangement. Defaults to 7. */ seed?: number; /** How fast every slide plays, as a multiple of the default pace: the solve's moves and their spacing, a drift, a push and a press. `2` is twice as fast. Defaults to 1. */ speed?: number; } /* ── Slots ────────────────────────────────────────────────── */ // The empty grid, drawn once per layout. Slots fade with distance from the // headline and are left out under it, so the text sits on a clean field. function Slots({ grid }: { grid: Grid }) { const slots: React.ReactNode[] = []; for (let r = 0; r < grid.rows; r++) { for (let c = 0; c < grid.cols; c++) { if (grid.hole.has(r * grid.cols + c)) continue; const fade = Math.min(1, Math.max(0.12, 1 - (reach(grid, c, r) - PUZZLE.band) * 0.3)); const edge = c === 0 || r === 0 || c === grid.cols - 1 || r === grid.rows - 1 ? 0.6 : 1; slots.push( <rect key={r * grid.cols + c} x={grid.ox + c * grid.step + grid.inset + 0.5} y={grid.oy + r * grid.step + grid.inset + 0.5} width={grid.size - 1} height={grid.size - 1} rx={grid.radius} opacity={fade * edge} />, ); } } return ( <svg aria-hidden className="pointer-events-none absolute inset-0 -z-10 size-full"> <g fill="currentColor" fillOpacity={0.025} stroke="currentColor" strokeOpacity={0.06}> {slots} </g> </svg> ); } /* ── SlidePuzzle ──────────────────────────────────────────── */ export function SlidePuzzle({ tiles, cell = 64, solve = true, drift = true, push = true, seed = PUZZLE.seed, speed = TIMING.speed, className, style, children, ...props }: SlidePuzzleProps) { const rootRef = React.useRef<HTMLDivElement>(null); const holeRef = React.useRef<HTMLDivElement>(null); const tileEls = React.useRef(new Map<string, HTMLLIElement>()); const actions = React.useRef<{ press: (id: string, dir?: Dir) => void } | null>(null); const [grid, setGrid] = React.useState<Grid | null>(null); const reduced = usePrefersReducedMotion(); const size = Math.round(cell * PUZZLE.tile); const radius = Math.round(size * PUZZLE.radius); const every = drift === true ? TIMING.drift.every : drift === false ? 0 : drift; const tileKey = tiles.map((t) => t.id).join("\u0000"); // Read by the moves as they start, so a new speed never rebuilds (and re-scrambles) the puzzle. const speedRef = React.useRef(speed); React.useEffect(() => { speedRef.current = Math.max(TIMING.minSpeed, speed); }, [speed]); React.useEffect(() => { const root = rootRef.current; const holeEl = holeRef.current; if (!root || !holeEl) return; const ids = tileKey ? tileKey.split("\u0000") : []; const els = tileEls.current; let g: Grid | null = null; let at: number[] = []; // cell per tile, -1 when it didn't fit let occupant = new Map<number, number>(); // cell → tile let pending: Move[] = []; // the solve, once planned let solved = !solve || reduced; let solving = false; let inView = false; let hovering = false; let driftTimer = 0; let lastMove: { tile: number; from: number } | null = null; const timers = new Set<number>(); const shown = new Set<number>(); // tiles that have faded in const moving = new WeakMap<HTMLElement, Animation>(); const labelTimers = new Map<number, number>(); const later = (fn: () => void, ms: number) => { const id = window.setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); return id; }; const el = (t: number) => els.get(ids[t]); /** A slide's ms at the current `speed`. */ const paced = (ms: number) => ms / speedRef.current; const cellXY = (i: number) => { const grid = g!; return [grid.ox + (i % grid.cols) * grid.step + grid.inset, grid.oy + Math.floor(i / grid.cols) * grid.step + grid.inset]; }; const transformOf = (i: number) => { const [x, y] = cellXY(i); return `translate3d(${x}px, ${y}px, 0)`; }; /** Move a tile's element to a cell: at once, or on a compositor tween from wherever it is now. */ function render(t: number, duration = 0, easing: string = TIMING.inOut) { const node = el(t); if (!node || !g) return; const cellIndex = at[t]; if (cellIndex < 0) { node.hidden = true; return; } node.hidden = false; node.toggleAttribute("data-below", Math.floor(cellIndex / g.cols) === 0); const to = transformOf(cellIndex); const running = moving.get(node); let from = node.style.transform; if (running && running.playState === "running") { // Retarget from where it is on screen, so an interrupted tile never jumps. from = getComputedStyle(node).transform; running.cancel(); } node.style.transform = to; if (!shown.has(t)) { shown.add(t); node.style.opacity = "1"; node.animate([{ opacity: 0 }, { opacity: 1 }], { duration: TIMING.appear.duration, easing: TIMING.appear.easing }); return; } if (duration > 0 && !reduced && from && from !== to) { moving.set(node, node.animate([{ transform: from }, { transform: to }], { duration, easing })); } } function move(t: number, to: number, duration: number, easing: string = TIMING.inOut) { occupant.delete(at[t]); occupant.set(to, t); at[t] = to; render(t, duration, easing); } function setLabel(t: number, on: boolean) { const node = el(t); if (node) node.toggleAttribute("data-label", on); } /** Centre a tile's label over it, but keep it 8px inside the box, so an edge tile's name isn't cut off. */ function placeLabel(t: number) { const node = el(t); const label = node?.querySelector<HTMLElement>("[data-slide-puzzle-label]"); if (!label || !g || at[t] < 0) return; const half = label.offsetWidth / 2; const [x] = cellXY(at[t]); const lo = 8 - x + half; const hi = g.width - 8 - x - half; const center = lo > hi ? g.size / 2 : Math.min(hi, Math.max(lo, g.size / 2)); label.style.left = `${center}px`; } function showLabel(t: number) { placeLabel(t); setLabel(t, true); } function hideLabel(t: number) { window.clearTimeout(labelTimers.get(t)); setLabel(t, false); } function measure(): Grid { const w = root!.clientWidth; const step = Math.round(Math.max(cell * PUZZLE.fit.min, Math.min(cell, w / PUZZLE.fit.columns))); const size = Math.round(step * PUZZLE.tile); const inset = (step - size) / 2; const radius = Math.round(size * PUZZLE.radius); if (root!.style.getPropertyValue("--slide-puzzle-cell") !== `${step}px`) { // Tiles and the headline's padding read these, so a phone gets smaller cells. root!.style.setProperty("--slide-puzzle-cell", `${step}px`); root!.style.setProperty("--slide-puzzle-size", `${size}px`); root!.style.setProperty("--slide-puzzle-radius", `${radius}px`); } const h = root!.clientHeight; const cols = Math.max(1, Math.floor(w / step)); const rows = Math.max(1, Math.floor(h / step)); const ox = Math.round((w - cols * step) / 2); const oy = Math.round((h - rows * step) / 2); const box = root!.getBoundingClientRect(); const scale = box.width / (root!.offsetWidth || 1) || 1; // a scaled parent (a preview frame) const hb = holeEl!.getBoundingClientRect(); const hx0 = (hb.left - box.left) / scale - PUZZLE.margin; const hx1 = (hb.right - box.left) / scale + PUZZLE.margin; const hy0 = (hb.top - box.top) / scale - PUZZLE.margin; const hy1 = (hb.bottom - box.top) / scale + PUZZLE.margin; const hole = new Set<number>(); const empty = hb.width === 0 || hb.height === 0; let rect: Rect = { c0: Infinity, c1: -Infinity, r0: Infinity, r1: -Infinity }; if (!empty) { for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const x0 = ox + c * step + inset; const y0 = oy + r * step + inset; if (x0 < hx1 && x0 + size > hx0 && y0 < hy1 && y0 + size > hy0) { hole.add(r * cols + c); rect = { c0: Math.min(rect.c0, c), c1: Math.max(rect.c1, c), r0: Math.min(rect.r0, r), r1: Math.max(rect.r1, r) }; } } } } if (!hole.size) { const c = Math.floor(cols / 2); const r = Math.floor(rows / 2); rect = { c0: c, c1: c, r0: r, r1: r }; } return { width: w, step, size, inset, radius, cols, rows, ox, oy, hole, around: rect, key: `${step}:${cols}x${rows}:${[...hole].join(",")}` }; } function stopSolve() { for (const id of timers) window.clearTimeout(id); timers.clear(); solving = false; } function layout() { const next = measure(); const same = g && g.key === next.key; const moved = !g || g.ox !== next.ox || g.oy !== next.oy || !same; g = next; if (moved) setGrid(next); if (same) { // Only the margins moved: keep every tile in its cell. at.forEach((_, t) => render(t)); return; } if (solving) solved = true; // a resize mid-solve skips to the end; before it starts, it re-plans stopSolve(); const homes = planHomes(next, ids.length, seed); let start = homes; pending = []; if (!solved) { const scramble = planScramble(next, homes, seed); start = scramble.start; pending = scramble.moves.reverse().map(({ tile, from, to }) => ({ tile, from: to, to: from })); } at = ids.map((_, t) => start[t] ?? -1); occupant = new Map(at.flatMap((i, t) => (i >= 0 ? [[i, t] as [number, number]] : []))); lastMove = null; at.forEach((_, t) => { const node = el(t); const running = node && moving.get(node); if (running) running.cancel(); render(t); }); if (!solved && inView) runSolve(); } /** Play the scramble backwards: overlapping moves, but never two tiles in one slot. */ function runSolve() { if (solving || solved || !pending.length) return; solving = true; const { clear } = TIMING.solve; const duration = paced(TIMING.solve.duration); const stagger = Math.min(paced(TIMING.solve.stagger), paced(TIMING.solve.total) / pending.length); const tileFree = new Map<number, number>(); const slotFree = new Map<number, number>(); let cursor = TIMING.solve.delay; let end = 0; for (const m of pending) { const start = Math.max(cursor, tileFree.get(m.tile) ?? 0, slotFree.get(m.to) ?? 0); tileFree.set(m.tile, start + duration); slotFree.set(m.from, start + duration * clear); cursor = start + stagger; end = Math.max(end, start + duration); later(() => move(m.tile, m.to, duration), start); } later(() => { solving = false; solved = true; pending = []; scheduleDrift(); }, end + TIMING.settle); } function free(t: number, dir: Dir, band: boolean) { const grid = g!; const c = (at[t] % grid.cols) + dir[0]; const r = Math.floor(at[t] / grid.cols) + dir[1]; if (!inside(grid, c, r)) return -1; const i = r * grid.cols + c; if (occupant.has(i)) return -1; if (band && reach(grid, c, r) > PUZZLE.band) return -1; return i; } function driftOnce() { if (!g) return; const active = document.activeElement; const options: [number, number][] = []; at.forEach((i, t) => { if (i < 0) return; const node = el(t); if (node?.hasAttribute("data-label") || (active && node?.contains(active))) return; for (const dir of DIRS) { const to = free(t, dir, true); if (to < 0) continue; if (lastMove && lastMove.tile === t && lastMove.from === to) continue; options.push([t, to]); } }); if (!options.length) return; // Prefer a different tile from the last one, so the motion wanders. const fresh = options.filter(([t]) => t !== lastMove?.tile); const pool = fresh.length ? fresh : options; const [t, to] = pool[Math.floor(Math.random() * pool.length)]; lastMove = { tile: t, from: at[t] }; move(t, to, paced(TIMING.drift.duration)); } function scheduleDrift() { window.clearTimeout(driftTimer); driftTimer = 0; if (!every || reduced || !solved || !inView || document.hidden) return; driftTimer = window.setTimeout(() => { driftTimer = 0; if (!hovering && !solving) driftOnce(); scheduleDrift(); }, every); } function bump(t: number, dir: Dir) { const node = el(t); if (!node || reduced) return; const [x, y] = cellXY(at[t]); const d = PUZZLE.bump; node.animate( [ { transform: `translate3d(${x}px, ${y}px, 0)` }, { transform: `translate3d(${x + dir[0] * d}px, ${y + dir[1] * d}px, 0)` }, { transform: `translate3d(${x}px, ${y}px, 0)` }, ], { duration: paced(TIMING.bump.duration), easing: TIMING.bump.easing }, ); } /** A pressed tile (click, Enter, an arrow key) slides to a free neighbour. */ function press(id: string, dir?: Dir) { const t = ids.indexOf(id); if (t < 0 || !g || at[t] < 0 || solving) return; const grid = g; let options = dir ? [dir] : DIRS; if (!dir) { // Away from the hole first. const c = at[t] % grid.cols; const r = Math.floor(at[t] / grid.cols); const away = (d: Dir) => reach(grid, c + d[0], r + d[1]) - reach(grid, c, r); options = [...DIRS].sort((a, b) => away(b) - away(a)); } for (const d of options) { const to = free(t, d, false); if (to < 0) continue; lastMove = { tile: t, from: at[t] }; move(t, to, paced(TIMING.push), TIMING.shove); placeLabel(t); scheduleDrift(); return; } bump(t, options[0]); } actions.current = { press }; // Pushing: watch the mouse's speed and the cell it's in. let last: { x: number; y: number; time: number } | null = null; let vx = 0; let vy = 0; let lastCell = -1; let box: DOMRect | null = null; const onScroll = () => { box = null; }; function onPointerMove(event: PointerEvent) { if (event.pointerType !== "mouse" || !g) return; box ??= root!.getBoundingClientRect(); const scale = box.width / (root!.offsetWidth || 1) || 1; const x = (event.clientX - box.left) / scale; const y = (event.clientY - box.top) / scale; const now = event.timeStamp; if (last && now > last.time) { const dt = now - last.time; // A short average, so one jittery event doesn't count as a sweep. vx = vx * 0.4 + ((x - last.x) / dt) * 0.6; vy = vy * 0.4 + ((y - last.y) / dt) * 0.6; } last = { x, y, time: now }; const c = Math.floor((x - g.ox) / g.step); const r = Math.floor((y - g.oy) / g.step); const i = c >= 0 && r >= 0 && c < g.cols && r < g.rows ? r * g.cols + c : -1; if (i === lastCell) return; lastCell = i; if (!push || reduced || solving || i < 0) return; const t = occupant.get(i); if (t === undefined || Math.hypot(vx, vy) < PUZZLE.sweep) return; const dir: Dir = Math.abs(vx) > Math.abs(vy) ? [Math.sign(vx), 0] : [0, Math.sign(vy)]; // The line of tiles in front of this one moves with it, if the slot past it is free. const line: number[] = [t]; let cc = c + dir[0]; let rr = r + dir[1]; while (inside(g, cc, rr) && occupant.has(rr * g.cols + cc)) { line.push(occupant.get(rr * g.cols + cc)!); cc += dir[0]; rr += dir[1]; } if (!inside(g, cc, rr)) return; for (let k = line.length - 1; k >= 0; k--) { const tile = line[k]; hideLabel(tile); const ti = at[tile]; move(tile, (Math.floor(ti / g.cols) + dir[1]) * g.cols + (ti % g.cols) + dir[0], paced(TIMING.push), TIMING.shove); } lastMove = null; } function onPointerEnter(event: PointerEvent) { if (event.pointerType !== "mouse") return; hovering = true; box = null; last = null; } function onPointerLeave(event: PointerEvent) { if (event.pointerType !== "mouse") return; hovering = false; lastCell = -1; } // Labels: a dwell for the mouse, a timed show for a tap. Focus is handled in CSS. const cleanups: (() => void)[] = []; ids.forEach((id, t) => { const node = els.get(id); if (!node) return; const clearLabel = () => window.clearTimeout(labelTimers.get(t)); const enter = (event: PointerEvent) => { if (event.pointerType !== "mouse") return; clearLabel(); labelTimers.set(t, window.setTimeout(() => showLabel(t), TIMING.dwell)); }; const leave = (event: PointerEvent) => { if (event.pointerType !== "mouse") return; clearLabel(); labelTimers.set(t, window.setTimeout(() => setLabel(t, false), TIMING.hide)); }; const down = (event: PointerEvent) => { if (event.pointerType === "mouse") return; clearLabel(); showLabel(t); labelTimers.set(t, window.setTimeout(() => setLabel(t, false), TIMING.touch)); }; const focus = () => placeLabel(t); node.addEventListener("pointerenter", enter); node.addEventListener("pointerleave", leave); node.addEventListener("pointerdown", down); node.addEventListener("focusin", focus); cleanups.push(() => { clearLabel(); node.removeEventListener("pointerenter", enter); node.removeEventListener("pointerleave", leave); node.removeEventListener("pointerdown", down); node.removeEventListener("focusin", focus); }); }); let frame = 0; const resize = new ResizeObserver(() => { cancelAnimationFrame(frame); frame = requestAnimationFrame(layout); }); resize.observe(root); resize.observe(holeEl); const view = new IntersectionObserver( ([entry]) => { inView = entry.isIntersecting; const enough = entry.intersectionRatio >= 0.3 || entry.intersectionRect.height >= window.innerHeight * 0.5; if (inView && enough && !solved) runSolve(); if (inView) { if (!driftTimer) scheduleDrift(); } else { window.clearTimeout(driftTimer); driftTimer = 0; } }, { threshold: [0, 0.3, 0.6] }, ); view.observe(root); const onVisibility = () => { if (document.hidden) { window.clearTimeout(driftTimer); driftTimer = 0; } else if (!driftTimer) scheduleDrift(); }; root.addEventListener("pointermove", onPointerMove); root.addEventListener("pointerenter", onPointerEnter); root.addEventListener("pointerleave", onPointerLeave); window.addEventListener("scroll", onScroll, { passive: true, capture: true }); document.addEventListener("visibilitychange", onVisibility); return () => { actions.current = null; stopSolve(); window.clearTimeout(driftTimer); cancelAnimationFrame(frame); resize.disconnect(); view.disconnect(); cleanups.forEach((fn) => fn()); root.removeEventListener("pointermove", onPointerMove); root.removeEventListener("pointerenter", onPointerEnter); root.removeEventListener("pointerleave", onPointerLeave); window.removeEventListener("scroll", onScroll, { capture: true }); document.removeEventListener("visibilitychange", onVisibility); }; }, [tileKey, cell, solve, every, push, seed, reduced]); const onKeyDown = (id: string) => (event: React.KeyboardEvent) => { const dir: Dir | null = event.key === "ArrowRight" ? [1, 0] : event.key === "ArrowLeft" ? [-1, 0] : event.key === "ArrowDown" ? [0, 1] : event.key === "ArrowUp" ? [0, -1] : null; if (!dir) return; event.preventDefault(); actions.current?.press(id, dir); }; const card = cn( `relative flex size-full items-center justify-center overflow-hidden border border-zinc-950/[0.08] bg-[var(--slide-puzzle-tile)] shadow-[0_1px_2px_rgb(0_0_0/0.05),0_2px_6px_-2px_rgb(0_0_0/0.06)] outline-none transition-transform ${TIMING.label} dark:border-white/10`, "rounded-[var(--slide-puzzle-radius)] group-data-[label]/tile:-translate-y-0.5 group-has-[:focus-visible]/tile:-translate-y-0.5", "focus-visible:ring-2 focus-visible:ring-[var(--slide-puzzle-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--slide-puzzle-tile)]", ); return ( <div ref={rootRef} className={cn( "relative isolate w-full text-zinc-950 dark:text-white", "[--slide-puzzle-tile:#ffffff] [--slide-puzzle-ring:#ff5a1f] dark:[--slide-puzzle-tile:#18181b]", className, )} style={ { "--slide-puzzle-cell": `${cell}px`, "--slide-puzzle-size": `${size}px`, "--slide-puzzle-radius": `${radius}px`, ...style, } as React.CSSProperties } {...props} > {grid && <Slots grid={grid} />} <div className="pointer-events-none relative flex flex-col items-center justify-center px-6 py-[var(--slide-puzzle-inset,calc(var(--slide-puzzle-cell)*2.25))]"> <div ref={holeRef} className="pointer-events-auto relative"> {children} </div> </div> <ul className="pointer-events-none absolute inset-0 m-0 list-none p-0"> {tiles.map((tile) => ( <li key={tile.id} ref={(node) => { if (node) tileEls.current.set(tile.id, node); else tileEls.current.delete(tile.id); }} className="group/tile pointer-events-auto absolute left-0 top-0 opacity-0 will-change-transform data-[label]:z-10 has-[:focus-visible]:z-10" style={{ width: "var(--slide-puzzle-size)", height: "var(--slide-puzzle-size)" }} > <span aria-hidden className={`absolute inset-0 rounded-[var(--slide-puzzle-radius)] opacity-0 shadow-[0_12px_24px_-10px_rgb(0_0_0/0.28)] transition-opacity ${TIMING.label} group-data-[label]/tile:opacity-100 group-has-[:focus-visible]/tile:opacity-100 dark:shadow-[0_12px_24px_-10px_rgb(0_0_0/0.7)]`} /> {tile.href ? ( <a href={tile.href} aria-label={tile.label} className={card}> {tile.icon} </a> ) : ( <button type="button" aria-label={tile.label} className={cn(card, "cursor-pointer")} onClick={() => actions.current?.press(tile.id)} onKeyDown={onKeyDown(tile.id)} > {tile.icon} </button> )} <span aria-hidden data-slide-puzzle-label className={cn( `pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 translate-y-1 whitespace-nowrap rounded-full bg-zinc-950 px-2.5 py-1 text-xs font-medium text-white opacity-0 shadow-sm transition-[opacity,translate] ${TIMING.label} dark:bg-white dark:text-zinc-950`, "group-data-[label]/tile:translate-y-0 group-data-[label]/tile:opacity-100 group-has-[:focus-visible]/tile:translate-y-0 group-has-[:focus-visible]/tile:opacity-100", "group-data-[below]/tile:bottom-auto group-data-[below]/tile:top-full group-data-[below]/tile:mb-0 group-data-[below]/tile:mt-2", )} > {tile.label} </span> </li> ))} </ul> </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Slide Puzzle. Each one installs whole, with the component included.
Team
A studio's careers section with the team as portrait tiles and three orange open seats. Rest on a face for a name and role.
import * as React from "react";
import { SlidePuzzle, type SlidePuzzleTile } from "@/components/ui/slide-puzzle";
const portrait = (id: string) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=facearea&facepad=2.5&w=128&h=128&q=80`;
// Field & Fold, a small type-tools studio: the team, plus the seats that are open.
const PEOPLE: [string, string, string][] = [
["ines", "Inês Duarte · Design lead", "1438761681033-6461ffad8d80"],
["matteo", "Matteo Ricci · Engineering", "1500648767791-00dcc994a43e"],
["sofia", "Sofia Lindqvist · Type design", "1494790108377-be9c29b29330"],
["dev", "Dev Arora · Founder", "1507003211169-0a1dd7228f2d"],
["hana", "Hana Mori · Product", "1534528741775-53994a69daeb"],
["noor", "Noor Haddad · Research", "1544005313-94ddf0286df2"],
["luca", "Luca Brandt · Engineering", "1506794778202-cad84cf45f1d"],
["ada", "Ada Okafor · Support", "1573496359142-b8d87734a5a2"],
["tomas", "Tomás Vidal · Fonts", "1552058544-f2b08422138a"],
];
const SEATS = ["Product designer", "Rust engineer", "Type engineer"];
const TILES: SlidePuzzleTile[] = [
...PEOPLE.map(([id, label, photo]) => ({
id,
label,
href: `#team-${id}`,
// eslint-disable-next-line @next/next/no-img-element
icon: <img src={portrait(photo)} alt="" draggable={false} className="size-full object-cover" />,
})),
...SEATS.map((role, i) => ({
id: `seat-${i}`,
label: `Open seat · ${role}`,
href: "#roles",
icon: (
<span className="flex size-full items-center justify-center bg-[#ff5a1f]/10 text-[#ff5a1f] dark:bg-[#ff5a1f]/15">
<svg viewBox="0 0 16 16" className="size-4" aria-hidden>
<path d="M8 3v10M3 8h10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</span>
),
})),
];
export function SlidePuzzleTeamDemo() {
return (
<section className="w-full max-w-5xl overflow-hidden rounded-3xl border border-zinc-950/[0.06] bg-zinc-50 text-zinc-950 dark:border-white/10 dark:bg-zinc-950 dark:text-white">
<SlidePuzzle tiles={TILES} cell={68} seed={3}>
<div className="flex max-w-md flex-col items-center text-center">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-[#ff5a1f]">Careers · 3 open seats</p>
<h2 className="mt-3 text-balance text-3xl font-medium tracking-[-0.03em] sm:text-4xl">
Come make small things feel right.
</h2>
<p className="mt-3 text-pretty text-[15px] leading-relaxed text-zinc-500 dark:text-zinc-400">
We're nine people in Lisbon and Oslo building the tools type designers open every morning. The orange
tiles are waiting for someone.
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<a
href="#roles"
className="inline-flex h-9 items-center rounded-full bg-zinc-950 px-4 text-sm font-medium text-white transition-colors hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ff5a1f] dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200"
>
See open roles
</a>
<a
href="#studio"
className="inline-flex h-9 items-center rounded-full px-3 text-sm font-medium text-zinc-600 transition-colors hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ff5a1f] dark:text-zinc-400 dark:hover:text-white"
>
How we work →
</a>
</div>
</div>
</SlidePuzzle>
</section>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Slide Puzzle: Icon tiles in a faint grid of slots around your headline, moving like a sliding puzzle. The first time it scrolls into view the tiles start scrambled and the puzzle solves itself, one cell at a time; after that a tile slides into a free slot every few seconds. Sweep the mouse fast into a tile and it's shoved along the grid with the tiles in front of it; rest on one and its name rises above it. This demo, Team: A studio's careers section with the team as portrait tiles and three orange open seats. Rest on a face for a name and role. 1. Install it: npx shadcn@latest add @keyframeui/slide-puzzle-team-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/slide-puzzle-team-demo.json instead.) It adds the demo, components/slide-puzzle-team-demo.tsx, and the component, components/ui/slide-puzzle.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <SlidePuzzle> on its own. 3. These props set its feel. Change them before you change the file: - cell (default 64): Size of one grid cell in px, tile and gap together. Tiles are three quarters of it. A narrow box shrinks its cells, to 70% at most, so six columns fit. - solve (default true): Start scrambled and solve the puzzle the first time it scrolls into view. - push (default true): Let a fast mouse sweep shove tiles along the grid, one cell, with the line of tiles in front. - speed (default 1): How fast every slide plays, as a multiple of the default pace: the solve's moves and their spacing, each drift, a push and a press. `2` is twice as fast. - seed (default 7): Picks the layout and the scramble. Change it for a different arrangement. 4. Don't change, in components/ui/slide-puzzle.tsx: - Tiles move only by `transform`, on WAAPI tweens written straight to their elements, never through React state. An interrupted tile retargets from where it is on screen, so it never jumps. - A tile only slides one cell along the grid into an empty slot, never into the hole, and drift and the scramble keep it within `band` cells of it. `occupant` changes with `at` on every move. - The solve is the scramble played backwards, each move waiting until the tile ahead has partly left the slot, so moves overlap but two tiles never share a slot. - Homes and the scramble come from the seeded PRNG, so a layout is the same on every visit, and `speed` is read through a ref as each move starts, so changing it never re-scrambles. - Reduced motion has no solve, drift, push or bump: tiles start home and a press moves at once, while labels and the first fade-in remain. The reduced flag is false while hydrating. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/slide-puzzle.md
Not Found
A docs 404 where the numbered tiles are buttons: press one, or use the arrow keys, and it slides into a free slot.
import * as React from "react";
import { SlidePuzzle, type SlidePuzzleTile } from "@/components/ui/slide-puzzle";
// Tinta's docs 404: numbered tiles you can slide about while you're here. No links, so every tile is a button.
const COLORS = ["#ff5a1f", "#2563eb", "#10b981", "#7c3aed", "#f43f5e", "#f59e0b", "#0ea5e9", "#ef4444", "#65a30d"];
const TILES: SlidePuzzleTile[] = COLORS.map((color, i) => ({
id: `tile-${i + 1}`,
label: `Tile ${i + 1}`,
icon: (
<span
className="flex size-full items-center justify-center text-lg font-semibold tabular-nums text-white"
style={{ backgroundColor: color }}
>
{i + 1}
</span>
),
}));
export function SlidePuzzleNotFoundDemo() {
return (
<section className="w-full max-w-5xl overflow-hidden rounded-3xl border border-zinc-950/[0.06] bg-white text-zinc-950 dark:border-white/10 dark:bg-zinc-950 dark:text-white">
<SlidePuzzle tiles={TILES} drift={false} seed={12}>
<div className="flex max-w-sm flex-col items-center text-center">
<p className="font-mono text-xs font-medium text-zinc-400">Error 404</p>
<h2 className="mt-3 text-balance text-3xl font-medium tracking-[-0.03em] sm:text-4xl">
This page slid out of place.
</h2>
<p className="mt-3 text-pretty text-[15px] leading-relaxed text-zinc-500 dark:text-zinc-400">
The link points at an empty slot. Push a few tiles around while you're here, or head back to the docs.
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<a
href="#home"
className="inline-flex h-9 items-center gap-1 rounded-full bg-[#ff5a1f] px-4 text-sm font-medium text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.25)] transition-colors hover:bg-[#f04d12] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ff5a1f]"
>
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
<path d="M10 4 6 8l4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Back home
</a>
<a
href="#search"
className="inline-flex h-9 items-center rounded-full border border-zinc-950/10 bg-white px-4 text-sm font-medium transition-colors hover:bg-zinc-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ff5a1f] dark:border-white/10 dark:bg-zinc-900 dark:hover:bg-zinc-800"
>
Search the docs
</a>
</div>
</div>
</SlidePuzzle>
</section>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Slide Puzzle: Icon tiles in a faint grid of slots around your headline, moving like a sliding puzzle. The first time it scrolls into view the tiles start scrambled and the puzzle solves itself, one cell at a time; after that a tile slides into a free slot every few seconds. Sweep the mouse fast into a tile and it's shoved along the grid with the tiles in front of it; rest on one and its name rises above it. This demo, Not Found: A docs 404 where the numbered tiles are buttons: press one, or use the arrow keys, and it slides into a free slot. 1. Install it: npx shadcn@latest add @keyframeui/slide-puzzle-404-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/slide-puzzle-404-demo.json instead.) It adds the demo, components/slide-puzzle-404-demo.tsx, and the component, components/ui/slide-puzzle.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <SlidePuzzle> on its own. 3. These props set its feel. Change them before you change the file: - cell (default 64): Size of one grid cell in px, tile and gap together. Tiles are three quarters of it. A narrow box shrinks its cells, to 70% at most, so six columns fit. - solve (default true): Start scrambled and solve the puzzle the first time it scrolls into view. - push (default true): Let a fast mouse sweep shove tiles along the grid, one cell, with the line of tiles in front. - speed (default 1): How fast every slide plays, as a multiple of the default pace: the solve's moves and their spacing, each drift, a push and a press. `2` is twice as fast. - seed (default 7): Picks the layout and the scramble. Change it for a different arrangement. 4. Don't change, in components/ui/slide-puzzle.tsx: - Tiles move only by `transform`, on WAAPI tweens written straight to their elements, never through React state. An interrupted tile retargets from where it is on screen, so it never jumps. - A tile only slides one cell along the grid into an empty slot, never into the hole, and drift and the scramble keep it within `band` cells of it. `occupant` changes with `at` on every move. - The solve is the scramble played backwards, each move waiting until the tile ahead has partly left the slot, so moves overlap but two tiles never share a slot. - Homes and the scramble come from the seeded PRNG, so a layout is the same on every visit, and `speed` is read through a ref as each move starts, so changing it never re-scrambles. - Reduced motion has no solve, drift, push or bump: tiles start home and a press moves at once, while labels and the first fade-in remain. The reduced flag is false while hydrating. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/slide-puzzle.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
tiles Required | SlidePuzzleTile[] | None | The tiles, as { id, label, icon, href? }. label is the name that shows on hover and focus (and what screen readers announce), icon is an inline SVG or an <img>, centered and clipped to the tile. A tile with href is a link; without one it's a button that slides into a free slot when pressed. |
children | React.ReactNode | None | Your headline and calls to action, centered. Their box, plus 10px, is the hole no tile enters. |
cell | number | 64 | Size of one grid cell in px, tile and gap together. Tiles are three quarters of it. A narrow box shrinks its cells, to 70% at most, so six columns fit. |
solve | boolean | true | Start scrambled and solve the puzzle the first time it scrolls into view. |
drift | boolean | number | true | Slide one tile into a free neighbouring slot every so often: true for every 2.4s, a number of ms, or false to keep still. It pauses under the pointer and off screen. |
push | boolean | true | Let a fast mouse sweep shove tiles along the grid, one cell, with the line of tiles in front. |
speed | number | 1 | How fast every slide plays, as a multiple of the default pace: the solve's moves and their spacing, each drift, a push and a press. 2 is twice as fast. |
seed | number | 7 | Picks the layout and the scramble. Change it for a different arrangement. |
className | string | None | Extra classes for the root. The slots are drawn in its text color. Set the tile color with --slide-puzzle-tile (white, #18181b in dark), the focus ring with --slide-puzzle-ring (#ff5a1f) and the space above and below the headline with --slide-puzzle-inset (2.25 cells). |
tilesRequiredType
SlidePuzzleTile[]The tiles, as
{ id, label, icon, href? }.labelis the name that shows on hover and focus (and what screen readers announce),iconis an inline SVG or an<img>, centered and clipped to the tile. A tile withhrefis a link; without one it's a button that slides into a free slot when pressed.childrenType
React.ReactNodeYour headline and calls to action, centered. Their box, plus 10px, is the hole no tile enters.
cellType
numberDefault64Size of one grid cell in px, tile and gap together. Tiles are three quarters of it. A narrow box shrinks its cells, to 70% at most, so six columns fit.
solveType
booleanDefaulttrueStart scrambled and solve the puzzle the first time it scrolls into view.
driftType
boolean | numberDefaulttrueSlide one tile into a free neighbouring slot every so often:
truefor every 2.4s, a number of ms, orfalseto keep still. It pauses under the pointer and off screen.pushType
booleanDefaulttrueLet a fast mouse sweep shove tiles along the grid, one cell, with the line of tiles in front.
speedType
numberDefault1How fast every slide plays, as a multiple of the default pace: the solve's moves and their spacing, each drift, a push and a press.
2is twice as fast.seedType
numberDefault7Picks the layout and the scramble. Change it for a different arrangement.
classNameType
stringExtra classes for the root. The slots are drawn in its text color. Set the tile color with
--slide-puzzle-tile(white,#18181bin dark), the focus ring with--slide-puzzle-ring(#ff5a1f) and the space above and below the headline with--slide-puzzle-inset(2.25 cells).