Say you run a bike repair shop and want to hand out a first visit discount on your website. A code printed in plain text gets skimmed past with everything else on the page. Ask people to put six pieces of a picture back together, and they stop, play for ten seconds, and read the offer they just rebuilt. This puzzle button does exactly that: the code appears only once the picture is whole, and anyone who would rather skip the game can do so with one click.
Put this week's offer back together to unlock the code.
15% off your first tune up. Show this code at the counter: TUNE15
How it works
- One picture, six windows. The offer artwork lives once in a
template. The script copies it into six buttons. Each copy is three times wider and twice as tall as its button and is shifted so only one slice shows, so together the six buttons look like one image. - Pieces move by slot. Every piece has two CSS custom properties,
--xand--y, that say which column and row it sits in. Changing them slides the piece there with a CSS transition. No layout is recalculated by hand. - Two ways to swap. Drag a piece onto another and they trade places. Or tap one piece, then tap a second. The tap method also works with a keyboard, because every piece is a real button you can reach with Tab and press with Enter or Space.
- Check after every move. The script counts how many pieces sit in their own slot and says so in a live region, so screen reader users hear their progress. When all six match, the gaps close, the seams vanish and the code appears.
- Always a way out. "Skip the puzzle" solves it instantly and shows the same code.
The code
Three blocks, no library and no images: the artwork is plain HTML and CSS, so you can change the words without opening a design tool.
<div class="pz" data-lab="puzzle-unlock">
<p class="pz-intro">Put this week's offer back together to unlock the code.</p>
<div class="pz-board" role="group" aria-label="Puzzle, 6 pieces">
<!-- The picture each piece shows a slice of. The script copies it into every piece. -->
<template class="pz-art-tpl">
<div class="pz-art" aria-hidden="true">
<span class="pz-wheel pz-wheel-a"></span>
<span class="pz-wheel pz-wheel-b"></span>
<span class="pz-art-kicker">Bike shop offer</span>
<span class="pz-art-big">15% off</span>
<span class="pz-art-small">your first tune up</span>
</div>
</template>
</div>
<p class="pz-status" aria-live="polite"></p>
<div class="pz-prize" hidden>
<p><strong>15% off your first tune up.</strong> Show this code at the counter: <span class="pz-code">TUNE15</span></p>
</div>
<div class="pz-actions">
<button type="button" class="pz-skip">Skip the puzzle</button>
<button type="button" class="pz-mix" hidden>Mix it up again</button>
</div>
</div>.pz {
width: min(100%, 420px);
display: grid;
justify-items: center;
gap: 12px;
font-family: system-ui, sans-serif;
color: #101a14;
text-align: center;
}
.pz-intro { margin: 0; font-size: 15px; color: #46534a; }
/* The board keeps a 3 by 2 shape; pieces are positioned inside it by slot. */
.pz-board {
position: relative;
width: 100%;
aspect-ratio: 3 / 2;
container-type: inline-size;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.pz-piece {
--x: 0;
--y: 0;
--dx: 0px;
--dy: 0px;
position: absolute;
top: 0;
left: 0;
width: 33.334%;
height: 50%;
padding: 3px;
border: 0;
background: none;
cursor: grab;
transform: translate(calc(var(--x) * 100% + var(--dx)), calc(var(--y) * 100% + var(--dy)));
transition: transform .28s cubic-bezier(.2, .8, .2, 1), padding .4s ease;
}
.pz-piece:focus-visible { outline: none; }
.pz-piece:focus-visible .pz-face { box-shadow: 0 0 0 3px #f4d96c, 0 0 0 5px #133621; }
.pz-piece.is-dragging { z-index: 2; cursor: grabbing; transition: none; }
.pz-face {
position: relative;
display: block;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 6px 14px -8px rgba(16, 26, 20, .7);
transition: border-radius .4s ease, box-shadow .2s ease, transform .2s ease;
}
.pz-piece.is-picked .pz-face { transform: scale(.92); box-shadow: 0 0 0 3px #f4d96c, 0 10px 20px -10px rgba(16, 26, 20, .8); }
.pz-piece.is-dragging .pz-face { transform: scale(1.04); box-shadow: 0 16px 28px -12px rgba(16, 26, 20, .8); }
/* Each piece holds a full size copy of the picture, shifted so only its own slice shows. */
.pz-art {
position: absolute;
top: calc(var(--r) * -100%);
left: calc(var(--c) * -100%);
width: 300%;
height: 200%;
background: radial-gradient(circle at 80% 20%, #1f5233, #133621 60%);
color: #f2f3ef;
}
.pz-art-kicker, .pz-art-big, .pz-art-small { position: absolute; left: 0; right: 0; }
.pz-art-kicker { top: 15%; font-size: 3.2cqw; letter-spacing: .16em; text-transform: uppercase; color: #f4d96c; }
.pz-art-big { top: 30%; font: 800 17cqw/1 system-ui, sans-serif; color: #f4d96c; }
.pz-art-small { top: 70%; font-size: 4.6cqw; }
.pz-wheel { position: absolute; border: 6px solid rgba(244, 217, 108, .22); border-radius: 50%; }
.pz-wheel-a { width: 34%; aspect-ratio: 1; left: -6%; bottom: -22%; }
.pz-wheel-b { width: 22%; aspect-ratio: 1; right: 4%; top: -8%; }
/* Solved: the gaps close and the seams disappear. */
.pz.is-solved .pz-piece { padding: 0; cursor: default; }
.pz.is-solved .pz-face { border-radius: 0; box-shadow: none; }
.pz.is-solved .pz-board { border-radius: 12px; overflow: hidden; box-shadow: 0 18px 40px -24px rgba(16, 26, 20, .6); }
.pz-status { margin: 0; min-height: 1.4em; font-size: 14px; color: #46534a; }
.pz-prize p { margin: 0; font-size: 16px; line-height: 1.5; }
.pz-code {
display: inline-block;
padding: 2px 10px;
border: 2px dashed #133621;
border-radius: 6px;
background: #f4d96c;
font: 700 16px ui-monospace, monospace;
letter-spacing: .08em;
}
.pz-actions button {
border: 0;
background: none;
color: #133621;
font: 500 14px system-ui, sans-serif;
text-decoration: underline;
cursor: pointer;
}
.pz-actions button:focus-visible { outline: 2px solid #133621; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.pz-piece, .pz-face { transition: none; }
}(function () {
var root = document.querySelector('[data-lab="puzzle-unlock"]');
if (!root) return;
var board = root.querySelector('.pz-board');
var tpl = root.querySelector('.pz-art-tpl');
var status = root.querySelector('.pz-status');
var prize = root.querySelector('.pz-prize');
var skip = root.querySelector('.pz-skip');
var mix = root.querySelector('.pz-mix');
var COLS = 3, ROWS = 2; // board size; match the CSS piece width and height
var DRAG_START = 6; // pixels of movement before a press counts as a drag
var pieces = [], slotOf = [], picked = null, solved = false;
// Build one button per piece. Piece i belongs in slot i and shows that slice of the art.
for (var i = 0; i < COLS * ROWS; i++) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'pz-piece';
b.setAttribute('aria-label', 'Piece ' + (i + 1));
var face = document.createElement('span');
face.className = 'pz-face';
face.style.setProperty('--c', i % COLS);
face.style.setProperty('--r', Math.floor(i / COLS));
face.appendChild(tpl.content.cloneNode(true));
b.appendChild(face);
board.appendChild(b);
pieces.push(b);
wire(b, i);
}
function place(i) {
pieces[i].style.setProperty('--x', slotOf[i] % COLS);
pieces[i].style.setProperty('--y', Math.floor(slotOf[i] / COLS));
}
function inPlace() {
var n = 0;
for (var i = 0; i < slotOf.length; i++) if (slotOf[i] === i) n++;
return n;
}
// Shuffle until at most one piece happens to sit in its right spot.
function shuffle() {
var s = [];
for (var i = 0; i < pieces.length; i++) s.push(i);
do {
for (var j = s.length - 1; j > 0; j--) {
var k = Math.floor(Math.random() * (j + 1)), t = s[j]; s[j] = s[k]; s[k] = t;
}
slotOf = s;
} while (inPlace() > 1);
pieces.forEach(function (p, n) { p.disabled = false; place(n); });
solved = false;
root.classList.remove('is-solved');
prize.hidden = true;
mix.hidden = true;
skip.hidden = false;
status.textContent = '';
}
function swap(a, b) {
var t = slotOf[a]; slotOf[a] = slotOf[b]; slotOf[b] = t;
place(a); place(b);
var n = inPlace();
if (n === pieces.length) return finish();
status.textContent = n + ' of ' + pieces.length + ' pieces in place.';
}
function finish() {
solved = true;
unpick();
root.classList.add('is-solved');
pieces.forEach(function (p) { p.disabled = true; place(pieces.indexOf(p)); });
status.textContent = 'Solved. Your code is below.';
prize.hidden = false;
skip.hidden = true;
mix.hidden = false;
}
function unpick() {
if (picked !== null) pieces[picked].classList.remove('is-picked');
picked = null;
}
// Tap or Enter picks a piece; the second piece you choose swaps with it.
function choose(i) {
if (solved) return;
if (picked === null) {
picked = i;
pieces[i].classList.add('is-picked');
status.textContent = 'Piece picked. Choose the piece to swap it with.';
} else if (picked === i) {
unpick();
status.textContent = '';
} else {
var a = picked;
unpick();
swap(a, i);
}
}
// Dragging moves the piece with the pointer, then swaps it with whatever slot it lands on.
function wire(b, i) {
var start = null, dragging = false;
b.addEventListener('pointerdown', function (e) {
if (solved || e.button > 0) return;
start = { x: e.clientX, y: e.clientY };
dragging = false;
b.setPointerCapture(e.pointerId);
});
b.addEventListener('pointermove', function (e) {
if (!start) return;
var dx = e.clientX - start.x, dy = e.clientY - start.y;
if (!dragging && Math.abs(dx) + Math.abs(dy) > DRAG_START) {
dragging = true;
unpick();
b.classList.add('is-dragging');
}
if (dragging) {
b.style.setProperty('--dx', dx + 'px');
b.style.setProperty('--dy', dy + 'px');
}
});
b.addEventListener('pointerup', function (e) {
if (!start) return;
start = null;
if (!dragging) return;
var r = board.getBoundingClientRect();
var col = Math.floor((e.clientX - r.left) / (r.width / COLS));
var row = Math.floor((e.clientY - r.top) / (r.height / ROWS));
endDrag(b);
if (col < 0 || row < 0 || col >= COLS || row >= ROWS) return;
var target = slotOf.indexOf(row * COLS + col);
if (target !== i) swap(i, target);
});
b.addEventListener('pointercancel', function () { start = null; endDrag(b); });
b.addEventListener('click', function () {
if (!dragging) choose(i);
dragging = false;
});
}
function endDrag(b) {
b.classList.remove('is-dragging');
b.style.setProperty('--dx', '0px');
b.style.setProperty('--dy', '0px');
}
skip.addEventListener('click', function () {
slotOf = pieces.map(function (p, n) { return n; });
finish();
});
mix.addEventListener('click', function () { shuffle(); pieces[0].focus(); });
shuffle();
})();When to use it, and when not to
It fits small, friendly rewards: a discount for new customers, a reveal on a seasonal campaign page, a code for people who reached the bottom of your newsletter archive, or a bit of fun on a page for a kids' class or a family event. Six pieces take most people a few seconds, which is long enough to be memorable and short enough not to feel like work.
It is a poor fit for anything people need to get done. Never put a booking button, a phone number, prices or a checkout behind a puzzle. Customers who came to act will leave instead. Keep it to one per page as well; a second puzzle stops being a surprise.
On accessibility, the picture itself is decorative and hidden from screen readers, so the skip button matters: it is the honest route for anyone who cannot see the pieces or finds dragging hard. Leave it in. Also remember the code sits in the page source, so this suits open offers, not one time codes.
Make it yours
- The offer: edit the text inside
.pz-artfor the picture, and the matching text in.pz-prizefor the real, readable version with your code. - Difficulty: change
COLSandROWSin the script, then set the.pz-piecewidth to 100% divided byCOLSand its height to 100% divided byROWS, and update the boardaspect-ratio. Four by three is a real challenge on a phone. - Feel:
DRAG_STARTsets how far a press must move before it counts as a drag; raise it if taps on touch screens keep turning into small drags.
Frequently asked questions
Does it work on phones?
Yes. It uses pointer events and turns off touch scrolling on the board only, so dragging a piece moves the piece while the rest of the page scrolls normally. Tapping two pieces to swap them also works well with a thumb.
Can I use a photo instead of text?
Yes. Give .pz-art a background-image with background-size: cover and remove the text spans. Pick a photo with clear shapes and colours, or the puzzle becomes hard to solve.
Will it hurt my page speed?
No. The script runs once when the page loads, creates six buttons and then waits for input. It does no animation loop of its own; the sliding is handled by CSS transitions.


