GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Jigsaw Puzzle JavaScript: 9 Snapping Tab Boards

In jigsaw puzzle javascript, a jigsaw is done when one group is left. 01 has two neighbors share one flipped curve, 02 springs a piece in only inside distance

Auto-plays · click a tile to jump to its section · all nine in one zip

Cutting a Photo into Squares Is Not a Jigsaw: Piece Shape Comes from Edges Shared with Neighbors

A picture cut into plain squares lets any piece sit in any cell as far as shape goes, so the player has only the image to go on. A jigsaw piece has a knob pushing out of each inner edge and a matching socket on the neighbor across that edge, so the outline narrows down the match before the colors do. For that match to hold on screen without a sliver of gap, the two pieces cannot each draw their own similar curve; they have to use one curve together.

Writing one edge as five curve segments

Each inner edge in these demos is five cubic segments in a row: shoulder, neck, top of the knob, neck, shoulder. tabEdge measures along the edge as u and at a right angle to it as v, both in units of the edge length, then multiplies v by a tab sign of +1 or −1, so a single sign decides whether the knob bulges out or the socket dips in. The knob radius is half of TAB_SIZE 0.2 with up to 12% wobble (KNOB_JITTER), the neck is 0.62 of that radius (NECK_RATIO), and the knob center drifts at most 0.06 from the middle of the edge (EDGE_WOBBLE). All of it comes from one seeded stream (seed 486) drawn edge by edge, so a reload rebuilds the same board, and the outer border uses one straight segment (flatEdge), which leaves corner pieces with two straight sides and edge pieces with one.

The on-screen outline is a polyline, not a curve

The curves only place points. chainInto splits every segment into 12 steps (CURVE_STEPS) and joins them with lineTo, so the clip that cuts the picture, the isPointInPath test that asks which piece a click landed in, and the calculation-side pointInPoly all look at the same polyline. When the drawn outline and the hit outline differ, a press on the tip of a visible knob grabs the piece next to it; built this way, that mismatch has no room to appear.

Jigsaw Puzzle JavaScript: 9 Scenes — a Shared-Curve Piece Cutter, a Threshold Spring Snap, a One-Root Group Merge, Quarter-Turn Pieces, a Tray That Spreads Without Overlap, an Edge-First Auto Solver, a Lifted Piece with Tilt and Shadow, a Completion Wave with Gloss and a Self-Assembling Hero

01 to 04 are rules a single piece follows on the board (cutting, snapping, grouping, turning), 05 to 07 handle the space around the board and the hand (the tray, the solver, the lift), 08 is the moment of completion, and 09 strings the first eight into one opening scene. Each of the nine runs on one 2D canvas with internal coordinates of 480×300, and the picture is not an outside photo: sceneryColor(u, v) paints sky, sun, clouds, far hills, near hills and wildflowers pixel by pixel onto a hidden canvas once. A piece clips that picture with its own outline, so wherever it travels it carries the patch of landscape that belongs to its home cell.

Twenty-four frames over two seconds on a 480×300 canvas

Item Cumulative area Frames that moved Mean intensity
01 Shared-curve piece cutter 47.4% 23/23 50.1
02 Two-limit spring snap 17.3% 23/23 86.5
03 One-root group merge 26.9% 23/23 66.5
04 Quarter-turn pieces 19.9% 23/23 79.8
05 Tray that spreads without overlap 24.2% 19/23 62.2
06 Edge-first auto solver 28.9% 23/23 68.2
07 Lifted piece tilt 36.3% 23/23 84.0
08 Completion wave and gloss 26.4% 23/23 35.0
09 Self-assembling hero 39.8% 23/23 59.1

01 covers the widest area, likely because all twelve pieces spread out and come back together, and 05 alone reads 19/23, apparently because the pieces sit stacked on one point for the first 0.15 seconds and the tray goes still once the overlap reaches zero. 08 has the lowest intensity, likely because the board rises only about 3px and most of the change is seam lines fading out.

