{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"puzzle-transition","title":"Puzzle Transition","description":"Pages laid out as joined jigsaw pieces, outlined in one SVG line just outside each page. A change pulls the camera back a little to show the outline, slides to the next piece with its arm in this one's socket, and closes in, each step on a minimum-jerk law that turns back smoothly when interrupted. Live DOM, no canvas.","dependencies":[],"files":[{"path":"src/components/PuzzleTransition/index.js","content":"export { default } from './PuzzleTransition.jsx'\n","type":"registry:component","target":"components/puzzle-transition/index.js"},{"path":"src/components/PuzzleTransition/PuzzleTransition.jsx","content":"'use client'\n\nimport { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport './PuzzleTransition.css'\n\n// Measure and place before paint in the browser; on the server there is nothing to place.\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect\n\n/* =========================================================\n   PuzzleTransition\n   Wraps content that changes — a route, a step, a tab — and\n   cuts its pages from one jigsaw. Each page is a piece whose\n   outline lies just outside its edge, and the edge it shares\n   with the next is a jigsaw cut with an arm that reaches into\n   the next piece. At rest the page fills the box and the\n   outline is out of view. When transitionKey changes, the\n   camera pulls back a little, far enough to show the outline;\n   then the pages slide across together and the cut crosses\n   the box, its arm locked into the next page; then the camera\n   closes in on the next page, and its outline is out of view\n   in turn.\n\n   Two pieces that lock share their cut, so one line is both\n   outlines; a line for each shows a gap or a doubled stroke\n   where a jigsaw has neither. The arm is a real tab's shape:\n   a short neck, then a round head twice as wide, so the piece\n   it reaches into holds it. A round bump with no waist reads\n   as a bulge in the paper, not a tab. Because the arm reaches\n   past the page, the next page lies further off than a page's\n   length: a margin, the line, the arm and a margin again.\n   Butting the pages edge to edge would lay the arm over the\n   next page's content. Every piece has the same cut on both\n   sides, a socket behind and an arm ahead, so a page looks the\n   same pulled back whichever transition shows it.\n\n   The camera has two channels, its scale and how far it has\n   slid, and each step moves one of them on the minimum-jerk\n   law a hand follows between two rests (Flash & Hogan, 1985),\n\n     x(τ) = 10τ³ − 15τ⁴ + 6τ⁵,   τ = t/T\n\n   pull back to `zoom`, slide, close in to 1, one after\n   another. A keyframed sequence would have to finish its\n   steps or jump; here every step is solved from the channel's\n   position, speed and acceleration, so a key changed back\n   partway turns the step in progress round where it is and\n   runs the rest backwards.\n\n   The pages stay live DOM, moved and scaled by transforms and\n   never copied; the outline is one SVG path over them, at a\n   constant line width whatever the scale. While the camera\n   moves neither page takes the pointer, and the arriving one\n   is inert until it is the page on show.\n\n   A key that changes again while the camera moves waits for\n   it to arrive, and then the next page is brought to the one\n   just shown.\n\n   Under reduced motion, in a hidden tab, or with the box off\n   screen, there is nobody to watch: the change is made at\n   once and onComplete still fires.\n   ========================================================= */\n\nconst KNOB = 0.15       // the arm's head radius, as a share of its stretch of the joining edge\nconst KNOB_MAX = 0.12   // and at most this share of the page's length along the slide\nconst REACH = 2.39      // the arm's crown, in head radii past the cut\nconst PULL = 0.2        // the share of the duration each of pulling back and closing in takes\n\n/* One arm as six cubics in head radii: u along the edge from the arm's\n   middle, v out from the edge into the next piece. A tight turn off the edge\n   into a neck about half the head's width, the head a circle of radius 1\n   whose middle is 1.39 out, its crown at REACH, and back the same way. */\nconst KNOB_PATH = [\n  [-0.75, 0, -0.65, 0, -0.6, 0, -0.55, 0.1],\n  [-0.55, 0.1, -0.33, 0.59, -1, 0.81, -1, 1.39],\n  [-1, 1.39, -1, 1.942, -0.552, REACH, 0, REACH],\n  [0, REACH, 0.552, REACH, 1, 1.942, 1, 1.39],\n  [1, 1.39, 1, 0.81, 0.33, 0.59, 0.55, 0.1],\n  [0.55, 0.1, 0.6, 0, 0.65, 0, 0.75, 0],\n]\n\n/* The pages' own coordinates onto the box, as [d00, d01, d02, d10, d11, d12]:\n   a along the slide, c across it. The page on show runs from a = 0 to A and\n   the next lies further along a, so the pages move toward smaller a: pages\n   that move left are laid out with the next one on the right. */\nconst DIRS = {\n  left: () => [1, 0, 0, 0, 1, 0],\n  right: (W) => [-1, 0, W, 0, 1, 0],\n  up: () => [0, 1, 0, 1, 0, 0],\n  down: (W, H) => [0, 1, 0, -1, 0, H],\n}\n\nconst clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n)\nconst finite = (n, d) => (Number.isFinite(n) ? n : d)\n\n/* The minimum-jerk quintic from x0, moving at v0 and accelerating at a0, to\n   rest at x1 after T seconds: the smoothest path between them. */\nfunction reach(x0, v0, a0, x1, T) {\n  const d = x1 - x0\n  return [\n    x0,\n    v0,\n    a0 / 2,\n    (20 * d - 12 * v0 * T - 3 * a0 * T * T) / (2 * T ** 3),\n    (-30 * d + 16 * v0 * T + 3 * a0 * T * T) / (2 * T ** 4),\n    (12 * d - 6 * v0 * T - a0 * T * T) / (2 * T ** 5),\n  ]\n}\n\n// One coordinate moved by reaches: where it is, its speed and acceleration, and the reach it is on.\nconst channel = (x) => ({ x, v: 0, a: 0, to: x, q: null, t0: 0, T: 0 })\n\nfunction aim(c, to, T, now) {\n  c.T = Math.max(1e-3, T)\n  c.q = reach(c.x, c.v, c.a, to, c.T)\n  c.to = to\n  c.t0 = now\n}\n\n// Moves a channel on to `now`; true while it is still on its way.\nfunction follow(c, now) {\n  if (!c.q) return false\n  const t = now - c.t0\n  if (t >= c.T) {\n    c.x = c.to\n    c.v = 0\n    c.a = 0\n    c.q = null\n    return false\n  }\n  const [, c1, c2, c3, c4, c5] = c.q\n  c.x = c.q[0] + t * (c1 + t * (c2 + t * (c3 + t * (c4 + t * c5))))\n  c.v = c1 + t * (2 * c2 + t * (3 * c3 + t * (4 * c4 + t * 5 * c5)))\n  c.a = 2 * c2 + t * (6 * c3 + t * (12 * c4 + t * 20 * c5))\n  return true\n}\n\nfunction hold(c, x) {\n  c.x = x\n  c.to = x\n  c.v = 0\n  c.a = 0\n  c.q = null\n}\n\nfunction cubic(s, t) {\n  const m = 1 - t\n  const w0 = m * m * m, w1 = 3 * m * m * t, w2 = 3 * m * t * t, w3 = t * t * t\n  return [w0 * s[0] + w1 * s[2] + w2 * s[4] + w3 * s[6], w0 * s[1] + w1 * s[3] + w2 * s[5] + w3 * s[7]]\n}\n\nconst pathOf = (pts) => pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(2)} ${p[1].toFixed(2)}`).join('')\n\n/* Where the outline lies, in the coordinates of the page on show: its print\n   runs from a = 0 to A and from c = 0 to C. The line's near edge is `margin`\n   outside the print all round. The cut ahead of it reaches into the next\n   piece, whose print starts `margin` past the line's edge at the arms'\n   crowns; the same cut lies one piece behind, reaching into this one, and\n   one piece ahead of the next. The top and bottom edges run the length of\n   the three. */\nfunction layoutFor(A, C, knobs, margin, thickness) {\n  const n = clamp(Math.round(finite(knobs, 1)), 1, 8)\n  const m = clamp(finite(margin, 12), 0, Math.min(A, C) / 4)\n  const half = Math.max(0, finite(thickness, 6)) / 2\n  const e = m + half\n  const r = Math.max(2, Math.min((KNOB * C) / n, KNOB_MAX * A))\n  const seam = A + e\n  const next = seam + REACH * r + e\n  const steps = clamp(Math.round(r / 3), 8, 32)\n  const cutAt = (at) => {\n    const cut = [[at, -e]]\n    for (let i = 0; i < n; i++) {\n      const mid = ((i + 0.5) * C) / n\n      cut.push([at, mid + KNOB_PATH[0][0] * r])\n      for (const s of KNOB_PATH) {\n        for (let k = 1; k <= steps; k++) {\n          const [u, v] = cubic(s, k / steps)\n          cut.push([at + v * r, mid + u * r])\n        }\n      }\n    }\n    cut.push([at, C + e])\n    return pathOf(cut)\n  }\n  const first = seam - next, last = seam + next\n  const edges = pathOf([[first, -e], [last, -e]]) + pathOf([[first, C + e], [last, C + e]])\n  return { next, outline: cutAt(first) + cutAt(seam) + cutAt(last) + edges }\n}\n\n// A page's key as a React key: typed, so 1 and '1' are two pages.\nconst keyOf = (k) => `${typeof k}:${String(k)}`\n\nexport default function PuzzleTransition({\n  transitionKey,\n  direction = 'left',    // the way the pages move: 'left' | 'right' | 'up' | 'down'\n  duration = 1400,       // ms, pulling back, sliding and closing in; never under 300\n  zoom = 0.85,           // the scale the camera pulls back to before the slide; 1 for none\n  knobs = 1,             // arms along the joining edge, 1 to 8\n  margin = 12,           // px between a page's edge and its outline\n  thickness = 6,         // px, the line, at any scale\n  color = 'currentColor', // the line\n  onComplete,            // () => void, once the camera has arrived, or gone back\n  children,\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const firstRef = useRef(null)\n  const secondRef = useRef(null)\n  const lineRef = useRef(null)\n  const api = useRef(null)\n\n  /* Two fixed slots, so a page keeps its DOM when it goes from arriving to\n     being on show. `front` is the slot holding the page on show; the other\n     holds the next page, if there is one. */\n  const [deck, setDeck] = useState(() => ({ front: 0, view: { key: transitionKey, children }, incoming: null }))\n  let next = deck\n  if (Object.is(transitionKey, deck.view.key)) {\n    if (children !== deck.view.children) next = { ...deck, view: { key: transitionKey, children } }\n  } else if (!deck.incoming) {\n    next = { ...deck, incoming: { key: transitionKey, children } }\n  } else if (Object.is(transitionKey, deck.incoming.key) && children !== deck.incoming.children) {\n    next = { ...deck, incoming: { key: transitionKey, children } }\n  }\n  if (next !== deck) setDeck(next)\n\n  const live = useRef(null)\n  const deckRef = useRef(deck)\n  // Synced after every render and before the effects below, so the motion reads\n  // current props and the current deck without restarting.\n  useEffect(() => {\n    live.current = { direction, duration, zoom, knobs, margin, thickness, onComplete }\n    deckRef.current = deck\n  })\n\n  useEffect(() => {\n    const root = rootRef.current\n    const slots = [firstRef.current, secondRef.current]\n    const line = lineRef.current\n\n    let phase = 'idle'    // 'idle' | 'go' | 'back' (going back) | 'done' (waiting on React)\n    let raf = 0, last = 0, clock = 0\n    let seen = true\n    let dir = 'left'\n    let W = 1, H = 1, ox = 0, oy = 0\n    let shapeKey = '', shape = null\n    const pan = channel(0)     // how far the camera has slid from the page on show to the next, 0 to 1\n    const scale = channel(1)   // the camera's scale: 1 at rest, `zoom` pulled back\n    let plan = []              // the steps still to come, each [channel, to, seconds]\n    let step = null            // the channel the step in progress moves\n\n    const props = () => live.current\n    const seconds = () => Math.max(0.3, finite(props().duration, 1400) / 1000)\n    const zoomOf = () => clamp(finite(props().zoom, 0.85), 0.3, 1)\n\n    /* From wherever the camera is, the steps that bring it to rest on `to`:\n       pull back, slide, close in. A step takes its share of the duration, or\n       the part of that share its distance is, never under a third of it. */\n    const planFor = (to) => {\n      const z = zoomOf()\n      const pull = z < 1 ? PULL * seconds() : 0\n      const slide = seconds() - 2 * pull\n      const part = (d, whole) => clamp(d / (whole || 1), 1 / 3, 1)\n      if (pan.q || Math.abs(pan.x - to) > 1e-4) {\n        return [\n          [scale, z, pull * part(Math.abs(scale.x - z), 1 - z)],\n          [pan, to, slide * part(Math.abs(pan.x - to), 1)],\n          [scale, 1, pull],\n        ]\n      }\n      return [[scale, 1, pull * part(Math.abs(scale.x - 1), 1 - z)]]\n    }\n\n    // Starts the next step that has somewhere to go; false once the plan is done.\n    const advance = () => {\n      while (plan.length) {\n        const [c, to, T] = plan.shift()\n        if (!c.q && Math.abs(c.x - to) < 1e-4) continue\n        aim(c, to, T, clock)\n        step = c\n        return true\n      }\n      step = null\n      return false\n    }\n\n    const measure = () => {\n      const vertical = dir === 'up' || dir === 'down'\n      const A = vertical ? H : W\n      const C = vertical ? W : H\n      const p = props()\n      const key = `${A}|${C}|${p.knobs}|${p.margin}|${p.thickness}`\n      if (key !== shapeKey) {\n        shapeKey = key\n        shape = layoutFor(A, C, p.knobs, p.margin, p.thickness)\n        line.setAttribute('d', shape.outline)\n      }\n      return { A, C }\n    }\n\n    const draw = () => {\n      const { A, C } = measure()\n      const D = DIRS[dir](W, H)\n      // The camera looks at u along the slide, scaled by s about the box's middle.\n      const s = scale.x\n      const u = A / 2 + clamp(pan.x, 0, 1) * shape.next\n      const ta = A / 2 - s * u\n      const tc = (C / 2) * (1 - s)\n      // A page's print onto the box: its slot is moved and scaled, never redrawn.\n      const place = (el, a0) => {\n        const a1 = s * a0 + ta\n        const ex = Math.min(D[0] * a1 + D[1] * tc + D[2], D[0] * (a1 + s * A) + D[1] * (tc + s * C) + D[2])\n        const ey = Math.min(D[3] * a1 + D[4] * tc + D[5], D[3] * (a1 + s * A) + D[4] * (tc + s * C) + D[5])\n        el.style.transform = `translate3d(${ex}px, ${ey}px, 0) scale(${s})`\n      }\n      place(slots[deckRef.current.front], 0)\n      const arriving = slots[1 - deckRef.current.front]\n      arriving.style.left = `${ox}px`\n      arriving.style.top = `${oy}px`\n      arriving.style.width = `${W}px`\n      arriving.style.height = `${H}px`\n      place(arriving, shape.next)\n      const e = D[0] * ta + D[1] * tc + D[2] + ox\n      const f = D[3] * ta + D[4] * tc + D[5] + oy\n      line.setAttribute('transform', `matrix(${D[0] * s} ${D[3] * s} ${D[1] * s} ${D[4] * s} ${e} ${f})`)\n    }\n\n    // The camera has arrived or gone back; React swaps the slots, then landed() tidies up.\n    const commit = () => {\n      phase = 'done'\n      setDeck((d) => (d.incoming ? { front: 1 - d.front, view: d.incoming, incoming: null } : { ...d }))\n    }\n    const giveBack = () => {\n      phase = 'done'\n      setDeck((d) => ({ ...d, incoming: null }))\n    }\n\n    const frame = (now) => {\n      // Real elapsed time, held to 1/30 s so a stalled tab does not lurch on return.\n      const dt = last ? Math.min(1 / 30, (now - last) / 1000) : 1 / 60\n      last = now\n      clock += dt\n      if (!(step && follow(step, clock)) && !advance()) {\n        if (phase === 'go') commit()\n        else if (phase === 'back') giveBack()\n      }\n      draw()\n      raf = phase === 'done' ? 0 : requestAnimationFrame(frame)\n    }\n\n    const run = () => {\n      if (!raf) {\n        last = 0\n        raf = requestAnimationFrame(frame)\n      }\n    }\n\n    // Nobody is watching, or nobody wants the motion: make the change and be done.\n    const settle = () => {\n      if (phase === 'go') commit()\n      else if (phase === 'back') giveBack()\n      if (raf) cancelAnimationFrame(raf)\n      raf = 0\n    }\n\n    const fit = () => {\n      const shown = slots[deckRef.current.front]\n      W = Math.max(1, shown.offsetWidth)\n      H = Math.max(1, shown.offsetHeight)\n      ox = shown.offsetLeft\n      oy = shown.offsetTop\n      if (phase === 'go' || phase === 'back') draw()\n    }\n\n    const calm = window.matchMedia('(prefers-reduced-motion: reduce)')\n\n    const request = () => {\n      if (phase === 'done' || phase === 'go') return\n      if (calm.matches || !seen || document.hidden) {\n        commit()\n        return\n      }\n      if (phase === 'idle') {\n        const d = props().direction\n        dir = d === 'right' || d === 'up' || d === 'down' ? d : 'left'\n        hold(pan, 0)\n        hold(scale, 1)\n        fit()\n        root.setAttribute('data-moving', '')\n      }\n      phase = 'go'\n      plan = planFor(1)\n      advance()\n      draw()\n      run()\n    }\n\n    const cancel = () => {\n      if (phase !== 'go') return\n      if (calm.matches || !seen || document.hidden) {\n        giveBack()\n        return\n      }\n      phase = 'back'\n      plan = planFor(0)\n      advance()\n      run()\n    }\n\n    // React has swapped the slots (or let the next page go): put everything back at rest.\n    const landed = () => {\n      if (phase !== 'done') return\n      phase = 'idle'\n      if (raf) cancelAnimationFrame(raf)\n      raf = 0\n      plan = []\n      step = null\n      root.removeAttribute('data-moving')\n      for (const el of slots) {\n        el.style.transform = ''\n        el.style.left = ''\n        el.style.top = ''\n        el.style.width = ''\n        el.style.height = ''\n      }\n      props().onComplete?.()\n    }\n\n    const ro = new ResizeObserver(fit)\n    ro.observe(root)\n\n    const io = new IntersectionObserver((entries) => {\n      seen = entries[entries.length - 1].isIntersecting\n      if (!seen) settle()\n    })\n    io.observe(root)\n\n    const onHide = () => {\n      if (document.hidden) settle()\n    }\n    const onCalm = () => {\n      if (calm.matches) settle()\n    }\n    document.addEventListener('visibilitychange', onHide)\n    calm.addEventListener('change', onCalm)\n\n    api.current = { request, cancel, landed }\n\n    return () => {\n      api.current = null\n      ro.disconnect()\n      io.disconnect()\n      document.removeEventListener('visibilitychange', onHide)\n      calm.removeEventListener('change', onCalm)\n      if (raf) cancelAnimationFrame(raf)\n      root.removeAttribute('data-moving')\n    }\n  }, [])\n\n  // The deck has changed under a finished move: tidy up before the swap is painted.\n  useIsomorphicLayoutEffect(() => {\n    api.current?.landed()\n  }, [deck])\n\n  /* A new key sends the camera to the next page; the key going back to the\n     page on show before it arrives takes it back. Any other key waits for it\n     to arrive, and is brought to it after. */\n  const incoming = deck.incoming\n  const viewKey = deck.view.key\n  useEffect(() => {\n    if (!incoming) return\n    if (Object.is(transitionKey, incoming.key)) api.current?.request()\n    else if (Object.is(transitionKey, viewKey)) api.current?.cancel()\n  }, [transitionKey, incoming, viewKey])\n\n  const pages = [0, 1].map((i) => (i === deck.front ? deck.view : deck.incoming))\n\n  return (\n    <div\n      ref={rootRef}\n      className={`puzzle-transition ${className}`.trim()}\n      aria-busy={incoming ? true : undefined}\n      style={style}\n      {...rest}\n    >\n      {pages.map((page, i) => (\n        <div\n          key={i}\n          ref={i ? secondRef : firstRef}\n          className={`puzzle-transition__page${i === deck.front ? '' : ' is-arriving'}`}\n          aria-hidden={i === deck.front ? undefined : true}\n          inert={i !== deck.front}\n        >\n          {page ? <Fragment key={keyOf(page.key)}>{page.children}</Fragment> : null}\n        </div>\n      ))}\n      <svg className=\"puzzle-transition__lines\" aria-hidden=\"true\">\n        <path\n          ref={lineRef}\n          fill=\"none\"\n          stroke={color}\n          strokeWidth={thickness}\n          strokeLinejoin=\"round\"\n          vectorEffect=\"non-scaling-stroke\"\n        />\n      </svg>\n    </div>\n  )\n}\n","type":"registry:component","target":"components/puzzle-transition/PuzzleTransition.jsx"},{"path":"src/components/PuzzleTransition/PuzzleTransition.css","content":"/* PuzzleTransition — the page on show keeps its normal flow and its own\n   stacking; the next page is laid beside it at the same size and both are\n   moved and scaled by transforms alone. The outline is one SVG path over\n   them, just outside the pages' edges, so it is out of view at rest; it is\n   hidden then as well. While the camera moves, neither page takes the\n   pointer; at rest the arriving slot is hidden. */\n\n.puzzle-transition {\n  position: relative;\n  overflow: hidden;\n  isolation: isolate;\n}\n\n.puzzle-transition__page {\n  position: relative;\n  z-index: 1;\n  height: 100%;\n  isolation: isolate;\n  transform-origin: 0 0;\n}\n\n.puzzle-transition__page.is-arriving {\n  position: absolute;\n  inset: 0;\n  z-index: 2;\n  visibility: hidden;\n}\n\n.puzzle-transition[data-moving] .puzzle-transition__page {\n  pointer-events: none;\n  will-change: transform;\n}\n\n.puzzle-transition[data-moving] .puzzle-transition__page.is-arriving {\n  visibility: visible;\n}\n\n.puzzle-transition__lines {\n  position: absolute;\n  inset: 0;\n  z-index: 3;\n  width: 100%;\n  height: 100%;\n  display: block;\n  overflow: hidden;\n  pointer-events: none;\n  visibility: hidden;\n}\n\n.puzzle-transition[data-moving] .puzzle-transition__lines {\n  visibility: visible;\n}\n","type":"registry:file","target":"components/puzzle-transition/PuzzleTransition.css"},{"path":"src/components/PuzzleTransition/PuzzleTransition.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from 'react'\n\nexport interface PuzzleTransitionProps extends ComponentPropsWithoutRef<'div'> {\n  /**\n   * Changing this sends the camera to the next page, which lies beside the page on show as the\n   * jigsaw piece it locks into: it pulls back, slides and closes in. Changed back before it\n   * arrives, it goes back the way it came; changed again while it moves, the next page is\n   * brought once it has arrived.\n   */\n  transitionKey?: string | number\n  /** The way the pages move. The next page comes in from the opposite side. */\n  direction?: 'left' | 'right' | 'up' | 'down'\n  /** The whole move, ms: pulling back and closing in take a fifth each, the slide the rest. Held to at least 300; anything that is not a finite number falls back to 1400. */\n  duration?: number\n  /** The scale the camera pulls back to before the slide, which brings the outline into view. 1 for no pull back; held to at least 0.3. */\n  zoom?: number\n  /** How many arms are cut along the joining edge, 1 to 8. */\n  knobs?: number\n  /** How far outside a page's edge its outline lies, px, measured to the line's edge. At rest it is out of view. */\n  margin?: number\n  /** The line's width, px, the same at any scale. */\n  thickness?: number\n  /** The line's colour. Any CSS colour; by default the text colour. */\n  color?: string\n  /** Called once the camera has arrived at the next page, or gone back to the one on show. */\n  onComplete?: () => void\n  /** What is showing for the current `transitionKey`. */\n  children?: ReactNode\n}\n\ndeclare const PuzzleTransition: (props: PuzzleTransitionProps) => ReactElement\nexport default PuzzleTransition\n","type":"registry:lib","target":"components/puzzle-transition/PuzzleTransition.d.ts"}],"type":"registry:component"}