Slide Puzzle
import { useEffect, useState } from 'react';
const css = `
.loader-slide-puzzle {
position: relative;
width: 84px;
height: 84px;
border-radius: 10px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.loader-slide-puzzle__tile {
position: absolute;
left: 4px;
top: 4px;
width: 24px;
height: 24px;
border-radius: 5px;
background: light-dark(#dbdde1, #2e3035);
box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 12%, transparent);
transition: transform 0.22s cubic-bezier(0.34, 1.3, 0.64, 1);
}
.loader-slide-puzzle__tile[data-key] { background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 10px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 35%, transparent); }
@media (prefers-reduced-motion: reduce) { .loader-slide-puzzle__tile { transition: none; } }
`;
// The eight border cells of the 3x3, clockwise. The gap walks round them, and each
// step slides the tile in its way into the hole: the ring of tiles rotates for ever.
const RING = [0, 1, 2, 5, 8, 7, 6, 3];
const at = (cell: number) => `translate(${(cell % 3) * 26}px, ${Math.floor(cell / 3) * 26}px)`;
export function SlidePuzzleLoader() {
// where[t] = the cell tile t sits in. Tile 7 starts in the centre; the gap starts at cell 3.
const [where, setWhere] = useState(() => [0, 1, 2, 5, 8, 7, 6, 4]);
const [gap, setGap] = useState(3);
useEffect(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let g = 3;
let w = [0, 1, 2, 5, 8, 7, 6, 4];
const id = window.setInterval(() => {
const next = RING[(RING.indexOf(g) + 7) % 8]; // the cell before the gap, going anticlockwise
w = w.map((c) => (c === next ? g : c));
g = next;
setWhere(w);
setGap(g);
}, 300);
return () => clearInterval(id);
}, []);
return (
<>
<style>
{css}
</style>
<div className="loader-slide-puzzle" role="status" aria-label="Loading" data-gap={gap}>
{where.map((cell, t) => (
<span
key={t}
className="loader-slide-puzzle__tile"
data-key={t === 0 || undefined}
style={{ transform: at(cell) }}
aria-hidden="true"
/>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initLoaderSlidePuzzle(el) on the mounted element instead.
<div class="loader-slide-puzzle" role="status" aria-label="Loading" data-gap="3">
<span class="loader-slide-puzzle__tile" data-key="true" style="transform:translate(0px, 0px)" aria-hidden="true"></span>
<span class="loader-slide-puzzle__tile" style="transform:translate(26px, 0px)" aria-hidden="true"></span>
<span class="loader-slide-puzzle__tile" style="transform:translate(52px, 0px)" aria-hidden="true"></span>
<span class="loader-slide-puzzle__tile" style="transform:translate(52px, 26px)" aria-hidden="true"></span>
<span class="loader-slide-puzzle__tile" style="transform:translate(52px, 52px)" aria-hidden="true"></span>
<span class="loader-slide-puzzle__tile" style="transform:translate(26px, 52px)" aria-hidden="true"></span>
<span class="loader-slide-puzzle__tile" style="transform:translate(0px, 52px)" aria-hidden="true"></span>
<span class="loader-slide-puzzle__tile" style="transform:translate(26px, 26px)" aria-hidden="true"></span>
</div>.loader-slide-puzzle {
position: relative;
width: 84px;
height: 84px;
border-radius: 10px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.loader-slide-puzzle__tile {
position: absolute;
left: 4px;
top: 4px;
width: 24px;
height: 24px;
border-radius: 5px;
background: light-dark(#dbdde1, #2e3035);
box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 12%, transparent);
transition: transform 0.22s cubic-bezier(0.34, 1.3, 0.64, 1);
}
.loader-slide-puzzle__tile[data-key] { background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 10px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 35%, transparent); }
@media (prefers-reduced-motion: reduce) { .loader-slide-puzzle__tile { transition: none; } }function initLoaderSlidePuzzle(root) {
var tiles = root.querySelectorAll('.loader-slide-puzzle__tile');
if (tiles.length !== 8) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// The eight border cells of the 3x3, clockwise. The gap walks round them, and each step
// slides the tile in its way into the hole, so the ring of tiles rotates for ever.
var RING = [0, 1, 2, 5, 8, 7, 6, 3];
var where = [0, 1, 2, 5, 8, 7, 6, 4]; // the cell each tile sits in; tile 7 is the centre
var gap = 3;
function at(cell) {
return 'translate(' + (cell % 3) * 26 + 'px, ' + Math.floor(cell / 3) * 26 + 'px)';
}
var timer = setInterval(function () {
var next = RING[(RING.indexOf(gap) + 7) % 8]; // the cell before the gap, going anticlockwise
var t = where.indexOf(next);
where[t] = gap;
tiles[t].style.transform = at(gap);
gap = next;
root.setAttribute('data-gap', String(gap));
}, 300);
return function () { clearInterval(timer); };
}
document.querySelectorAll('.loader-slide-puzzle').forEach(initLoaderSlidePuzzle);Goal: Create a "Slide Puzzle" loader.
Visual behavior: A 3x3 sliding puzzle where the gap walks round the edge forever, so the ring of tiles keeps rotating with a springy slide, the bright key tile riding round with them.
Technique:
- Eight tiles are absolutely placed by transform from the cell they occupy; one cell is the gap.
- Every 300ms the tile in the border cell before the gap (anticlockwise) slides into it, and its old cell becomes the gap.
- Because the gap only walks the border ring, the ring of tiles rotates endlessly while the centre tile stays put; each slide uses a slightly overshooting transition.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the tiles stay still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Slide Puzzle" loader.
Visual behavior: A 3x3 sliding puzzle where the gap walks round the edge forever, so the ring of tiles keeps rotating with a springy slide, the bright key tile riding round with them.
Technique:
- Eight tiles are absolutely placed by transform from the cell they occupy; one cell is the gap.
- Every 300ms the tile in the border cell before the gap (anticlockwise) slides into it, and its old cell becomes the gap.
- Because the gap only walks the border ring, the ring of tiles rotates endlessly while the centre tile stays put; each slide uses a slightly overshooting transition.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the tiles stay still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A 3x3 sliding puzzle where the gap walks round the edge forever, so the ring of tiles keeps rotating with a springy slide, the bright key tile riding round with them.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- continuous, loop, spring
- Techniques
- interval, css transition, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the tiles stay still.