01A Piece Cutter Where Neighbors Share One Curve

Use this scene when a page has to show how a picture will be cut before anyone plays, such as a preview on a custom photo puzzle gift page: from 0.25 seconds the 17 inner edges are drawn one after another with a coral dot at the tip, and from 1.15 seconds each piece lifts and drifts outward from the board center, 0.05 seconds after the one before. In all 17 neighbor pairs one chain equals the other's reversed chain with a difference of 0 at every point, each pair is one knob facing one socket, and the spread spring (stiffness 90, damping 11) carries a corner piece up to 33.1px and runs 10.3% past its target before returning. Of the two 4x zoom panels on the right, the top one shows the seam drawn from one shared chain and the bottom one shows each piece rounding its own corner to whole pixels.

A Piece Cutter Where Neighbors Share One Curve
Open in a new tab

01 · A Piece Cutter Where Neighbors Share One Curve — Measured area 47.428% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TAB_SIZEEDGE_WOBBLESPREAD_GAIN
    // Reverse a chain: only the point order flips. One cubic segment (p0, c1, c2, p1) read as (p1, c2, c1, p0) is the same curve, so the mismatch is 0
    function reverseChain(pts) {
        const out = [];
        for (let k = pts.length - 2; k >= 0; k -= 2) {
            out.push(pts[k], pts[k + 1]);
        }
        return out;
    }

02A Snap That Pulls Only Inside Distance and Angle Thresholds

This is the snap for a kids' learning site picture match, where a rough drop should still join but a wrong spot should not, and a simulated hand drops the middle-right piece three times. At 0.7 seconds the distance is 6.4px but the angle gap is 18°, outside the 8° angle limit, and at 1.3 seconds the gap is 0° but the distance is 36.77px, outside the 14px distance limit, so both drops stay put under an "outside the limits" tag; only the drop at 1.854 seconds, at 11.4px and 3°, is "snapped". After joining, the spring (stiffness 160, damping 14) pulls the remaining 11.37px in, swings 1.35px past the spot at 0.288 seconds, and stays inside a ±0.5px band from 0.417 seconds on.

A Snap That Pulls Only Inside Distance and Angle Thresholds
Open in a new tab

02 · A Snap That Pulls Only Inside Distance and Angle Thresholds — Measured area 17.341% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SNAP_RADIUSSNAP_ANGLESNAP_STIFFNESS
    // Closest snap candidate between the pieces of group i and their true neighbors in other groups
    function bestSnap(i) {
        let best = { m: -1, n: -1, d: Infinity, da: 0, ok: false, gx: 0, gy: 0 };
        const root = findRoot(i);
        for (const m of membersOf(i)) {
            for (let s = 0; s < 4; s += 1) {
                const n = neighborOf(m, s);
                if (n < 0 || findRoot(n) === root) {
                    continue;
                }
                const dm = puzzle.defs[m];
                const dn = puzzle.defs[n];
                const gx = pieces[n].tx + (dm.hx - dn.hx);
                const gy = pieces[n].ty + (dm.hy - dn.hy);
                const d = Math.hypot(pieces[m].tx - gx, pieces[m].ty - gy);
                const da = wrapAngle(pieces[m].ta - pieces[n].ta);
                if (d < best.d) {
                    best = { m: m, n: n, d: d, da: da, ok: d < SNAP_RADIUS && Math.abs(da) < SNAP_ANGLE && orientOk(m) && orientOk(n), gx: gx, gy: gy };
                }
            }
        }
        return best;
    }

03A Group Merge That Files Joined Pieces Under One Root

