{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"magnetic-field","title":"Magnetic Field","description":"Iron filings aligning to a dipole under the cursor. B proportional to (3(m·r̂)r̂ − m)/r³, so the pattern has two lobes and the loops between them — a radial fan has neither.","dependencies":[],"files":[{"path":"src/components/MagneticField/index.js","content":"export { default } from './MagneticField.jsx'\n","type":"registry:component","target":"components/magnetic-field/index.js"},{"path":"src/components/MagneticField/MagneticField.jsx","content":"'use client'\n\nimport { useEffect, useRef } from 'react'\nimport './MagneticField.css'\n\n/* =========================================================\n   MagneticField\n   Iron filings scattered on a surface, aligning to a magnet\n   held under the cursor.\n\n   The obvious version points every filing at the pointer,\n   which gives a radial fan — a starburst. Real filings do\n   not do that, because a bar magnet is a dipole, not a\n   monopole. Its field is\n\n     B ∝ (3(m·r̂)r̂ − m) / r³\n\n   Off either pole the filings lie along the axis. Straight\n   out to the side they lie along it too — the field there\n   runs back the other way, and a filing cannot show which\n   way. Between the two, 54.7° off the axis where 3cos²θ = 1,\n   they stand straight across it. So walking once round the\n   magnet the filings turn over twice, where a fan's turn\n   over once, and that second turn is the loops everyone\n   recognises from the school experiment. A fan has none.\n\n   The magnet sits a little under the paper (`depth`), which\n   is where it really is. That keeps the field finite under\n   the cursor: the filings there lie back along the axis\n   instead of whipping round a singular point.\n\n   How hard a filing is pulled follows |B|, not distance.\n   |B| = m·√(1 + 3cos²θ) / r³, twice as strong off a pole as\n   to the side, so the grip halves at 170·(m·√(1+3cos²θ))^⅓\n   px: the gripped patch reaches a quarter further along the\n   axis than across it and turns with `tilt`, and `moment`\n   widens it by its cube root, as a stronger magnet would.\n\n   The magnet does not switch on. When the pointer arrives\n   its reach spreads out from under it in a few frames. When\n   it leaves, the hand takes the magnet with it: the pointer\n   is followed off the edge while the reach draws back in, a\n   little slower than it came, so the outer filings let go\n   first, the ones it was holding hardest last, and the patch\n   goes out the way the hand went. Parked at the last point\n   inside and dissolved there, it reads as a magnet switched\n   off, not taken away, and the trail it left catches up to\n   it after the hand has gone. A finger has no hover to\n   follow, so one lifting mid-stroke carries the magnet on at\n   the speed of its last 80 ms, slowing as a hand does. Come\n   back while it is still going and it carries on from where\n   the reach was. Put it down somewhere else while the last\n   is still letting go — a second tap, say — and that is a\n   second magnet: fields add as vectors, so B is summed\n   over both and the grip taken from the sum, and the first\n   patch lets go on its own time while the new one arrives,\n   instead of the grip leaping across the field.\n\n   Filings only rotate; they never move. That is what they do\n   on paper, and it also means the grid can be built once and\n   the frame loop writes nothing but a rotation per filing.\n   They are all friction and no inertia, so each closes a\n   fixed fraction of its remaining turn per 60 fps frame —\n   applied in real elapsed time, so a 120 Hz display does not\n   run them twice as fast — and snaps home once the turn is\n   too small to see, which is what lets the loop stop.\n\n   Orientation is a director, not a vector: a filing has no\n   north end, so the angle is taken modulo π and eased along\n   the shorter arc. Without that they spin a full half-turn\n   whenever the field crosses the ±90° seam. The magnet's\n   pull and the pull back to the resting angle are added as\n   directors too, on doubled angles, where a line with no\n   north end is a single point on a circle. That gives each\n   filing one place to settle for a given magnet. Adding the\n   two turns instead gives two wherever the field stands\n   across the resting angle, and a filing a moving magnet\n   sweeps past can be left in the wrong one: a frozen wake\n   behind the hand that a magnet set down on the same spot\n   does not have.\n\n   Touch has no hover, so the magnet comes down where a\n   finger lands and lifts with it. A drag that sets off\n   sideways carries the magnet; one that sets off up or down\n   scrolls the page as usual (touch-action: pan-y), and the\n   magnet lifts where it was. Set touch-action: none on the\n   field if every drag should steer. Children render above\n   the filings as real content, and a control inside that\n   takes keyboard focus draws the magnet to it. It slides\n   from one control to the next, critically damped and\n   keeping whatever speed it had, so tabbing through reads\n   as the same magnet moving rather than one per control.\n   ========================================================= */\n\n/* Director, not vector: fold an angle difference to (−π/2, π/2], the shorter\n   way round for something with no north end. */\nconst arc = (d) => {\n  d %= Math.PI\n  if (d > Math.PI / 2) d -= Math.PI\n  if (d < -Math.PI / 2) d += Math.PI\n  return d\n}\n\n// A finite number, or the fallback. One NaN angle would poison a filing for good.\nconst num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d)\n\n// Focus the browser would ring, from the keyboard; a click's focus is the pointer's business.\nconst keyboard = (el) => {\n  try { return el.matches(':focus-visible') } catch { return false }\n}\n\nconst REACH = 170     // px where a unit moment's grip is half, across the axis\nconst ARRIVE = 0.06   // s, time constant of the reach spreading out on arrival\nconst RELEASE = 0.16  // s, and of it drawing back in on departure\nconst COAST = 0.1     // s, time constant of a lifted magnet slowing with the hand that lifted it\nconst TRAIL = 80      // ms of stroke a lift's speed is measured over\nconst FLING = 3000    // px/s, the most a lift carries on at\nconst GLIDE = 20      // rad/s, the magnet sliding to a focused control (critically damped)\nconst HANDOFF = 24    // px; come back further than this from a magnet letting go and it is a new one\nconst LEAVING = 3     // magnets still letting go at once; past that the faintest is dropped\nconst GONE = 1e-3     // a reach this small grips nothing visible\nconst SETTLED = 1.5e-3 // rad; the tip of a 34 px filing moves 1/40 px. Snap and rest\n\n/* A magnet let go: it carries on at its speed, slowing exponentially. The\n   exact solution of dv/dt = −v/COAST over dt, so no frame rate changes how\n   far it goes. */\nconst coast = (g, dt) => {\n  const f = Math.exp(-dt / COAST)\n  const d = COAST * (1 - f)\n  g.x += g.vx * d; g.y += g.vy * d\n  g.vx *= f; g.vy *= f\n}\n\nexport default function MagneticField({\n  cell = 22,            // filing spacing, px\n  length = 11,          // filing length, px\n  thickness = 1.6,      // filing stroke, px\n  moment = 1,           // dipole strength; the gripped patch grows as its cube root\n  tilt = 0,             // dipole axis, radians — 0 lies along x\n  falloff = 2.2,        // how fast alignment gives way to the resting angle\n  rest = 0,             // resting angle when the magnet is far away, radians\n  smoothing = 0.22,     // share of the remaining turn a filing closes per 60 fps frame\n  depth = 10,           // how far under the paper the magnet sits, px\n  color = '#e8e4dc',    // filing colour\n  background = 'transparent', // behind the filings\n  className = '',\n  style,\n  children,\n  ...rest_\n}) {\n  const rootRef = useRef(null)\n  const canvasRef = useRef(null)\n  const api = useRef(null)\n\n  const live = useRef({})\n  // Synced after every render and before the effect below, so the loop reads\n  // current props without the grid being rebuilt.\n  useEffect(() => {\n    live.current = { cell, length, thickness, moment, tilt, falloff, rest, smoothing, depth, color }\n  })\n\n  useEffect(() => {\n    const root = rootRef.current\n    const canvas = canvasRef.current\n    const ctx = canvas && canvas.getContext('2d')\n    if (!root || !ctx) return\n\n    const motion = window.matchMedia('(prefers-reduced-motion: reduce)')\n    let still = motion.matches\n\n    let W = 0, H = 0, dpr = 0\n    let c = 0, cols = 0, rows = 0, ox = 0, oy = 0\n    // Filing centres and current angle.\n    let xs = new Float32Array(0), ys = new Float32Array(0), ang = new Float32Array(0)\n\n    /* The magnet in hand: where it is, how fast it is moving, and how much\n       of its reach has arrived (0..1) and is heading for. It follows the\n       pointer while one is over the field, else a control inside that has\n       keyboard focus, else, while it is being taken away, the pointer that\n       left; (tx, ty) is where that is. */\n    const M = { x: 0, y: 0, vx: 0, vy: 0, reach: 0, goal: 0, gliding: false }\n    let tx = 0, ty = 0\n    // Magnets put down elsewhere, each carrying on from where it was, letting go.\n    const leaving = []\n    // This frame's magnets, flat: position and m·reach³ scaled so |B| = 1 at the half-grip.\n    const sx = new Float64Array(LEAVING + 1)\n    const sy = new Float64Array(LEAVING + 1)\n    const ss = new Float64Array(LEAVING + 1)\n\n    // pointer: one is over the field. away: it has left, and is followed while the reach draws in.\n    let pointer = false, away = false, px = 0, py = 0\n    let focused = null\n    // The last few pointer samples, [ms, x, y] in client px, for the speed a lift carries.\n    const trail = new Float64Array(8 * 3)\n    let tn = 0\n\n    let raf = 0, last = 0\n    let onScreen = true\n\n    const size = () => {\n      const b = root.getBoundingClientRect()\n      const d = Math.min(2, window.devicePixelRatio || 1)\n      if (b.width === W && b.height === H && d === dpr) return\n      W = b.width; H = b.height; dpr = d\n      canvas.width = Math.max(1, Math.round(W * dpr))\n      canvas.height = Math.max(1, Math.round(H * dpr))\n      canvas.style.width = `${W}px`\n      canvas.style.height = `${H}px`\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n    }\n\n    const layout = () => {\n      const nc = Math.max(4, num(live.current.cell, 22))\n      const nCols = Math.max(1, Math.floor(W / nc))\n      const nRows = Math.max(1, Math.floor(H / nc))\n      const nox = (W - nCols * nc) / 2 + nc / 2\n      const noy = (H - nRows * nc) / 2 + nc / 2\n      if (nc === c && nCols === cols && nRows === rows && nox === ox && noy === oy) return\n\n      const n = nCols * nRows\n      const nxs = new Float32Array(n), nys = new Float32Array(n), nang = new Float32Array(n)\n      const r0 = arc(num(live.current.rest, 0))\n      for (let j = 0, i = 0; j < nRows; j++) {\n        for (let k = 0; k < nCols; k++, i++) {\n          const x = nox + k * nc, y = noy + j * nc\n          nxs[i] = x; nys[i] = y\n          /* A resize or a new spacing takes each filing's angle from the\n             nearest one already on screen, so the field carries on turning\n             from what was showing instead of snapping back to rest. */\n          if (ang.length) {\n            const ok = Math.min(cols - 1, Math.max(0, Math.round((x - ox) / c)))\n            const oj = Math.min(rows - 1, Math.max(0, Math.round((y - oy) / c)))\n            nang[i] = ang[oj * cols + ok]\n          } else nang[i] = r0\n        }\n      }\n      xs = nxs; ys = nys; ang = nang\n      c = nc; cols = nCols; rows = nRows; ox = nox; oy = noy\n    }\n\n    const paint = () => {\n      const p = live.current\n      const h = Math.max(0, num(p.length, 11)) / 2\n      const lw = Math.max(0, num(p.thickness, 1.6))\n      ctx.clearRect(0, 0, W, H)\n      // A zero lineWidth is ignored by canvas, not drawn as nothing.\n      if (!h || !lw) return\n      ctx.strokeStyle = p.color\n      ctx.lineWidth = lw\n      ctx.lineCap = 'round'\n      ctx.beginPath()\n      for (let i = 0; i < xs.length; i++) {\n        const cs = Math.cos(ang[i]) * h\n        const sn = Math.sin(ang[i]) * h\n        ctx.moveTo(xs[i] - cs, ys[i] - sn)\n        ctx.lineTo(xs[i] + cs, ys[i] + sn)\n      }\n      ctx.stroke()\n    }\n\n    const step = (dt) => {\n      const p = live.current\n      const th = num(p.tilt, 0)\n      const hx = Math.cos(th), hy = Math.sin(th)\n      const e = Math.max(0.1, num(p.falloff, 2.2)) / 3\n      const r0 = num(p.rest, 0)\n      // The resting angle as a director: doubled, so r0 and r0 + π are one point.\n      const rc = Math.cos(2 * r0), rs = Math.sin(2 * r0)\n      const sm = Math.min(1, Math.max(0.01, num(p.smoothing, 0.22)))\n      const dz = Math.max(0, num(p.depth, 10))\n      let moving = false\n\n      // Reach arriving or drawing back in, in real time.\n      if (still) { M.reach = M.goal; leaving.length = 0 }\n      else {\n        if (M.reach !== M.goal) {\n          M.reach += (M.goal - M.reach) * (1 - Math.exp(-dt / (M.goal > M.reach ? ARRIVE : RELEASE)))\n          if (Math.abs(M.goal - M.reach) < GONE) M.reach = M.goal\n          else moving = true\n        }\n        const fade = Math.exp(-dt / RELEASE)\n        for (let j = leaving.length - 1; j >= 0; j--) {\n          const g = leaving[j]\n          g.reach *= fade\n          coast(g, dt)\n          if (g.reach < GONE) leaving.splice(j, 1)\n          else moving = true\n        }\n      }\n\n      // Nothing to slide to once the magnet is let go; it keeps the speed it had.\n      if (!M.goal || still) M.gliding = false\n\n      if (M.gliding) {\n        /* Sliding to a focused control. The exact critically damped step, so\n           it cannot overshoot the control or go unstable on a slow frame, and\n           it starts from whatever velocity the magnet already had. */\n        const f = Math.exp(-GLIDE * dt)\n        const ex = M.x - tx, ey = M.y - ty\n        const cx = M.vx + GLIDE * ex, cy = M.vy + GLIDE * ey\n        const nx = (ex + cx * dt) * f, ny = (ey + cy * dt) * f\n        M.vx = (M.vx - GLIDE * cx * dt) * f\n        M.vy = (M.vy - GLIDE * cy * dt) * f\n        if (Math.hypot(nx, ny) < 0.25 && Math.hypot(M.vx, M.vy) < 4) {\n          M.gliding = false; M.x = tx; M.y = ty; M.vx = 0; M.vy = 0\n        } else { M.x = tx + nx; M.y = ty + ny; moving = true }\n      }\n      // Lifted by a finger mid-stroke: the hand carries it on, slowing.\n      else if (!M.goal && !away && !still) coast(M, dt)\n      // Held, being taken away, or just arrived: exactly where it is aimed.\n      else { M.x = tx; M.y = ty; M.vx = 0; M.vy = 0 }\n\n      // Strength m·reach³, since reach is a length and |B| goes as m/r³.\n      const s0 = Math.abs(num(p.moment, 1)) * REACH ** 3\n      let n = 0\n      if (M.reach > 0) { sx[n] = M.x; sy[n] = M.y; ss[n] = s0 * M.reach ** 3; n++ }\n      for (const g of leaving) { sx[n] = g.x; sy[n] = g.y; ss[n] = s0 * g.reach ** 3; n++ }\n\n      const k = still ? 1 : 1 - Math.pow(1 - sm, dt * 60)\n      const dz2 = dz * dz\n\n      for (let i = 0; i < xs.length; i++) {\n        const a = ang[i]\n        let bx = 0, by = 0, bz = 0\n        for (let j = 0; j < n; j++) {\n          const dx = xs[i] - sx[j]\n          const dy = ys[i] - sy[j]\n          const r2 = dx * dx + dy * dy + dz2\n          if (r2 < 1e-6) continue\n          const r = Math.sqrt(r2)\n          // r̂ is 3D — the magnet is dz under the paper — and m lies flat.\n          const ux = dx / r, uy = dy / r, uz = dz / r\n          // Dipole: 3(m·r̂)r̂ − m. The dot product is what creates the lobes.\n          const dot = hx * ux + hy * uy\n          const f = ss[j] / (r2 * r)\n          bx += f * (3 * dot * ux - hx)\n          by += f * (3 * dot * uy - hy)\n          bz += f * 3 * dot * uz\n        }\n\n        /* Grip from |B|: a half at the half-grip, giving way at a rate set by\n           falloff — exposed because the visual reach matters more here than\n           the physical constant. With no magnet w is exactly 0. */\n        const q = Math.sqrt(bx * bx + by * by + bz * bz)\n        const w = q > 0 ? 1 / (1 + Math.pow(q, -e)) : 0\n\n        /* Where the filing settles: the field's direction along the paper\n           and the resting angle, weighted by the grip and averaged as\n           directors — each as a unit vector at twice its angle, halved back\n           after. One answer for each filing, whatever the magnet did before;\n           blending the two turns instead has a second, and a filing swept\n           into it by a passing magnet stays there. Where the paper sees no\n           field (straight down through it) only the resting angle pulls. */\n        const bp = bx * bx + by * by\n        let dc = (1 - w) * rc, ds = (1 - w) * rs\n        if (bp > 0) { dc += w * (bx * bx - by * by) / bp; ds += w * 2 * bx * by / bp }\n        const turn = dc * dc + ds * ds > 1e-12 ? arc(Math.atan2(ds, dc) / 2 - a) : 0\n        if (Math.abs(turn) > SETTLED) {\n          ang[i] = arc(a + turn * k)\n          moving = true\n        } else ang[i] = arc(a + turn)\n      }\n      return moving\n    }\n\n    const frame = (now) => {\n      raf = 0\n      // Real elapsed time, clamped so a backgrounded tab does not lurch on return.\n      const dt = last ? Math.min(1 / 30, Math.max(0, (now - last) / 1000)) : 1 / 60\n      last = now\n      let moving = step(dt)\n      // Reduced motion: no easing, just the settled field, in this one frame.\n      if (still) for (let n = 0; moving && n < 4; n++) moving = step(dt)\n      paint()\n      // Taken away and gone: stop following the pointer round the page.\n      if (away && !M.reach) follow(false)\n      if (moving && !still) raf = requestAnimationFrame(frame)\n      else last = 0\n    }\n\n    const kick = () => {\n      if (raf || !onScreen) return\n      raf = requestAnimationFrame(frame)\n    }\n\n    /* Where the magnet should be, from whatever it is following: the pointer\n       over the field, else a focused control, else the pointer that left\n       with it. Only the first two hold it; the last is taking it away. */\n    const aim = () => {\n      // A focused control removed from the page takes its focus with it, unannounced.\n      if (focused && !focused.isConnected) focused = null\n      M.goal = pointer || focused ? 1 : 0\n      if (!M.goal && !away) return\n      const b = root.getBoundingClientRect()\n      if (focused && !pointer) {\n        const f = focused.getBoundingClientRect()\n        tx = f.left + f.width / 2 - b.left\n        ty = f.top + f.height / 2 - b.top\n      } else {\n        tx = px - b.left\n        ty = py - b.top\n      }\n    }\n\n    const note = (e) => {\n      const j = (tn++ % 8) * 3\n      trail[j] = e.timeStamp; trail[j + 1] = e.clientX; trail[j + 2] = e.clientY\n    }\n    /* The stroke's speed as it ended: the distance back to the oldest sample\n       within TRAIL ms of the last, over the time between them. One event's\n       delta is noise; this is what the hand was doing. A stroke that had\n       already stopped carries nothing. */\n    const fling = () => {\n      if (tn < 2) return [0, 0]\n      const j1 = ((tn - 1) % 8) * 3\n      let j0 = -1\n      for (let k = 2; k <= Math.min(tn, 8); k++) {\n        const j = ((tn - k) % 8) * 3\n        if (trail[j1] - trail[j] > TRAIL) break\n        j0 = j\n      }\n      const s = j0 < 0 ? 0 : (trail[j1] - trail[j0]) / 1000\n      if (s < 0.008) return [0, 0]\n      const vx = (trail[j1 + 1] - trail[j0 + 1]) / s\n      const vy = (trail[j1 + 2] - trail[j0 + 2]) / s\n      const v = Math.hypot(vx, vy)\n      return v > FLING ? [vx * FLING / v, vy * FLING / v] : [vx, vy]\n    }\n\n    // Follow a pointer that has left, for as long as the magnet is still going.\n    const onAway = (e) => {\n      if (e.pointerType === 'touch') return\n      px = e.clientX; py = e.clientY\n      aim(); kick()\n    }\n    const follow = (on) => {\n      if (on === away) return\n      away = on\n      if (on) window.addEventListener('pointermove', onAway, { passive: true })\n      else window.removeEventListener('pointermove', onAway)\n    }\n\n    /* Put the magnet on its target. The pointer is the hand holding it, so it\n       is always exactly under the pointer; a move of focus is the only thing\n       it slides for, and only with a magnet on screen to slide. One coming\n       down away from where the last is still letting go is a new magnet: the\n       old one is left to let go where it is, and this one arrives from\n       nothing. */\n    const place = (slide, arrive) => {\n      if (slide && M.reach > 0.05 && !still) { M.gliding = true; return }\n      if (arrive && !still && M.reach > GONE && Math.hypot(tx - M.x, ty - M.y) > HANDOFF) {\n        if (leaving.length >= LEAVING) {\n          let f = 0\n          for (let j = 1; j < leaving.length; j++) if (leaving[j].reach < leaving[f].reach) f = j\n          leaving.splice(f, 1)\n        }\n        leaving.push({ x: M.x, y: M.y, vx: M.vx, vy: M.vy, reach: M.reach })\n        M.reach = 0\n      }\n      M.gliding = false; M.x = tx; M.y = ty; M.vx = 0; M.vy = 0\n    }\n\n    const onPointer = (e) => {\n      if (!e.isPrimary) return // a second finger does not pull the magnet between the two\n      const arriving = !pointer\n      // A new stroke's speed is its own: a quick second tap must not be\n      // measured against where the last one lifted.\n      if (arriving) tn = 0\n      note(e)\n      pointer = true\n      follow(false)\n      px = e.clientX\n      py = e.clientY\n      aim(); place(false, arriving); kick()\n    }\n\n    /* The hand lets go. A mouse or pen leaving is followed off the field; a\n       finger lifting carries the magnet on at its speed; a touch the page has\n       taken to scroll just lifts, its coordinates meaning nothing. With a\n       control focused the magnet goes back to it instead, from that speed. */\n    const release = (e) => {\n      if (!pointer || !e.isPrimary) return\n      pointer = false\n      let v = [0, 0]\n      if (e.type !== 'pointercancel') {\n        note(e); v = fling()\n        px = e.clientX; py = e.clientY\n      }\n      const hover = e.type === 'pointerleave' && e.pointerType !== 'touch'\n      if (hover && !focused && !still) follow(true)\n      aim()\n      if (!away) { M.vx = v[0]; M.vy = v[1] }\n      if (focused) place(true, true)\n      kick()\n    }\n    // A finger lifting takes the magnet with it; a mouse or pen still hovers.\n    const onUp = (e) => { if (e.pointerType === 'touch') release(e) }\n\n    const onFocusIn = (e) => {\n      focused = keyboard(e.target) ? e.target : null\n      if (!pointer) {\n        if (focused) follow(false)\n        aim(); if (focused) place(true, true); kick()\n      }\n    }\n    const onFocusOut = (e) => {\n      if (e.relatedTarget && root.contains(e.relatedTarget)) return // focusin follows\n      focused = null\n      if (!pointer) { aim(); kick() }\n    }\n\n    // The page scrolling under a still cursor moves the paper, not the hand.\n    const onScroll = () => {\n      if (!pointer && !focused && !away) return\n      aim(); if (M.goal) place(M.gliding, false); kick()\n    }\n\n    const onMotion = (e) => { still = e.matches; kick() }\n\n    const io = new IntersectionObserver(([en]) => {\n      onScreen = en.isIntersecting\n      if (onScreen) kick()\n      else {\n        if (raf) { cancelAnimationFrame(raf); raf = 0; last = 0 }\n        follow(false)\n      }\n    })\n    io.observe(root)\n\n    const ro = new ResizeObserver(() => {\n      size(); layout(); paint()\n      if (pointer || focused || away) { aim(); if (M.goal) place(M.gliding, false) }\n      kick()\n    })\n    ro.observe(root)\n\n    size(); layout(); paint()\n\n    api.current = { sync: () => { layout(); kick() } }\n\n    root.addEventListener('pointerenter', onPointer, { passive: true })\n    root.addEventListener('pointermove', onPointer, { passive: true })\n    root.addEventListener('pointerdown', onPointer, { passive: true })\n    root.addEventListener('pointerup', onUp, { passive: true })\n    root.addEventListener('pointercancel', release, { passive: true })\n    root.addEventListener('pointerleave', release, { passive: true })\n    root.addEventListener('focusin', onFocusIn)\n    root.addEventListener('focusout', onFocusOut)\n    window.addEventListener('scroll', onScroll, { passive: true, capture: true })\n    motion.addEventListener?.('change', onMotion)\n\n    return () => {\n      api.current = null\n      io.disconnect(); ro.disconnect()\n      root.removeEventListener('pointerenter', onPointer)\n      root.removeEventListener('pointermove', onPointer)\n      root.removeEventListener('pointerdown', onPointer)\n      root.removeEventListener('pointerup', onUp)\n      root.removeEventListener('pointercancel', release)\n      root.removeEventListener('pointerleave', release)\n      root.removeEventListener('focusin', onFocusIn)\n      root.removeEventListener('focusout', onFocusOut)\n      window.removeEventListener('scroll', onScroll, { capture: true })\n      motion.removeEventListener?.('change', onMotion)\n      follow(false)\n      if (raf) cancelAnimationFrame(raf)\n    }\n  }, [])\n\n  // A prop change carries on from the field on screen: a new rest angle or\n  // tilt is eased into, a new colour is painted, a new spacing is re-gridded.\n  useEffect(() => {\n    api.current?.sync()\n  }, [cell, length, thickness, moment, tilt, falloff, rest, smoothing, depth, color])\n\n  return (\n    <div\n      ref={rootRef}\n      className={`magnetic-field ${className}`.trim()}\n      style={{ background, ...style }}\n      {...rest_}\n    >\n      <canvas ref={canvasRef} className=\"magnetic-field__canvas\" aria-hidden=\"true\" />\n      {children}\n    </div>\n  )\n}\n","type":"registry:component","target":"components/magnetic-field/MagneticField.jsx"},{"path":"src/components/MagneticField/MagneticField.css","content":"/* MagneticField — filings never move, only rotate, so the grid is built once\n   and the loop writes nothing but a stroke per frame.\n\n   The root is its own stacking context so the canvas can sit under the\n   children (z-index −1) without dropping behind the root's own background:\n   an absolutely positioned canvas otherwise paints over in-flow children and\n   takes their clicks. pan-y lets a vertical drag scroll the page while a\n   sideways one steers the magnet; pinch-zoom stays with the reader. */\n\n.magnetic-field {\n  position: relative;\n  isolation: isolate;\n  width: 100%;\n  height: 100%;\n  touch-action: pan-y pinch-zoom;\n}\n.magnetic-field__canvas { position: absolute; inset: 0; z-index: -1; }\n","type":"registry:file","target":"components/magnetic-field/MagneticField.css"},{"path":"src/components/MagneticField/MagneticField.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from 'react'\n\nexport interface MagneticFieldProps extends ComponentPropsWithoutRef<'div'> {\n  /** Filing spacing, px. */\n  cell?: number\n  /** Filing length, px. */\n  length?: number\n  /** Filing stroke width, px. */\n  thickness?: number\n  /** Dipole strength. The gripped patch grows as its cube root, as a stronger magnet's would. */\n  moment?: number\n  /** Dipole axis, in radians. 0 lies along x. */\n  tilt?: number\n  /** How fast alignment gives way to the resting angle with distance. */\n  falloff?: number\n  /** Angle a filing holds when the magnet is far away, in radians. */\n  rest?: number\n  /** Share of its remaining turn a filing closes per 60 fps frame, 0..1. Applied in real elapsed time, so the feel is the same at any refresh rate. */\n  smoothing?: number\n  /** How far under the paper the magnet sits, px. Keeps the field finite under the cursor. */\n  depth?: number\n  /** Filing colour. */\n  color?: string\n  /** Background behind the filings. */\n  background?: string\n  /** Rendered above the filings as real content. A control inside that takes keyboard focus draws the magnet to it. */\n  children?: ReactNode\n}\n\ndeclare const MagneticField: (props: MagneticFieldProps) => ReactElement\nexport default MagneticField\n","type":"registry:lib","target":"components/magnetic-field/MagneticField.d.ts"}],"type":"registry:component"}