{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slide-puzzle",
  "type": "registry:ui",
  "title": "Slide Puzzle",
  "description": "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.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "background",
    "interaction",
    "landing"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/slide-puzzle",
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/slide-puzzle.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - Tiles move only by `transform`, on WAAPI tweens written straight to their elements, never\n *   through React state. An interrupted tile retargets from where it is on screen, so it never\n *   jumps.\n * - A tile only slides one cell along the grid into an empty slot, never into the hole, and drift\n *   and the scramble keep it within `band` cells of it. `occupant` changes with `at` on every move.\n * - The solve is the scramble played backwards, each move waiting until the tile ahead has partly\n *   left the slot, so moves overlap but two tiles never share a slot.\n * - Homes and the scramble come from the seeded PRNG, so a layout is the same on every visit, and\n *   `speed` is read through a ref as each move starts, so changing it never re-scrambles.\n * - Reduced motion has no solve, drift, push or bump: tiles start home and a press moves at once,\n *   while labels and the first fade-in remain. The reduced flag is false while hydrating.\n */\nconst TIMING = {\n  /**\n   * The solve, in ms: a move's run; the gap between starts (squeezed to fit `total`); the share of\n   * a move before the slot it leaves can be entered; and the wait after coming into view, so the\n   * tiles have faded in first.\n   */\n  solve: { duration: 200, stagger: 55, total: 1300, clear: 0.45, delay: 280 },\n  /** After the solve's last move lands, before drifting starts, in ms. */\n  settle: 40,\n  /** A drift: one move's run, and the default gap between moves, in ms. */\n  drift: { duration: 340, every: 2400 },\n  /** A shove or a press sliding a tile one cell, in ms. */\n  push: 220,\n  /** A pressed tile with nowhere to go bumping, in ms, and its curve. */\n  bump: { duration: 180, easing: \"ease-out\" },\n  /** Tiles fading in the first time they're placed, in ms, and its curve. */\n  appear: { duration: 320, easing: \"ease-out\" },\n  /** Sliding one cell: starts and stops with friction. */\n  inOut: \"cubic-bezier(0.65, 0, 0.35, 1)\",\n  /** A push or a press: all the speed up front. */\n  shove: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n  /** The pointer resting on a tile before its label shows, in ms. */\n  dwell: 120,\n  /** After the pointer leaves, before the label goes, in ms. */\n  hide: 120,\n  /** How long a tapped tile keeps its label, in ms. */\n  touch: 1800,\n  /** A label showing: the tile lifting, its shadow fading in, the name rising. Tailwind classes. */\n  label: \"duration-150 ease-out\",\n  /** How fast every slide plays, as a multiple of the default pace (the `speed` prop). */\n  speed: 1,\n  /** The slowest pace `speed` is held to, as a multiple of the default pace. */\n  minSpeed: 0.05,\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * SLIDE PUZZLE STORYBOARD\n *\n *  rest     a faint grid of rounded slots fills the box, fading out\n *           toward its edges and left out under your headline. The\n *           headline's box, plus 10px, is a hole no tile ever enters.\n *           The tiles sit one per slot, hugging the hole, spread\n *           round all four sides. A narrow box shrinks its cells (to\n *           70% at most) so six columns fit\n *  rules    a tile only ever slides one cell, along the grid, into\n *           an empty slot. Never diagonally, never through another\n *           tile, never into the hole, and it drifts no more than 2\n *           cells out from it\n *  solve    the first time 30% of it is in view, the tiles start\n *           scrambled (two moves each) and the puzzle solves itself:\n *           once they've faded in, the moves play back in reverse,\n *           200ms each, a new one every ~55ms. A tile waits for the\n *           one ahead of it to be 45% out of a slot before it slides\n *           in. About 1.5s\n *  drift    then every 2.4s one tile slides into a free neighbouring\n *           slot (340ms, in-out, like a tile with friction). It never\n *           undoes the last move. Pauses while the pointer is over\n *           it, off screen and on a hidden tab\n *  push     sweep the mouse fast (0.5px/ms or more) into a tile and\n *           it's shoved one cell along the sweep, with the line of\n *           tiles in front of it, if the slot past the line is free.\n *           220ms, all the speed up front. A slow pointer never\n *           pushes\n *  label    rest on a tile for 120ms, or tab to it, and its name\n *           rises 4px into a pill above it (below it on the top\n *           row) while the tile lifts 2px onto a shadow. It goes\n *           120ms after the pointer leaves\n *  slide    a tile without a link is a button: press it and it\n *           slides into a free neighbour, away from the hole if it\n *           can. Arrow keys move it that way. A blocked tile bumps\n *  touch    a tap shows the label for 1.8s; no pushing\n *  resize   the grid is rebuilt from `cell` and the hole from the\n *           headline's box, and the tiles go home at once\n *  reduced  no solve, no drift, no push: the tiles are home from the\n *           start. Labels still fade in, and a pressed tile moves at\n *           once\n * ───────────────────────────────────────────────────────── */\n\nconst PUZZLE = {\n  tile: 0.75, // tile size, as a share of `cell`; the rest is the gap between slots\n  radius: 0.25, // corner radius, as a share of the tile\n  margin: 10, // px kept clear round the headline; a slot that reaches into it is part of the hole\n  band: 2, // cells out from the hole a tile may drift; homes sit 1 out where they can, else 2\n  fit: { columns: 6, min: 0.7 }, // a narrow box shrinks its cells until this many columns fit, but never below this share of `cell`\n  scramble: 2, // moves per tile the solve undoes\n  sweep: 0.5, // px/ms the mouse must be moving to shove a tile\n  bump: 3, // px a pressed tile with nowhere to go nudges\n  seed: 7,\n} as const;\n\nconst REDUCED = \"(prefers-reduced-motion: reduce)\";\n\ntype Dir = [number, number];\nconst DIRS: Dir[] = [\n  [1, 0],\n  [-1, 0],\n  [0, 1],\n  [0, -1],\n];\n\ntype Rect = { c0: number; c1: number; r0: number; r1: number };\n\ntype Grid = {\n  width: number; // px, the box\n  step: number; // px per cell, `cell` or less in a narrow box\n  size: number; // px, a tile\n  inset: number; // px from a cell's edge to its tile\n  radius: number; // px, a tile's corners\n  cols: number;\n  rows: number;\n  ox: number; // px from the left of the box to the first column\n  oy: number;\n  hole: Set<number>; // cell indices the headline covers\n  around: Rect; // what distances are measured from: the hole's cells, or the middle cell\n  key: string; // changes when the cells or the hole do\n};\n\n/** Small seeded PRNG (mulberry32), so a layout is the same on every visit. */\nfunction random(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n/** Cells between a cell and the hole, counting diagonals as one (0 inside it). */\nfunction reach(grid: Grid, c: number, r: number) {\n  const { c0, c1, r0, r1 } = grid.around;\n  return Math.max(c0 - c, 0, c - c1, r0 - r, 0, r - r1);\n}\n\nfunction inside(grid: Grid, c: number, r: number) {\n  return c >= 0 && r >= 0 && c < grid.cols && r < grid.rows && !grid.hole.has(r * grid.cols + c);\n}\n\n/** Homes for `count` tiles: near the hole, spread apart, the same for the same seed. */\nfunction planHomes(grid: Grid, count: number, seed: number) {\n  const rand = random(seed);\n  const open: number[] = [];\n  for (let r = 0; r < grid.rows; r++) {\n    for (let c = 0; c < grid.cols; c++) {\n      const d = reach(grid, c, r);\n      if (d >= 1 && d <= PUZZLE.band && inside(grid, c, r)) open.push(r * grid.cols + c);\n    }\n  }\n  // Leave room to move: never fill more than 60% of the band.\n  const n = Math.min(count, Math.floor(open.length * 0.6));\n  const homes: number[] = [];\n  const jitter = new Map(open.map((i) => [i, rand()]));\n  for (let k = 0; k < n; k++) {\n    let best = -1;\n    let score = -Infinity;\n    for (const i of open) {\n      if (homes.includes(i)) continue;\n      const c = i % grid.cols;\n      const r = Math.floor(i / grid.cols);\n      let near = 4;\n      for (const h of homes) {\n        near = Math.min(near, Math.hypot(c - (h % grid.cols), r - Math.floor(h / grid.cols)));\n      }\n      // Hug the hole, keep a couple of cells from the tiles already placed, and a little luck.\n      const s = Math.min(near, 2.4) - (reach(grid, c, r) - 1) * 1.1 + (jitter.get(i) ?? 0) * 0.9;\n      if (s > score) {\n        score = s;\n        best = i;\n      }\n    }\n    if (best < 0) break;\n    homes.push(best);\n  }\n  return homes;\n}\n\ntype Move = { tile: number; from: number; to: number };\n\n/** Walk the tiles away from home, two moves each; the solve plays these backwards. */\nfunction planScramble(grid: Grid, homes: number[], seed: number) {\n  const rand = random(seed * 31 + 11);\n  const at = [...homes];\n  const taken = new Set(at);\n  const moved = at.map(() => 0);\n  const last = at.map(() => -1);\n  const moves: Move[] = [];\n  const target = homes.length * PUZZLE.scramble;\n  for (let tries = 0; moves.length < target && tries < target * 8; tries++) {\n    // The tile that has moved least goes next, so every tile takes part.\n    const least = Math.min(...moved);\n    const pool = at.map((_, t) => t).filter((t) => moved[t] === least);\n    const t = pool[Math.floor(rand() * pool.length)];\n    const c = at[t] % grid.cols;\n    const r = Math.floor(at[t] / grid.cols);\n    const options = DIRS.map(([dc, dr]) => [c + dc, r + dr] as const).filter(\n      ([nc, nr]) =>\n        inside(grid, nc, nr) &&\n        !taken.has(nr * grid.cols + nc) &&\n        nr * grid.cols + nc !== last[t] &&\n        reach(grid, nc, nr) <= PUZZLE.band,\n    );\n    if (!options.length) {\n      moved[t] += 1;\n      continue;\n    }\n    const [nc, nr] = options[Math.floor(rand() * options.length)];\n    const to = nr * grid.cols + nc;\n    moves.push({ tile: t, from: at[t], to });\n    taken.delete(at[t]);\n    taken.add(to);\n    last[t] = at[t];\n    at[t] = to;\n    moved[t] += 1;\n  }\n  return { start: at, moves };\n}\n\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(REDUCED);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\n\n/** prefers-reduced-motion, false on the server and in the hydration render. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(REDUCED).matches,\n    () => false,\n  );\n}\n\nexport interface SlidePuzzleTile {\n  /** Stable key for the tile. */\n  id: string;\n  /** Its name: the label that shows on hover and focus, and what screen readers announce. */\n  label: string;\n  /** What's on the tile: an inline SVG glyph or an `<img>`; it's centered and clipped to the tile. */\n  icon: React.ReactNode;\n  /** Makes the tile a link. Without it the tile is a button that slides when pressed. */\n  href?: string;\n}\n\nexport interface SlidePuzzleProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** The tiles, scattered round the children in slots of their own. */\n  tiles: SlidePuzzleTile[];\n  /** Your headline and calls to action, centered. Their box, plus 10px, is the hole no tile enters. */\n  children?: React.ReactNode;\n  /** Size of one grid cell in px, tile and gap together. Tiles are three quarters of it. Defaults to 64. */\n  cell?: number;\n  /** Start scrambled and solve the puzzle the first time it scrolls into view. Defaults to true. */\n  solve?: boolean;\n  /** 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. */\n  drift?: boolean | number;\n  /** Let a fast mouse sweep shove tiles along the grid. Defaults to true. */\n  push?: boolean;\n  /** Picks the layout and the scramble. Change it for a different arrangement. Defaults to 7. */\n  seed?: number;\n  /** 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. */\n  speed?: number;\n}\n\n/* ── Slots ────────────────────────────────────────────────── */\n// The empty grid, drawn once per layout. Slots fade with distance from the\n// headline and are left out under it, so the text sits on a clean field.\n\nfunction Slots({ grid }: { grid: Grid }) {\n  const slots: React.ReactNode[] = [];\n  for (let r = 0; r < grid.rows; r++) {\n    for (let c = 0; c < grid.cols; c++) {\n      if (grid.hole.has(r * grid.cols + c)) continue;\n      const fade = Math.min(1, Math.max(0.12, 1 - (reach(grid, c, r) - PUZZLE.band) * 0.3));\n      const edge = c === 0 || r === 0 || c === grid.cols - 1 || r === grid.rows - 1 ? 0.6 : 1;\n      slots.push(\n        <rect\n          key={r * grid.cols + c}\n          x={grid.ox + c * grid.step + grid.inset + 0.5}\n          y={grid.oy + r * grid.step + grid.inset + 0.5}\n          width={grid.size - 1}\n          height={grid.size - 1}\n          rx={grid.radius}\n          opacity={fade * edge}\n        />,\n      );\n    }\n  }\n  return (\n    <svg aria-hidden className=\"pointer-events-none absolute inset-0 -z-10 size-full\">\n      <g fill=\"currentColor\" fillOpacity={0.025} stroke=\"currentColor\" strokeOpacity={0.06}>\n        {slots}\n      </g>\n    </svg>\n  );\n}\n\n/* ── SlidePuzzle ──────────────────────────────────────────── */\n\nexport function SlidePuzzle({\n  tiles,\n  cell = 64,\n  solve = true,\n  drift = true,\n  push = true,\n  seed = PUZZLE.seed,\n  speed = TIMING.speed,\n  className,\n  style,\n  children,\n  ...props\n}: SlidePuzzleProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const holeRef = React.useRef<HTMLDivElement>(null);\n  const tileEls = React.useRef(new Map<string, HTMLLIElement>());\n  const actions = React.useRef<{ press: (id: string, dir?: Dir) => void } | null>(null);\n  const [grid, setGrid] = React.useState<Grid | null>(null);\n  const reduced = usePrefersReducedMotion();\n\n  const size = Math.round(cell * PUZZLE.tile);\n  const radius = Math.round(size * PUZZLE.radius);\n  const every = drift === true ? TIMING.drift.every : drift === false ? 0 : drift;\n  const tileKey = tiles.map((t) => t.id).join(\"\\u0000\");\n\n  // Read by the moves as they start, so a new speed never rebuilds (and re-scrambles) the puzzle.\n  const speedRef = React.useRef(speed);\n  React.useEffect(() => {\n    speedRef.current = Math.max(TIMING.minSpeed, speed);\n  }, [speed]);\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const holeEl = holeRef.current;\n    if (!root || !holeEl) return;\n    const ids = tileKey ? tileKey.split(\"\\u0000\") : [];\n    const els = tileEls.current;\n\n    let g: Grid | null = null;\n    let at: number[] = []; // cell per tile, -1 when it didn't fit\n    let occupant = new Map<number, number>(); // cell → tile\n    let pending: Move[] = []; // the solve, once planned\n    let solved = !solve || reduced;\n    let solving = false;\n    let inView = false;\n    let hovering = false;\n    let driftTimer = 0;\n    let lastMove: { tile: number; from: number } | null = null;\n    const timers = new Set<number>();\n    const shown = new Set<number>(); // tiles that have faded in\n    const moving = new WeakMap<HTMLElement, Animation>();\n    const labelTimers = new Map<number, number>();\n\n    const later = (fn: () => void, ms: number) => {\n      const id = window.setTimeout(() => {\n        timers.delete(id);\n        fn();\n      }, ms);\n      timers.add(id);\n      return id;\n    };\n    const el = (t: number) => els.get(ids[t]);\n    /** A slide's ms at the current `speed`. */\n    const paced = (ms: number) => ms / speedRef.current;\n    const cellXY = (i: number) => {\n      const grid = g!;\n      return [grid.ox + (i % grid.cols) * grid.step + grid.inset, grid.oy + Math.floor(i / grid.cols) * grid.step + grid.inset];\n    };\n    const transformOf = (i: number) => {\n      const [x, y] = cellXY(i);\n      return `translate3d(${x}px, ${y}px, 0)`;\n    };\n\n    /** Move a tile's element to a cell: at once, or on a compositor tween from wherever it is now. */\n    function render(t: number, duration = 0, easing: string = TIMING.inOut) {\n      const node = el(t);\n      if (!node || !g) return;\n      const cellIndex = at[t];\n      if (cellIndex < 0) {\n        node.hidden = true;\n        return;\n      }\n      node.hidden = false;\n      node.toggleAttribute(\"data-below\", Math.floor(cellIndex / g.cols) === 0);\n      const to = transformOf(cellIndex);\n      const running = moving.get(node);\n      let from = node.style.transform;\n      if (running && running.playState === \"running\") {\n        // Retarget from where it is on screen, so an interrupted tile never jumps.\n        from = getComputedStyle(node).transform;\n        running.cancel();\n      }\n      node.style.transform = to;\n      if (!shown.has(t)) {\n        shown.add(t);\n        node.style.opacity = \"1\";\n        node.animate([{ opacity: 0 }, { opacity: 1 }], { duration: TIMING.appear.duration, easing: TIMING.appear.easing });\n        return;\n      }\n      if (duration > 0 && !reduced && from && from !== to) {\n        moving.set(node, node.animate([{ transform: from }, { transform: to }], { duration, easing }));\n      }\n    }\n\n    function move(t: number, to: number, duration: number, easing: string = TIMING.inOut) {\n      occupant.delete(at[t]);\n      occupant.set(to, t);\n      at[t] = to;\n      render(t, duration, easing);\n    }\n\n    function setLabel(t: number, on: boolean) {\n      const node = el(t);\n      if (node) node.toggleAttribute(\"data-label\", on);\n    }\n    /** Centre a tile's label over it, but keep it 8px inside the box, so an edge tile's name isn't cut off. */\n    function placeLabel(t: number) {\n      const node = el(t);\n      const label = node?.querySelector<HTMLElement>(\"[data-slide-puzzle-label]\");\n      if (!label || !g || at[t] < 0) return;\n      const half = label.offsetWidth / 2;\n      const [x] = cellXY(at[t]);\n      const lo = 8 - x + half;\n      const hi = g.width - 8 - x - half;\n      const center = lo > hi ? g.size / 2 : Math.min(hi, Math.max(lo, g.size / 2));\n      label.style.left = `${center}px`;\n    }\n    function showLabel(t: number) {\n      placeLabel(t);\n      setLabel(t, true);\n    }\n    function hideLabel(t: number) {\n      window.clearTimeout(labelTimers.get(t));\n      setLabel(t, false);\n    }\n\n    function measure(): Grid {\n      const w = root!.clientWidth;\n      const step = Math.round(Math.max(cell * PUZZLE.fit.min, Math.min(cell, w / PUZZLE.fit.columns)));\n      const size = Math.round(step * PUZZLE.tile);\n      const inset = (step - size) / 2;\n      const radius = Math.round(size * PUZZLE.radius);\n      if (root!.style.getPropertyValue(\"--slide-puzzle-cell\") !== `${step}px`) {\n        // Tiles and the headline's padding read these, so a phone gets smaller cells.\n        root!.style.setProperty(\"--slide-puzzle-cell\", `${step}px`);\n        root!.style.setProperty(\"--slide-puzzle-size\", `${size}px`);\n        root!.style.setProperty(\"--slide-puzzle-radius\", `${radius}px`);\n      }\n      const h = root!.clientHeight;\n      const cols = Math.max(1, Math.floor(w / step));\n      const rows = Math.max(1, Math.floor(h / step));\n      const ox = Math.round((w - cols * step) / 2);\n      const oy = Math.round((h - rows * step) / 2);\n      const box = root!.getBoundingClientRect();\n      const scale = box.width / (root!.offsetWidth || 1) || 1; // a scaled parent (a preview frame)\n      const hb = holeEl!.getBoundingClientRect();\n      const hx0 = (hb.left - box.left) / scale - PUZZLE.margin;\n      const hx1 = (hb.right - box.left) / scale + PUZZLE.margin;\n      const hy0 = (hb.top - box.top) / scale - PUZZLE.margin;\n      const hy1 = (hb.bottom - box.top) / scale + PUZZLE.margin;\n      const hole = new Set<number>();\n      const empty = hb.width === 0 || hb.height === 0;\n      let rect: Rect = { c0: Infinity, c1: -Infinity, r0: Infinity, r1: -Infinity };\n      if (!empty) {\n        for (let r = 0; r < rows; r++) {\n          for (let c = 0; c < cols; c++) {\n            const x0 = ox + c * step + inset;\n            const y0 = oy + r * step + inset;\n            if (x0 < hx1 && x0 + size > hx0 && y0 < hy1 && y0 + size > hy0) {\n              hole.add(r * cols + c);\n              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) };\n            }\n          }\n        }\n      }\n      if (!hole.size) {\n        const c = Math.floor(cols / 2);\n        const r = Math.floor(rows / 2);\n        rect = { c0: c, c1: c, r0: r, r1: r };\n      }\n      return { width: w, step, size, inset, radius, cols, rows, ox, oy, hole, around: rect, key: `${step}:${cols}x${rows}:${[...hole].join(\",\")}` };\n    }\n\n    function stopSolve() {\n      for (const id of timers) window.clearTimeout(id);\n      timers.clear();\n      solving = false;\n    }\n\n    function layout() {\n      const next = measure();\n      const same = g && g.key === next.key;\n      const moved = !g || g.ox !== next.ox || g.oy !== next.oy || !same;\n      g = next;\n      if (moved) setGrid(next);\n      if (same) {\n        // Only the margins moved: keep every tile in its cell.\n        at.forEach((_, t) => render(t));\n        return;\n      }\n      if (solving) solved = true; // a resize mid-solve skips to the end; before it starts, it re-plans\n      stopSolve();\n      const homes = planHomes(next, ids.length, seed);\n      let start = homes;\n      pending = [];\n      if (!solved) {\n        const scramble = planScramble(next, homes, seed);\n        start = scramble.start;\n        pending = scramble.moves.reverse().map(({ tile, from, to }) => ({ tile, from: to, to: from }));\n      }\n      at = ids.map((_, t) => start[t] ?? -1);\n      occupant = new Map(at.flatMap((i, t) => (i >= 0 ? [[i, t] as [number, number]] : [])));\n      lastMove = null;\n      at.forEach((_, t) => {\n        const node = el(t);\n        const running = node && moving.get(node);\n        if (running) running.cancel();\n        render(t);\n      });\n      if (!solved && inView) runSolve();\n    }\n\n    /** Play the scramble backwards: overlapping moves, but never two tiles in one slot. */\n    function runSolve() {\n      if (solving || solved || !pending.length) return;\n      solving = true;\n      const { clear } = TIMING.solve;\n      const duration = paced(TIMING.solve.duration);\n      const stagger = Math.min(paced(TIMING.solve.stagger), paced(TIMING.solve.total) / pending.length);\n      const tileFree = new Map<number, number>();\n      const slotFree = new Map<number, number>();\n      let cursor = TIMING.solve.delay;\n      let end = 0;\n      for (const m of pending) {\n        const start = Math.max(cursor, tileFree.get(m.tile) ?? 0, slotFree.get(m.to) ?? 0);\n        tileFree.set(m.tile, start + duration);\n        slotFree.set(m.from, start + duration * clear);\n        cursor = start + stagger;\n        end = Math.max(end, start + duration);\n        later(() => move(m.tile, m.to, duration), start);\n      }\n      later(() => {\n        solving = false;\n        solved = true;\n        pending = [];\n        scheduleDrift();\n      }, end + TIMING.settle);\n    }\n\n    function free(t: number, dir: Dir, band: boolean) {\n      const grid = g!;\n      const c = (at[t] % grid.cols) + dir[0];\n      const r = Math.floor(at[t] / grid.cols) + dir[1];\n      if (!inside(grid, c, r)) return -1;\n      const i = r * grid.cols + c;\n      if (occupant.has(i)) return -1;\n      if (band && reach(grid, c, r) > PUZZLE.band) return -1;\n      return i;\n    }\n\n    function driftOnce() {\n      if (!g) return;\n      const active = document.activeElement;\n      const options: [number, number][] = [];\n      at.forEach((i, t) => {\n        if (i < 0) return;\n        const node = el(t);\n        if (node?.hasAttribute(\"data-label\") || (active && node?.contains(active))) return;\n        for (const dir of DIRS) {\n          const to = free(t, dir, true);\n          if (to < 0) continue;\n          if (lastMove && lastMove.tile === t && lastMove.from === to) continue;\n          options.push([t, to]);\n        }\n      });\n      if (!options.length) return;\n      // Prefer a different tile from the last one, so the motion wanders.\n      const fresh = options.filter(([t]) => t !== lastMove?.tile);\n      const pool = fresh.length ? fresh : options;\n      const [t, to] = pool[Math.floor(Math.random() * pool.length)];\n      lastMove = { tile: t, from: at[t] };\n      move(t, to, paced(TIMING.drift.duration));\n    }\n\n    function scheduleDrift() {\n      window.clearTimeout(driftTimer);\n      driftTimer = 0;\n      if (!every || reduced || !solved || !inView || document.hidden) return;\n      driftTimer = window.setTimeout(() => {\n        driftTimer = 0;\n        if (!hovering && !solving) driftOnce();\n        scheduleDrift();\n      }, every);\n    }\n\n    function bump(t: number, dir: Dir) {\n      const node = el(t);\n      if (!node || reduced) return;\n      const [x, y] = cellXY(at[t]);\n      const d = PUZZLE.bump;\n      node.animate(\n        [\n          { transform: `translate3d(${x}px, ${y}px, 0)` },\n          { transform: `translate3d(${x + dir[0] * d}px, ${y + dir[1] * d}px, 0)` },\n          { transform: `translate3d(${x}px, ${y}px, 0)` },\n        ],\n        { duration: paced(TIMING.bump.duration), easing: TIMING.bump.easing },\n      );\n    }\n\n    /** A pressed tile (click, Enter, an arrow key) slides to a free neighbour. */\n    function press(id: string, dir?: Dir) {\n      const t = ids.indexOf(id);\n      if (t < 0 || !g || at[t] < 0 || solving) return;\n      const grid = g;\n      let options = dir ? [dir] : DIRS;\n      if (!dir) {\n        // Away from the hole first.\n        const c = at[t] % grid.cols;\n        const r = Math.floor(at[t] / grid.cols);\n        const away = (d: Dir) => reach(grid, c + d[0], r + d[1]) - reach(grid, c, r);\n        options = [...DIRS].sort((a, b) => away(b) - away(a));\n      }\n      for (const d of options) {\n        const to = free(t, d, false);\n        if (to < 0) continue;\n        lastMove = { tile: t, from: at[t] };\n        move(t, to, paced(TIMING.push), TIMING.shove);\n        placeLabel(t);\n        scheduleDrift();\n        return;\n      }\n      bump(t, options[0]);\n    }\n    actions.current = { press };\n\n    // Pushing: watch the mouse's speed and the cell it's in.\n    let last: { x: number; y: number; time: number } | null = null;\n    let vx = 0;\n    let vy = 0;\n    let lastCell = -1;\n    let box: DOMRect | null = null;\n    const onScroll = () => {\n      box = null;\n    };\n\n    function onPointerMove(event: PointerEvent) {\n      if (event.pointerType !== \"mouse\" || !g) return;\n      box ??= root!.getBoundingClientRect();\n      const scale = box.width / (root!.offsetWidth || 1) || 1;\n      const x = (event.clientX - box.left) / scale;\n      const y = (event.clientY - box.top) / scale;\n      const now = event.timeStamp;\n      if (last && now > last.time) {\n        const dt = now - last.time;\n        // A short average, so one jittery event doesn't count as a sweep.\n        vx = vx * 0.4 + ((x - last.x) / dt) * 0.6;\n        vy = vy * 0.4 + ((y - last.y) / dt) * 0.6;\n      }\n      last = { x, y, time: now };\n      const c = Math.floor((x - g.ox) / g.step);\n      const r = Math.floor((y - g.oy) / g.step);\n      const i = c >= 0 && r >= 0 && c < g.cols && r < g.rows ? r * g.cols + c : -1;\n      if (i === lastCell) return;\n      lastCell = i;\n      if (!push || reduced || solving || i < 0) return;\n      const t = occupant.get(i);\n      if (t === undefined || Math.hypot(vx, vy) < PUZZLE.sweep) return;\n      const dir: Dir = Math.abs(vx) > Math.abs(vy) ? [Math.sign(vx), 0] : [0, Math.sign(vy)];\n      // The line of tiles in front of this one moves with it, if the slot past it is free.\n      const line: number[] = [t];\n      let cc = c + dir[0];\n      let rr = r + dir[1];\n      while (inside(g, cc, rr) && occupant.has(rr * g.cols + cc)) {\n        line.push(occupant.get(rr * g.cols + cc)!);\n        cc += dir[0];\n        rr += dir[1];\n      }\n      if (!inside(g, cc, rr)) return;\n      for (let k = line.length - 1; k >= 0; k--) {\n        const tile = line[k];\n        hideLabel(tile);\n        const ti = at[tile];\n        move(tile, (Math.floor(ti / g.cols) + dir[1]) * g.cols + (ti % g.cols) + dir[0], paced(TIMING.push), TIMING.shove);\n      }\n      lastMove = null;\n    }\n    function onPointerEnter(event: PointerEvent) {\n      if (event.pointerType !== \"mouse\") return;\n      hovering = true;\n      box = null;\n      last = null;\n    }\n    function onPointerLeave(event: PointerEvent) {\n      if (event.pointerType !== \"mouse\") return;\n      hovering = false;\n      lastCell = -1;\n    }\n\n    // Labels: a dwell for the mouse, a timed show for a tap. Focus is handled in CSS.\n    const cleanups: (() => void)[] = [];\n    ids.forEach((id, t) => {\n      const node = els.get(id);\n      if (!node) return;\n      const clearLabel = () => window.clearTimeout(labelTimers.get(t));\n      const enter = (event: PointerEvent) => {\n        if (event.pointerType !== \"mouse\") return;\n        clearLabel();\n        labelTimers.set(t, window.setTimeout(() => showLabel(t), TIMING.dwell));\n      };\n      const leave = (event: PointerEvent) => {\n        if (event.pointerType !== \"mouse\") return;\n        clearLabel();\n        labelTimers.set(t, window.setTimeout(() => setLabel(t, false), TIMING.hide));\n      };\n      const down = (event: PointerEvent) => {\n        if (event.pointerType === \"mouse\") return;\n        clearLabel();\n        showLabel(t);\n        labelTimers.set(t, window.setTimeout(() => setLabel(t, false), TIMING.touch));\n      };\n      const focus = () => placeLabel(t);\n      node.addEventListener(\"pointerenter\", enter);\n      node.addEventListener(\"pointerleave\", leave);\n      node.addEventListener(\"pointerdown\", down);\n      node.addEventListener(\"focusin\", focus);\n      cleanups.push(() => {\n        clearLabel();\n        node.removeEventListener(\"pointerenter\", enter);\n        node.removeEventListener(\"pointerleave\", leave);\n        node.removeEventListener(\"pointerdown\", down);\n        node.removeEventListener(\"focusin\", focus);\n      });\n    });\n\n    let frame = 0;\n    const resize = new ResizeObserver(() => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(layout);\n    });\n    resize.observe(root);\n    resize.observe(holeEl);\n\n    const view = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        const enough = entry.intersectionRatio >= 0.3 || entry.intersectionRect.height >= window.innerHeight * 0.5;\n        if (inView && enough && !solved) runSolve();\n        if (inView) {\n          if (!driftTimer) scheduleDrift();\n        } else {\n          window.clearTimeout(driftTimer);\n          driftTimer = 0;\n        }\n      },\n      { threshold: [0, 0.3, 0.6] },\n    );\n    view.observe(root);\n\n    const onVisibility = () => {\n      if (document.hidden) {\n        window.clearTimeout(driftTimer);\n        driftTimer = 0;\n      } else if (!driftTimer) scheduleDrift();\n    };\n\n    root.addEventListener(\"pointermove\", onPointerMove);\n    root.addEventListener(\"pointerenter\", onPointerEnter);\n    root.addEventListener(\"pointerleave\", onPointerLeave);\n    window.addEventListener(\"scroll\", onScroll, { passive: true, capture: true });\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      actions.current = null;\n      stopSolve();\n      window.clearTimeout(driftTimer);\n      cancelAnimationFrame(frame);\n      resize.disconnect();\n      view.disconnect();\n      cleanups.forEach((fn) => fn());\n      root.removeEventListener(\"pointermove\", onPointerMove);\n      root.removeEventListener(\"pointerenter\", onPointerEnter);\n      root.removeEventListener(\"pointerleave\", onPointerLeave);\n      window.removeEventListener(\"scroll\", onScroll, { capture: true });\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [tileKey, cell, solve, every, push, seed, reduced]);\n\n  const onKeyDown = (id: string) => (event: React.KeyboardEvent) => {\n    const dir: Dir | null =\n      event.key === \"ArrowRight\" ? [1, 0] : event.key === \"ArrowLeft\" ? [-1, 0] : event.key === \"ArrowDown\" ? [0, 1] : event.key === \"ArrowUp\" ? [0, -1] : null;\n    if (!dir) return;\n    event.preventDefault();\n    actions.current?.press(id, dir);\n  };\n\n  const card = cn(\n    `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`,\n    \"rounded-[var(--slide-puzzle-radius)] group-data-[label]/tile:-translate-y-0.5 group-has-[:focus-visible]/tile:-translate-y-0.5\",\n    \"focus-visible:ring-2 focus-visible:ring-[var(--slide-puzzle-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--slide-puzzle-tile)]\",\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"relative isolate w-full text-zinc-950 dark:text-white\",\n        \"[--slide-puzzle-tile:#ffffff] [--slide-puzzle-ring:#ff5a1f] dark:[--slide-puzzle-tile:#18181b]\",\n        className,\n      )}\n      style={\n        {\n          \"--slide-puzzle-cell\": `${cell}px`,\n          \"--slide-puzzle-size\": `${size}px`,\n          \"--slide-puzzle-radius\": `${radius}px`,\n          ...style,\n        } as React.CSSProperties\n      }\n      {...props}\n    >\n      {grid && <Slots grid={grid} />}\n\n      <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))]\">\n        <div ref={holeRef} className=\"pointer-events-auto relative\">\n          {children}\n        </div>\n      </div>\n\n      <ul className=\"pointer-events-none absolute inset-0 m-0 list-none p-0\">\n        {tiles.map((tile) => (\n          <li\n            key={tile.id}\n            ref={(node) => {\n              if (node) tileEls.current.set(tile.id, node);\n              else tileEls.current.delete(tile.id);\n            }}\n            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\"\n            style={{ width: \"var(--slide-puzzle-size)\", height: \"var(--slide-puzzle-size)\" }}\n          >\n            <span\n              aria-hidden\n              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)]`}\n            />\n            {tile.href ? (\n              <a href={tile.href} aria-label={tile.label} className={card}>\n                {tile.icon}\n              </a>\n            ) : (\n              <button\n                type=\"button\"\n                aria-label={tile.label}\n                className={cn(card, \"cursor-pointer\")}\n                onClick={() => actions.current?.press(tile.id)}\n                onKeyDown={onKeyDown(tile.id)}\n              >\n                {tile.icon}\n              </button>\n            )}\n            <span\n              aria-hidden\n              data-slide-puzzle-label\n              className={cn(\n                `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`,\n                \"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\",\n                \"group-data-[below]/tile:bottom-auto group-data-[below]/tile:top-full group-data-[below]/tile:mb-0 group-data-[below]/tile:mt-2\",\n              )}\n            >\n              {tile.label}\n            </span>\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n"
    }
  ]
}