For a team page group-photo puzzle where every join grows a block that has to drag as one, this scene drops one piece every 0.4 seconds from 0.5 seconds on, 6px off its true spot, for eleven merges. When the sizes tie, as in the 2+2 merge at 1.3 seconds, the root of the carried group (piece 5) stays, and the new groups that start elsewhere at 1.7 seconds (root 3) and 2.9 seconds (root 9) slide their root under the bigger group's root once they meet it, so root 5 survives to the final merge at 4.5 seconds. Afterward the 4×3 pointer table on the right holds 5 in all 12 cells, and no piece is more than one step from its root.

A Group Merge That Files Joined Pieces Under One Root
Open in a new tab

03 · A Group Merge That Files Joined Pieces Under One Root — Measured area 26.863% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MERGE_PLANSEAM_FADE_SSEAM_MIN
    function findRoot(i) {
        let x = i;
        while (parent[x] !== x) {
            parent[x] = parent[parent[x]];
            x = parent[x];
        }
        return x;
    }
    function joinGroups(a, b) {
        let ra = findRoot(a);
        let rb = findRoot(b);
        if (ra === rb) {
            return ra;
        }
        if (groupSize[ra] < groupSize[rb]) {
            const t = ra;
            ra = rb;
            rb = t;
        }
        parent[rb] = ra;
        groupSize[ra] += groupSize[rb];
        return ra;
    }

04Quarter-Turn Pieces That Rotate into Place

A quiz app's hard level, where the direction has to be right too, is the setting here: each press adds 90° to the target angle, and the angle spring (stiffness 180, damping 11) overshoots by 21.5° 0.25 seconds after a single press and stays within ±1° from 0.637 seconds. At 1.35 seconds piece 5 is dropped 5px from its spot while still turned 90°, inside the 14px distance limit, yet the tag reads "check closed" and the piece stays where it is; only once it stands upright does the same 5px drop at 2.5 seconds snap. Only 04 adds orientOk = upright (is the target angle, folded into −π to π, zero) to the check, and on joining, tryJoin below corrects the target angle by the remaining gap da alone, so a piece that went all the way round to 360° does not rewind to 0°.

Quarter-Turn Pieces That Rotate into Place
Open in a new tab

04 · Quarter-Turn Pieces That Rotate into Place — Measured area 19.883% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ROT_STIFFNESSROT_DAMPINGTURNS_AT_START
    // On drop: inside the limits, move the whole group's target to the right spot and join. Outside, it stays where it is
    function tryJoin(i) {
        const s = bestSnap(i);
        if (!s.ok) {
            return s;
        }
        const dx = s.gx - pieces[s.m].tx;
        const dy = s.gy - pieces[s.m].ty;
        // Correct the angle only by the remaining gap (da), so a piece that turned a full 360 degrees and stands upright does not rewind to 0
        for (const k of membersOf(s.m)) {
            pieces[k].tx += dx;
            pieces[k].ty += dy;
            pieces[k].ta -= s.da;
        }
        joinGroups(s.m, s.n);
        markSeams();
        return s;
    }

05A Piece Tray That Spreads Out Without Overlap

For an online gallery page's piece tray, where scattered pieces should cover neither the board nor each other, the scene tosses twelve pieces into three heaps at 0.15 seconds, then from 0.5 seconds runs 18 passes a second, 36 in all, in which circles of radius 24.8px push each overlapping pair apart by half the overlap depth each. The summed overlap per pass drops 574.6 → 116.1 → 50.1px, falls under 0.5px on pass 13 and reaches 0 from pass 18, and the two pieces that landed inside the board box get pushed out to its nearest outer side. When it ends, the closest two centers are 49.6px apart, exactly twice the radius, so the circles come to rest just touching.

A Piece Tray That Spreads Out Without Overlap
Open in a new tab

05 · A Piece Tray That Spreads Out Without Overlap — Measured area 24.217% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

