# 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.

Sliding Tile Background for React: icon tiles slide around your headline like a puzzle. Built with Tailwind CSS; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/slide-puzzle
- Tags: Background, Interaction, Landing
- npm dependencies: clsx, tailwind-merge
- Inspired by: [ByDefault](https://www.bydefault.so)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/slide-puzzle.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/slide-puzzle
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/slide-puzzle.json
```

## Usage

The main demo shows typical usage.

### Integrations

An ops assistant's integrations section: twelve tool tiles solve themselves into place round the headline, then drift. Each one is a link.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/slide-puzzle-demo
```

```tsx title="components/slide-puzzle-demo.tsx"
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>
  );
}
```

## 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.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/slide-puzzle-team-demo
```

```tsx title="components/slide-puzzle-team-demo.tsx"
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&apos;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>
  );
}
```

### 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.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/slide-puzzle-404-demo
```

```tsx title="components/slide-puzzle-404-demo.tsx"
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&apos;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>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tiles` (required) | `SlidePuzzleTile[]` |  | 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` |  | 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` |  | 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). |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/slide-puzzle.tsx`, keep these, which make it feel and behave right:

- 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 (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="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>
  );
}
```