TRAY_RRELAX_PER_STRAY_MARGIN
        for (let a = 0; a < ids.length; a += 1) {
            for (let b = a + 1; b < ids.length; b += 1) {
                const p = pieces[ids[a]];
                const q = pieces[ids[b]];
                let dx = q.tx - p.tx;
                let dy = q.ty - p.ty;
                let d = Math.hypot(dx, dy);
                const depth = radius * 2 - d;
                if (depth <= 0) {
                    continue;
                }
                overlap += depth;
                if (d < 1e-6) {
                    dx = 1;
                    dy = 0;
                    d = 1;
                }
                const push = depth / 2 / d;
                p.tx -= dx * push;
                p.ty -= dy * push;
                q.tx += dx * push;
                q.ty += dy * push;
            }
        }

06An Auto Solver That Starts from the Border

This fits a hint screen in a puzzle app that shows where the next piece goes and why: pieces are sorted by their straight sides into 4 corners, 6 edge pieces and 2 inner pieces, the frame goes in clockwise, and the middle fills after it. For each cell, every placed neighbor adds 3 when the tabs do not face each other, plus the mean color gap over 9 points sampled 2.5px inside the side ÷ 255 × 4, the lowest score wins, all 12 steps pick the right piece, and the border shape cuts most candidate lists down to one or two. At the first inner cell (5), where two candidates remain, the right piece scores 0.137 against 6.467 for the other inner piece, and the 12-cell heat map under the board shows those scores as color (deeper violet means a lower score, capped at 3).

An Auto Solver That Starts from the Border
Open in a new tab

06 · An Auto Solver That Starts from the Border — Measured area 28.913% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

COLOR_WEIGHTTAB_MISMATCH_COSTCOLOR_INSET
        let score = 0;
        const dirs = [[0, -1], [1, 0], [0, 1], [-1, 0]];
        for (let s = 0; s < 4; s += 1) {
            const nc = c + dirs[s][0];
            const nr = r + dirs[s][1];
            if (nc < 0 || nr < 0 || nc >= pz.cols || nr >= pz.rows) {
                continue;
            }
            const q = placedAt[nr * pz.cols + nc];
            if (q < 0) {
                continue;
            }
            const facing = (s + 2) % 4;
            const qd = pz.defs[q];
            if (d.sides[s].tab + qd.sides[facing].tab !== 0 || d.sides[s].tab === 0) {
                score += TAB_MISMATCH_COST;
            }
            score += colorGap(sideColors(d, s, pz.size, sample), sideColors(qd, facing, pz.size, sample)) / 255 * COLOR_WEIGHT;
        }
        return score;

07A Lifted Piece with Speed Tilt and Shadow Height

For a board game landing page that wants the feel of lifting a piece, the height target turns 1 from 0.15 seconds before the grab, and the height spring overshoots to 1.186 before settling within ±2% from 0.5 seconds on. Tilt is the smoothed horizontal speed × 0.0007, clamped at 0.32 rad (18.3°) and reaching that cap during the carry, while the shadow drifts down by up to 18.1px and its blur spreads to 21px at peak height. A probe dot circling the board compares the piece picked by its rectangle (coral dashes) with the one picked by its outline (mint), and pickByPoly below is the calculation copy used for offline counting, while on screen pickAt asks the same spot with isPointInPath.

A Lifted Piece with Speed Tilt and Shadow Height
Open in a new tab

07 · A Lifted Piece with Speed Tilt and Shadow Height — Measured area 36.252% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TILT_GAINTILT_MAXSHADOW_OFFSET
    function pickByBox(x, y) {
        for (let k = zOrder.length - 1; k >= 0; k -= 1) {
            const i = zOrder[k];
            const b = boxes07[i];
            const p = pieces[i];
            if (x >= p.x + b[0] && x <= p.x + b[2] && y >= p.y + b[1] && y <= p.y + b[3]) {
                return i;
            }
        }
        return -1;
    }
    // Path check (calculation copy): is the point inside the same polyline, by ray casting. The screen asks the same thing with isPointInPath
    function pickByPoly(x, y) {
        for (let k = zOrder.length - 1; k >= 0; k -= 1) {
            const i = zOrder[k];
            if (pointInPoly(x - pieces[i].x, y - pieces[i].y, polys07[i])) {
                return i;
            }
        }
        return -1;
    }

08A Seam Wave and Gloss That Spread at Completion

For a puzzle-complete celebration screen that has to announce the last piece at a glance, the last piece snaps at 0.554 seconds, and each piece's inner seams fade over 0.25 seconds after a delay equal to its distance from the last piece ÷ 260px/s (0 to 0.61 seconds), so every seam is gone by 1.414 seconds. Following the same delay, the board rises in the shape e^(−3.2τ) × sin(9τ) to a peak of 3.04px and dips to −1.0px, and a gloss band crosses once from 1.104 to 1.704 seconds. The straight outer edges are not erased, so the board keeps its frame, and a full copy of the picture fades in under the board as the seams go (erased pieces ÷ 20) to fill the piece borders.

A Seam Wave and Gloss That Spread at Completion
Open in a new tab

08 · A Seam Wave and Gloss That Spread at Completion — Measured area 26.381% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SEAM_SPEEDWAVE_DECAYGLOSS_S
    function delayOf(i) {
        const a = puzzle.defs[i];
        const b = puzzle.defs[LAST];
        return Math.hypot(a.hx - b.hx, a.hy - b.hy) / SEAM_SPEED;
    }
    function sinceDone(i) { return completeAt < 0 ? -1 : sceneT - completeAt - delayOf(i); }
    function waveOf(i) {
        const tau = sinceDone(i);
        if (tau <= 0) {
            return 0;
        }
        return Math.exp(-WAVE_DECAY * tau) * Math.sin(WAVE_FREQ * tau);
    }
    // Share of the inner seam left (1 full to 0 erased). The outer border of the board (straight edges) is not erased
    function seamLeft(i, s) {
        if (puzzle.defs[i].sides[s].tab === 0) {
            return 1;
        }
        const tau = sinceDone(i);
        if (tau < 0) {
            return 1;
        }
        return 1 - clamp(tau / SEAM_FADE_S, 0, 1);
    }

09A Hero Scene Where the Puzzle Assembles Itself

A photo print service's landing hero is the setting for this one, where the puzzle comes together with nobody touching it: the heap is thrown at 0.1 seconds, from 0.2 seconds only pieces not yet on the board push each other apart, and from 0.75 seconds pieces are lifted one every 0.38 seconds in 06's order and carried with 07's tilt. Drops come every 0.38 seconds from 1.05 seconds, each piece seats into its cell on the same spring values as 02 while the group count falls one at a time from 12, and with the solve at 12/12 the last piece, 6, lands at 5.233 seconds and 08's delay, wave and gloss spread out from it. After 7.4 seconds the pieces scatter and the scene starts over.

A Hero Scene Where the Puzzle Assembles Itself
Open in a new tab

09 · A Hero Scene Where the Puzzle Assembles Itself — Measured area 39.764% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_SRELAX_ITERSLIFT_LEAD_S
    function update(dt) {
        if (sceneT >= THROW_AT_S && sceneT - dt < THROW_AT_S) {
            throw9.forEach(function (t, i) { pieces[i].tx = t[0]; pieces[i].ty = t[1]; });
        }
        const want = clamp(Math.floor((sceneT - RELAX_AT_S) * RELAX_PER_S) + 1, 0, RELAX_ITERS);
        while (relaxDone9 < want) {
            // Push apart, on the tray, only the pieces that have not gone to the board yet
            relaxOnce(ids9().filter(function (i) { return !isPlaced(i); }), TRAY_R, BOARD_X, BOARD_Y, BOARD_X + COLS * PIECE, BOARD_Y + ROWS * PIECE);
            relaxDone9 += 1;
        }
        runCarries(sceneT);
        steps09.forEach(function (s, k) {
            const t0 = SOLVE_START_S + k * STEP_S;
            pieces[s.piece].th = sceneT >= t0 - LIFT_LEAD_S && sceneT < t0 + CARRY_S ? 1 : 0;
        });
        stepWorld(dt, SNAP_STIFFNESS, SNAP_DAMPING);
        if (completeAt >= 0) {
            simStatus = fmt(STATUS_DONE, { n: pieces.length });
        }
        else if (sceneT >= SOLVE_START_S) {
            simStatus = fmt(STATUS_PLACE, { g: groupCount(), j: joinsMade(), n: pieces.length - 1 });
        }
    }

What Does Jigsaw Puzzle Code in JavaScript Need Beyond Drag and Drop?

Dragging only moves a piece. A board also needs a check that asks whether the dropped piece sits beside its true neighbor (02 and 04), grouping that drags joined pieces as one block (03), picking that follows the outline of the picture (07), and a tray that spreads the pieces outside the board at the start (05). Leave any of the four out and the pieces move, but the puzzle does not finish.

Fitting it into an event page or a lesson

On an event page, a 3×3 board (the size used in 02 and 04) in front of the entry button, with 08's wave opening the next step on completion, is a safe pattern. For a lesson, cut a map or a diagram into a 4×3 board and show 06's heat map to a stuck student, so the hint also explains why that piece belongs there. If a brand page wants jigsaw puzzle html code that runs on its own product art, let the opening play itself as in 09 and keep the current structure, which stops the self-play the moment a person touches the board.

How many merges until the board is finished

With n pieces it takes exactly n − 1 merges for the groups to become one. That is why the status line "groups left {g} · joins {j}/{n}" fills its denominator with pieces.length - 1 rather than the piece count, and a 12-piece board is done when the join count reaches 11. The eleven merges in 03 match that number.

How this differs from the drag-and-drop component post

In Drag and Drop Animation CSS: 9 Physics Patterns, 01 pulls a dragged icon into the nearest cell center and 04 trades places once the neighbor's midpoint is passed. Here 02 looks at the offset and angle relative to the matching neighbor piece rather than a cell, and once joined the block moves as a whole as in 03, which is the part a drag puzzle needs on top. For a lighter puzzle toy, the logo match puzzle in HTML CSS JS mini games: 9 copy-paste mini apps is another starting point.

Neighbors Share One Flipped Curve, Snaps Pass Two Thresholds, Groups Follow One Root: the Arithmetic Under the Board

Building each edge once and flipping it for the neighbor

makePuzzle builds horizontal edges left to right and vertical edges top to bottom, once each. A piece takes its top and right edges as they are and its bottom and left edges through reverseChain, which reverses the point order and nothing else; the piece using an edge as is gets tab sign −sign and the piece reversing it gets +sign, so a knob on one side is a socket on the other. That is the whole javascript jigsaw puzzle generator behind the nine demos: one seed, one pass over the edges, and twelve outlines that fit by construction.

                const right = r * (cols + 1) + c + 1;
                const sides = [
                    { pts: hEdges[top], tab: -hSign[top] },
                    { pts: vEdges[right], tab: -vSign[right] },
                    { pts: reverseChain(hEdges[bottom]), tab: hSign[bottom] },
                    { pts: reverseChain(vEdges[left]), tab: vSign[left] }
                ];

One cubic segment (p0, c1, c2, p1) read as (p1, c2, c1, p0) traces the same curve, so the border between two neighbors is one point list walked in opposite directions.

Reading the overshoot from one damping ratio

Every spring in stepWorld runs the same substep update (velocity += (stiffness × remaining distance − damping × velocity) × h, position += velocity × h, h = 1/240 s), so damping ÷ (2√stiffness) is enough to predict the overshoot. Plugging that ratio into the step-response overshoot e^(−ζπ/√(1−ζ²)) for a start at rest calculates 12.4% for 02 at ζ 0.553 (measured 11.9%), 24.4% for 04 at 0.41 (measured 21.5° ÷ 90° = 23.9%), 19.2% for the height in 07 at 0.465 (measured 18.6%) and 10.7% for the spread in 01 at 0.58 (measured 10.3%), where the values in parentheses were measured by calling the function directly. The formula runs a little above every measurement, likely because the 1/240 s substep integration damps slightly more than the continuous equation.

What group lookup and overlap pushing cost

findRoot climbs to the root and, on the way, points each cell it passes at the cell two levels up, so the next lookup is shorter. joinGroups puts the smaller group under the larger one, which keeps the trees shallow; after the eleven merges in 03 the deepest cell sat one step from its root. relaxOnce in 05 compares every pair of pieces, which is 66 pairs per pass for 12 pieces at 18 passes a second, so the pair count grows close to the square of the piece count as boards get bigger.

Gapped Seams, the Wrong Piece Picked Up and Blurry Edges on High-Density Screens: Three Places a Puzzle Board Breaks

A half-pixel gap from rounding each piece on its own

On the 01 board the piece side is 55.5px and the top left of the board sits at (33.5, 52.25), so piece corners land on fractions of a pixel. Shift each piece's own top-left corner to a whole pixel by roundShift(v) = Math.round(v) − v and draw it separately, and the pieces slide by different amounts between −0.25 and +0.5px: of the 17 inner edges, 7 open a 0.5px gap, 10 overlap by −0.5 to −0.25px, and none fit exactly. Build each curve once and use it as is on one side and reversed on the other, and all 17 pairs differ by 0. Even two pieces that meet along the very same path show a faint line when each is clipped and pasted on its own, because the border pixels get covered twice at partial alpha and the background shows through, which is why 08 and 09 lay a full copy of the picture under the board after completion.

Picking by rectangle grabs the neighbor

A piece whose knobs bulge outward has a bounding rectangle of 2,502.5 to 4,201.8px², up to 1.68 times the 2,500px² cell, so neighboring rectangles overlap. On the finished board in 07, picking from the topmost piece down by rectangle grabs a piece other than the one under the finger for 3,990 of 30,000 points on a 1px grid (13.3%) and for 14.0% on a 2px grid, while picking by outline misses 0%. Each time the probe tag switches from "same piece a" to "box a · path b", with the coral dashes on one piece and the mint outline on another, you are watching one of those misses.

Leaving out the ratio blurs the knob edge

On a ratio-2 screen, multiplying the canvas backing size by the ratio gives 960×600; leaving it out gives 480×300, where each canvas pixel covers a 2×2 block of device pixels. The two middle panels in 05, labeled "ratio applied" and "ratio missing", draw the 16px around the tip of the right knob of piece 5 both ways, and the blurry pixels, with coverage between 0.1 and 0.9, rise from 54 with the ratio to 158 without it, 2.93 times as many. Our fitCanvas multiplies by devicePixelRatio and caps it at 2.

    // Pixel ratio: multiplied into the canvas backing size. Capped at 2 (so a ratio-3 device does not grow the pixels 2.25 times)
    let dpr = 1;
    function fitCanvas() {
        dpr = Math.min(window.devicePixelRatio || 1, 2);
        cv.width = Math.round(W * dpr);
        cv.height = Math.round(H * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    }

To change the ratio cap or the tab size yourself, download the comparison panels with the nine scene folders and type 4kz7bnan into the password prompt when you unzip them. By simple arithmetic, lifting the ratio cap to 3 makes the backing canvas 480×300 × 3 = 1440×900, and paintPicture, which paints the hidden picture pixel by pixel, does work in proportion to the pixel count, so (3 ÷ 2)² = 2.25 times what it does at ratio 2.

What Happens When the Tabs Grow? Tab Size, Snap Radius and Spring Stiffness Side by Side

Name Changed value What changes
Bigger tabs 01 TAB_SIZE 0.2 → 0.3 knob height 19.8 → 29.7%, width 19.3 → 28.9% (share of edge length), closest knob-to-socket distance between two adjacent sides of one piece 16.12 → 5.82px
Twice the snap radius 02 SNAP_RADIUS 14 → 28 the three drops end the same way, overshoot when dropped at the edge of the limit 1.6 → 3.26px
Stiffer spring 02 SNAP_STIFFNESS 160 → 400 damping ratio 0.553 → 0.35, overshoot 1.35px (11.9%) → 3.46px (30.5%), zero crossings 5 → 9, settled from 0.417 → 0.388 s

Bigger tabs make the interlock easier to read, but near a corner two knobs come within 5.82px of each other and the piece tips get crowded. Raising the snap radius to 28px still leaves the second drop's 36.77px outside, so the result stays the same, and since a drop at the edge of the limit now starts twice as far away, its overshoot goes from 1.6px to 3.26px at the same 11.9% ratio. Raising only the stiffness to 400 lowers the damping ratio to 14 ÷ (2√400) = 0.35, where the overshoot formula above calculates 30.9% against 30.5% measured by calling the function directly; keeping 0.553 would mean raising the damping by √(400 ÷ 160) ≈ 1.58 times as well, to about 22.1 (a calculation, not something measured in the demo).

Progress You Can Hear: a Pieces-Placed Sentence, a Pause Button and Reduced Motion for Accessibility

A sentence that reads in place of the picture

All nine canvases carry role="img", an aria-label that describes the scene in one sentence, and tabindex="0". The status line under each scene stays quiet with aria-live="off" during the self-play, switches to polite on the first input from a person, and from then on reads "groups left g · joins j/n" each time the sentence changes and "done: all n pieces in one group" once a single group is left.

Keyboard and pause

With the canvas focused, Enter or Space picks the next group, arrow keys move it 4px (16px with Shift) and run the drop check each time, and Escape lets go. In 04 alone, pressing R with a single loose piece picked adds 90° to its target angle, and the angle spring turns it. Each scene has a real button for pause that sets aria-pressed to true, and the loop stops while the tab is hidden and resumes when it comes back. Under the reduced motion setting the loop stops and one finished board with every piece in place is drawn, so captures at 0.4 and 1.2 seconds differed by 0 pixels in all nine scenes; 08 and 09 show the board 10 seconds after completion, with the seams erased and the wave and gloss already past. Key input under this setting puts the springs straight onto their targets and draws once.

Jigsaw Puzzle JavaScript FAQ: Piece Count, Swapping the Photo and Touch Input

Where do I change the number of pieces on the board

In the COLS, ROWS and PIECE lines of each item's index.html. The board width COLS × PIECE and height ROWS × PIECE have to fit inside the 480×300 canvas, and the tray radius TRAY_R in 05 and 09 is PIECE * 0.62, so it shrinks along with the pieces. With more pieces, the solver in 06 meets more inner cells with two or more candidates left, and the color score carries more of the decision.

Can I turn this into a jigsaw puzzle maker from photo files of my own

Yes, with one limit. The pieces only look at the hidden canvas pic, so replacing the pixel painting in paintPicture with a drawImage of the photo at board size × ratio leaves 01 to 05, 07 and 08 working as they are. 06 and 09 read that canvas with getImageData to score colors, and a photo from another origin drawn without permission taints the canvas so the read fails with a security error, so use a same-origin photo or one served with permission.

Can pieces be dragged with a finger on a phone

Yes. Mouse, pen and finger all come through one set of pointerdown, pointermove, pointerup and pointercancel handlers, setPointerCapture keeps hold of the piece even when the finger leaves the canvas, and touch-action: none on the canvas alone stops the page from scrolling while you drag on the board. In all nine scenes, grabbing a piece and dragging it 60px moved its whole group with it.

Enter the archive password

The password is inside this article. You will find it as you read.