{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lodestone",
  "type": "registry:ui",
  "title": "Lodestone",
  "description": "A field of hairlines that lines up like iron filings around your call to action. The button is a bar magnet, the pointer a second pole that bends the field toward it, the headline a stone the field flows around, and a success state swings every line to north in one wave.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "background",
    "interaction",
    "canvas"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/lodestone",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/lodestone.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - One canvas and one rAF loop draw every hairline, batched by opacity level and accent step. The\n *   loop sleeps once nothing swings, fades or travels, and while the field is off screen.\n * - Every change (a hover, a new `strength`, `aligned`, the intro) reaches a hairline\n *   `distance / waveSpeed` late, so it spreads as a wave; a retarget restarts from the value at its\n *   source spot.\n * - Hairlines have no head: angles wrap to (-π/2, π/2] through `wrapAxial`, so one never spins half\n *   a turn to point the other way.\n * - Each hairline is its own spring, from the weak-field stiffness up to `stiffness` as the field\n *   strengthens; `bounce` only takes damping away.\n * - Reduced motion draws the same field still: no intro, no cursor pole, no shockwave, and every\n *   state change lands in one frame.\n */\nconst TIMING = {\n  /**\n   * A hairline's spring: stiffness in the strongest field (the `stiffness` prop) and how far it\n   * overshoots (the `bounce` prop).\n   */\n  stiffness: 320,\n  bounce: 0.5,\n  /** Spring stiffness in the weakest field at the default `stiffness`; it scales with the prop. */\n  weakStiffness: 55,\n  /** How fast a change spreads from where it started, in px/s (the `waveSpeed` prop). */\n  waveSpeed: 1150,\n  /** A target's charge or hover changing at one spot, in seconds. */\n  tween: 0.55,\n  /** Swinging to north at one spot, in seconds. */\n  alignTween: 0.75,\n  /** A filing finding its line at one spot, in seconds, and the intro's wait before it starts. */\n  intro: 0.9,\n  introDelay: 0.15,\n  /** Time constant of the cursor's pole fading in and out, in seconds. */\n  cursorFollow: 0.18,\n  /** The shockwave: px/s it travels, and the time constant it decays on, in seconds. */\n  ring: { speed: 820, decay: 0.85 },\n  /** The longest step one frame advances the springs by, in seconds (a 20 fps frame). */\n  maxFrame: 0.05,\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * LODESTONE STORYBOARD\n *\n *  intro    the first time the field scrolls into view, the\n *           hairlines lie scattered like loose filings, then\n *           snap into line in a ring that spreads out from the\n *           call to action\n *  rest     every `LodestoneTarget` is a bar magnet: its field\n *           arcs from one end of the button to the other and\n *           loops out across the backdrop, so the whole surface\n *           quietly leads back to it. Strong field: long, dark\n *           hairlines that swing fast. Weak field: short, faint\n *           ones that barely agree on a direction\n *  cursor   the pointer is a second, weaker pole. Its field\n *           bends the loops toward it and, near the button,\n *           bridges into the far end like iron filings between\n *           two magnets\n *  hover    pointer or focus on a target: the cursor's pull lets\n *           go and the target charges up. Its field takes over\n *           in a wave that starts at the button, and the accent\n *           color gathers around it\n *  press    a shockwave rolls out from the target; each hairline\n *           twitches as it passes and springs back\n *  aligned  `aligned` (a waitlist joined, a form sent) swings\n *           every hairline to north in one wave, with a ring\n *  quiet    `LodestoneQuiet` (a headline, a paragraph) is a\n *           stone in the stream: the field parts and flows\n *           along its edges, and nothing draws under the text\n *  reduced  the same field, drawn still: no intro, no cursor,\n *           states change in one frame\n * ───────────────────────────────────────────────────────── */\n\nconst FIELD = {\n  falloff: 1.5, // field of one pole ~ 1/r^falloff; a real magnet is 2, this reaches further\n  soften: 14, // px, keeps the field finite right on a pole\n  hoverBoost: 1.5, // a hovered target's charge, on top of 1\n  jitter: 0.55, // rad, how much hairlines in a weak field disagree\n  minLength: 0.22, // a hairline in no field is this share of `length`\n  alpha: [0.06, 0.6], // opacity from the weakest to the strongest field\n  accentReach: 1.2, // how far the accent gathers around a target, relative to its field\n  ring: { width: 46, kick: 34 }, // shockwave: px, rad/s²\n  marks: 6, // a static + every this many cells\n  markAlpha: 0.2,\n  fadeCells: 3, // edge fade, in cells\n  levels: 14, // opacity steps hairlines are batched into\n  accents: 4, // accent mix steps\n  maxDpr: 2,\n} as const;\n\ntype Tween = { from: number; to: number; t0: number; dur: number; ox: number; oy: number };\ntype Poles = \"ends\" | \"center\";\n\ntype Target = {\n  el: HTMLElement;\n  poles: Poles;\n  strength: Tween;\n  hover: Tween;\n  hovered: boolean;\n  // per frame, relative to the root\n  x0: number;\n  y0: number;\n  x1: number;\n  y1: number;\n};\n\ntype Options = {\n  spacing: number;\n  length: number;\n  thickness: number;\n  reach: number;\n  cursor: number;\n  stiffness: number;\n  bounce: number;\n  waveSpeed: number;\n  intro: boolean;\n  marks: boolean;\n  fade: boolean;\n  reduced: boolean;\n};\n\nconst clamp01 = (t: number) => Math.min(1, Math.max(0, t));\nconst smoothstep = (a: number, b: number, x: number) => {\n  const t = clamp01((x - a) / (b - a));\n  return t * t * (3 - 2 * t);\n};\nconst easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);\n/** A hairline has no head, so angles that differ by π are the same: wraps to (-π/2, π/2]. */\nconst wrapAxial = (a: number) => a - Math.PI * Math.round(a / Math.PI);\nconst NORTH = Math.PI / 2;\n\nfunction still(value: number): Tween {\n  return { from: value, to: value, t0: 0, dur: 0, ox: 0, oy: 0 };\n}\n\n/** The tween's value at a point `d` px from where it started: the change reaches it `d / waveSpeed` s late. */\nfunction tweenAt(tw: Tween, t: number, d: number, waveSpeed: number) {\n  if (tw.from === tw.to) return tw.to;\n  const p = (t - tw.t0 - d / waveSpeed) / tw.dur;\n  if (p <= 0) return tw.from;\n  if (p >= 1) return tw.to;\n  return tw.from + (tw.to - tw.from) * easeInOut(p);\n}\n\nfunction tweenDone(tw: Tween, t: number, maxDistance: number, waveSpeed: number) {\n  return tw.from === tw.to || t > tw.t0 + tw.dur + maxDistance / waveSpeed;\n}\n\n/** Signed distance from a point to a rounded box, and the outward normal there. */\nfunction boxDistance(\n  px: number,\n  py: number,\n  x0: number,\n  y0: number,\n  x1: number,\n  y1: number,\n  radius: number,\n  out: { d: number; nx: number; ny: number },\n) {\n  const cx = (x0 + x1) / 2;\n  const cy = (y0 + y1) / 2;\n  const sx = px < cx ? -1 : 1;\n  const sy = py < cy ? -1 : 1;\n  const qx = Math.abs(px - cx) - ((x1 - x0) / 2 - radius);\n  const qy = Math.abs(py - cy) - ((y1 - y0) / 2 - radius);\n  if (qx > 0 && qy > 0) {\n    const len = Math.hypot(qx, qy);\n    out.d = len - radius;\n    out.nx = (sx * qx) / len;\n    out.ny = (sy * qy) / len;\n  } else if (qx > qy) {\n    out.d = qx - radius;\n    out.nx = sx;\n    out.ny = 0;\n  } else {\n    out.d = qy - radius;\n    out.nx = 0;\n    out.ny = sy;\n  }\n  return out;\n}\n\nfunction createField() {\n  let root: HTMLElement | null = null;\n  let canvas: HTMLCanvasElement | null = null;\n  let ctx: CanvasRenderingContext2D | null = null;\n  let probes: { ink: HTMLElement; accent: HTMLElement } | null = null;\n  let colors: string[] = [];\n\n  let options: Options = {\n    spacing: 22,\n    length: 0.6,\n    thickness: 1,\n    reach: 1,\n    cursor: 0.8,\n    stiffness: TIMING.stiffness,\n    bounce: TIMING.bounce,\n    waveSpeed: TIMING.waveSpeed,\n    intro: true,\n    marks: true,\n    fade: true,\n    reduced: false,\n  };\n\n  const targets = new Map<string, Target>();\n  const quiet = new Map<string, HTMLElement>();\n\n  // Hairlines, rebuilt when the root changes size.\n  let count = 0;\n  let px = new Float32Array(0);\n  let py = new Float32Array(0);\n  let theta = new Float32Array(0);\n  let omega = new Float32Array(0);\n  let scatter = new Float32Array(0); // a filing's angle before the intro\n  let jitter = new Float32Array(0); // -1…1, how this filing disagrees in a weak field\n  let marks: number[] = [];\n  let width = 0;\n  let height = 0;\n  let dpr = 1;\n  let settled = false; // hairlines have been placed on their field once\n\n  // Draw batches: one Float32Array of segments per opacity level × accent step.\n  let batches: Float32Array[] = [];\n  let batchCounts: Int32Array = new Int32Array(0);\n\n  let aligned = still(0);\n  let intro = still(1);\n  let introStarted = false;\n  const rings: { t0: number; x: number; y: number; amp: number }[] = [];\n  const cursor = { x: 0, y: 0, inside: false, charge: 0 };\n\n  let raf = 0;\n  let last = 0;\n  let visible = false;\n  const cleanups: (() => void)[] = [];\n\n  const now = () => performance.now() / 1000;\n\n  function readColors() {\n    if (!probes) return;\n    const ink = getComputedStyle(probes.ink).color;\n    const accent = getComputedStyle(probes.accent).color;\n    colors = [];\n    for (let i = 0; i < FIELD.accents; i++) {\n      const share = Math.round((i / (FIELD.accents - 1)) * 100);\n      probes.ink.style.color = `color-mix(in oklab, ${ink}, ${accent} ${share}%)`;\n      colors.push(getComputedStyle(probes.ink).color);\n    }\n    probes.ink.style.color = \"\";\n  }\n\n  function layout() {\n    if (!root || !canvas) return;\n    const w = root.clientWidth;\n    const h = root.clientHeight;\n    dpr = Math.min(window.devicePixelRatio || 1, FIELD.maxDpr);\n    if (w === width && h === height && canvas.width === Math.round(w * dpr)) return;\n    width = w;\n    height = h;\n    canvas.width = Math.round(w * dpr);\n    canvas.height = Math.round(h * dpr);\n\n    const s = options.spacing;\n    const cols = Math.max(1, Math.floor(w / s));\n    const rows = Math.max(1, Math.floor(h / s));\n    const ox = (w - (cols - 1) * s) / 2;\n    const oy = (h - (rows - 1) * s) / 2;\n    count = cols * rows;\n    px = new Float32Array(count);\n    py = new Float32Array(count);\n    const oldTheta = theta;\n    theta = new Float32Array(count);\n    omega = new Float32Array(count);\n    scatter = new Float32Array(count);\n    jitter = new Float32Array(count);\n    // Seeded, so the filings scatter the same way on every render.\n    let seed = 1234567;\n    const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);\n    for (let r = 0, i = 0; r < rows; r++) {\n      for (let c = 0; c < cols; c++, i++) {\n        px[i] = ox + c * s;\n        py[i] = oy + r * s;\n        scatter[i] = (random() - 0.5) * Math.PI;\n        jitter[i] = random() * 2 - 1;\n        theta[i] = oldTheta.length === count ? oldTheta[i] : scatter[i];\n      }\n    }\n    marks = [];\n    for (let r = 0; r < rows - 1; r++) {\n      for (let c = 0; c < cols - 1; c++) {\n        if ((r + 3) % FIELD.marks === 0 && (c + 3) % FIELD.marks === 0) {\n          marks.push(ox + (c + 0.5) * s, oy + (r + 0.5) * s);\n        }\n      }\n    }\n    batches = Array.from({ length: FIELD.levels * FIELD.accents }, () => new Float32Array(count * 4));\n    batchCounts = new Int32Array(FIELD.levels * FIELD.accents);\n    if (oldTheta.length !== count) settled = false;\n    wake();\n  }\n\n  function readRects() {\n    if (!root) return { quietRects: [] as number[][] };\n    const base = root.getBoundingClientRect();\n    for (const target of targets.values()) {\n      const r = target.el.getBoundingClientRect();\n      target.x0 = r.left - base.left;\n      target.y0 = r.top - base.top;\n      target.x1 = r.right - base.left;\n      target.y1 = r.bottom - base.top;\n    }\n    const quietRects: number[][] = [];\n    for (const el of quiet.values()) {\n      const r = el.getBoundingClientRect();\n      if (r.width === 0 && r.height === 0) continue;\n      quietRects.push([r.left - base.left, r.top - base.top, r.right - base.left, r.bottom - base.top]);\n    }\n    return { quietRects };\n  }\n\n  /** Where waves start: the strongest target, else the middle. */\n  function origin(): [number, number] {\n    let best: Target | null = null;\n    for (const target of targets.values()) {\n      if (!best || target.strength.to > best.strength.to) best = target;\n    }\n    if (!best) return [width / 2, height / 2];\n    return [(best.x0 + best.x1) / 2, (best.y0 + best.y1) / 2];\n  }\n\n  function retarget(tw: Tween, to: number, dur: number, ox: number, oy: number): Tween {\n    if (tw.to === to) return tw;\n    const t = now();\n    if (options.reduced) return still(to);\n    // Restart from wherever the source spot is right now.\n    return { from: tweenAt(tw, t, Math.hypot(ox - tw.ox, oy - tw.oy), options.waveSpeed), to, t0: t, dur, ox, oy };\n  }\n\n  const box = { d: 0, nx: 0, ny: 0 };\n\n  function frame() {\n    raf = 0;\n    if (!ctx || !canvas || !root) return;\n    const t = now();\n    const dt = Math.min(last ? t - last : 1 / 60, TIMING.maxFrame);\n    last = t;\n\n    const { quietRects } = readRects();\n    const { spacing, length, thickness, reach, waveSpeed, reduced } = options;\n    const maxDistance = Math.hypot(width, height);\n\n    // The cursor's pole: fades in while the pointer is over the field and no target is hovered.\n    let anyHover = false;\n    for (const target of targets.values()) anyHover ||= target.hovered;\n    const cursorGoal = cursor.inside && !anyHover && !reduced ? options.cursor : 0;\n    cursor.charge = reduced\n      ? cursorGoal\n      : cursor.charge + (cursorGoal - cursor.charge) * (1 - Math.exp(-dt / TIMING.cursorFollow));\n    if (Math.abs(cursor.charge - cursorGoal) < 0.002) cursor.charge = cursorGoal;\n\n    // Poles for this frame. Each target's charge is read per hairline, since changes travel as a wave.\n    const list = [...targets.values()];\n    const L = Math.min(Math.max(0.32 * Math.min(width, height), 110), 280) * reach;\n    const scale = Math.pow(L, FIELD.falloff);\n    const power = (FIELD.falloff + 1) / 2;\n    const soft2 = FIELD.soften * FIELD.soften;\n\n    for (let i = rings.length - 1; i >= 0; i--) {\n      if (t - rings[i].t0 > TIMING.ring.decay * 4) rings.splice(i, 1);\n    }\n\n    batchCounts.fill(0);\n    let moving = false;\n    const fadePx = spacing * FIELD.fadeCells;\n    const stiffMax = options.stiffness;\n    const stiffMin = stiffMax * (TIMING.weakStiffness / TIMING.stiffness);\n    const [alphaMin, alphaMax] = FIELD.alpha;\n\n    for (let i = 0; i < count; i++) {\n      const x = px[i];\n      const y = py[i];\n\n      // Edge fade and quiet zones (headlines, and the targets themselves) decide visibility.\n      let visibility = options.fade\n        ? smoothstep(0, fadePx, Math.min(x, y, width - x, height - y) + spacing * 0.5)\n        : 1;\n\n      let bx = 0;\n      let by = 0;\n      let tx = 0;\n      let ty = 0;\n      let hoverMax = 0;\n      for (const target of list) {\n        const cx = (target.x0 + target.x1) / 2;\n        const cy = (target.y0 + target.y1) / 2;\n        const strength = tweenAt(\n          target.strength,\n          t,\n          Math.hypot(x - target.strength.ox, y - target.strength.oy),\n          waveSpeed,\n        );\n        const hover = tweenAt(target.hover, t, Math.hypot(x - target.hover.ox, y - target.hover.oy), waveSpeed);\n        hoverMax = Math.max(hoverMax, hover);\n        const q = strength * (1 + FIELD.hoverBoost * hover);\n        if (q === 0) continue;\n        if (target.poles === \"ends\") {\n          const h = target.y1 - target.y0;\n          const inset = Math.min(h / 2, (target.x1 - target.x0) / 4);\n          // North pole on the left end, south on the right: the field runs from one to the other.\n          for (const [ax, sign] of [\n            [target.x0 + inset, 1],\n            [target.x1 - inset, -1],\n          ] as const) {\n            const dx = x - ax;\n            const dy = y - cy;\n            const f = (sign * q * scale) / Math.pow(dx * dx + dy * dy + soft2, power);\n            tx += dx * f;\n            ty += dy * f;\n          }\n        } else {\n          const dx = x - cx;\n          const dy = y - cy;\n          const f = (-q * scale) / Math.pow(dx * dx + dy * dy + soft2, power);\n          tx += dx * f;\n          ty += dy * f;\n        }\n        boxDistance(x, y, target.x0, target.y0, target.x1, target.y1, Math.min(8, (target.y1 - target.y0) / 2), box);\n        visibility *= smoothstep(spacing * 0.15, spacing * 0.75, box.d);\n      }\n      bx = tx;\n      by = ty;\n      if (cursor.charge > 0) {\n        const dx = x - cursor.x;\n        const dy = y - cursor.y;\n        const f = (cursor.charge * scale) / Math.pow(dx * dx + dy * dy + soft2, power);\n        bx += dx * f;\n        by += dy * f;\n      }\n\n      for (const r of quietRects) {\n        // A rounded, pebble-like outline, so the clearing never reads as a box.\n        const pad = spacing * 0.5;\n        const round = Math.min(r[2] - r[0], r[3] - r[1]) * 0.42 + pad;\n        boxDistance(x, y, r[0] - pad, r[1] - pad, r[2] + pad, r[3] + pad, round, box);\n        if (box.d < spacing * 3) {\n          // Part around the stone: take away the part of the field that runs into it.\n          const along = bx * box.nx + by * box.ny;\n          const bend = 1 - smoothstep(0, spacing * 3, box.d);\n          bx -= box.nx * along * bend;\n          by -= box.ny * along * bend;\n        }\n        visibility *= smoothstep(0, spacing * 1.6, box.d);\n      }\n\n      const magnitude = Math.hypot(bx, by);\n      const strengthHere = clamp01((Math.log2(magnitude + 1e-6) + 3.5) / 5);\n      const targetHere = clamp01((Math.log2(Math.hypot(tx, ty) + 1e-6) + FIELD.accentReach) / 3.2);\n\n      const a = tweenAt(aligned, t, Math.hypot(x - aligned.ox, y - aligned.oy), waveSpeed);\n      const introAt = tweenAt(intro, t, Math.hypot(x - intro.ox, y - intro.oy), waveSpeed);\n\n      let goal = magnitude > 1e-6 ? Math.atan2(by, bx) : theta[i];\n      goal += jitter[i] * FIELD.jitter * (1 - strengthHere) * (1 - strengthHere) * (1 - a);\n      if (introAt < 1) goal = scatter[i] + wrapAxial(goal - scatter[i]) * introAt;\n      if (a > 0) goal += wrapAxial(NORTH - goal) * a;\n      const s = strengthHere + (0.72 - strengthHere) * a;\n\n      // The shockwave: a ring that passes once, twitching hairlines as it goes.\n      let pulse = 0;\n      for (const ring of rings) {\n        const age = t - ring.t0;\n        const d = Math.hypot(x - ring.x, y - ring.y) - age * TIMING.ring.speed;\n        pulse += ring.amp * Math.exp(-(d * d) / (FIELD.ring.width * FIELD.ring.width)) * Math.exp(-age / TIMING.ring.decay);\n      }\n\n      if (reduced || (!settled && !options.intro)) {\n        theta[i] = goal;\n        omega[i] = 0;\n      } else {\n        const k = stiffMin + (stiffMax - stiffMin) * s;\n        const c = 2 * Math.sqrt(k) * (1 - options.bounce);\n        const delta = wrapAxial(goal - theta[i]);\n        omega[i] += (k * delta - c * omega[i] + pulse * FIELD.ring.kick) * dt;\n        theta[i] = wrapAxial(theta[i] + omega[i] * dt);\n        if (Math.abs(omega[i]) > 0.004 || Math.abs(delta) > 0.004) moving = true;\n      }\n\n      if (visibility <= 0.01) continue;\n      const alpha = clamp01((alphaMin + (alphaMax - alphaMin) * s + 0.35 * pulse) * visibility);\n      const level = Math.round(alpha * (FIELD.levels - 1));\n      if (level === 0) continue;\n      const accent = clamp01(Math.max(targetHere * (0.3 + 0.7 * hoverMax) * (1 - a * 0.5), pulse * 0.8));\n      const shade = Math.round(accent * (FIELD.accents - 1));\n      const half = ((length * spacing) / 2) * (FIELD.minLength + (1 - FIELD.minLength) * s) * (1 + 0.5 * pulse);\n      const dx = Math.cos(theta[i]) * half;\n      const dy = Math.sin(theta[i]) * half;\n      const b = level * FIELD.accents + shade;\n      const n = batchCounts[b]++ * 4;\n      const seg = batches[b];\n      seg[n] = x - dx;\n      seg[n + 1] = y - dy;\n      seg[n + 2] = x + dx;\n      seg[n + 3] = y + dy;\n    }\n    settled = true;\n\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    ctx.clearRect(0, 0, width, height);\n    ctx.lineCap = \"round\";\n    ctx.lineWidth = thickness;\n    for (let level = 1; level < FIELD.levels; level++) {\n      for (let shade = 0; shade < FIELD.accents; shade++) {\n        const b = level * FIELD.accents + shade;\n        const n = batchCounts[b];\n        if (!n) continue;\n        const seg = batches[b];\n        ctx.globalAlpha = level / (FIELD.levels - 1);\n        ctx.strokeStyle = colors[shade] ?? colors[0] ?? \"currentColor\";\n        ctx.beginPath();\n        for (let j = 0; j < n * 4; j += 4) {\n          ctx.moveTo(seg[j], seg[j + 1]);\n          ctx.lineTo(seg[j + 2], seg[j + 3]);\n        }\n        ctx.stroke();\n      }\n    }\n\n    if (options.marks && marks.length) {\n      ctx.strokeStyle = colors[0] ?? \"currentColor\";\n      ctx.lineWidth = thickness * 0.75;\n      const arm = 2.5;\n      ctx.beginPath();\n      let drawn = false;\n      for (let j = 0; j < marks.length; j += 2) {\n        const x = marks[j];\n        const y = marks[j + 1];\n        let visibility = options.fade ? smoothstep(0, fadePx, Math.min(x, y, width - x, height - y)) : 1;\n        for (const r of quietRects) {\n          boxDistance(x, y, r[0], r[1], r[2], r[3], 0, box);\n          visibility *= smoothstep(0, spacing, box.d);\n        }\n        for (const target of list) {\n          boxDistance(x, y, target.x0, target.y0, target.x1, target.y1, 0, box);\n          visibility *= smoothstep(0, spacing, box.d);\n        }\n        if (visibility < 0.5) continue;\n        ctx.moveTo(x - arm, y);\n        ctx.lineTo(x + arm, y);\n        ctx.moveTo(x, y - arm);\n        ctx.lineTo(x, y + arm);\n        drawn = true;\n      }\n      if (drawn) {\n        ctx.globalAlpha = FIELD.markAlpha;\n        ctx.stroke();\n      }\n    }\n    ctx.globalAlpha = 1;\n\n    let busy = moving || rings.length > 0 || cursor.charge !== cursorGoal;\n    busy ||= !tweenDone(aligned, t, maxDistance, waveSpeed) || !tweenDone(intro, t, maxDistance, waveSpeed);\n    for (const target of list) {\n      busy ||=\n        !tweenDone(target.strength, t, maxDistance, waveSpeed) || !tweenDone(target.hover, t, maxDistance, waveSpeed);\n    }\n    if (busy && visible) raf = requestAnimationFrame(frame);\n    else last = 0;\n  }\n\n  function wake() {\n    if (raf || !visible || !ctx) return;\n    raf = requestAnimationFrame(frame);\n  }\n\n  function startIntro() {\n    if (introStarted) return;\n    introStarted = true;\n    if (!options.intro || options.reduced) {\n      intro = still(1);\n      return;\n    }\n    readRects();\n    const [ox, oy] = origin();\n    intro = { from: 0, to: 1, t0: now() + TIMING.introDelay, dur: TIMING.intro, ox, oy };\n  }\n\n  return {\n    connect(el: { root: HTMLElement; canvas: HTMLCanvasElement; ink: HTMLElement; accent: HTMLElement }) {\n      root = el.root;\n      canvas = el.canvas;\n      ctx = canvas.getContext(\"2d\");\n      probes = { ink: el.ink, accent: el.accent };\n      readColors();\n      width = 0;\n      layout();\n\n      const resize = new ResizeObserver(() => {\n        layout();\n        wake();\n      });\n      resize.observe(root);\n      const io = new IntersectionObserver(([entry]) => {\n        visible = entry.isIntersecting;\n        if (visible) {\n          startIntro();\n          wake();\n        }\n      });\n      io.observe(root);\n\n      const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n      const recolor = () => {\n        readColors();\n        wake();\n      };\n      scheme.addEventListener(\"change\", recolor);\n      const themeAttrs = new MutationObserver(recolor);\n      themeAttrs.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"data-theme\", \"style\"] });\n\n      const move = (e: PointerEvent) => {\n        if (!root) return;\n        if (e.pointerType === \"touch\" && e.type === \"pointermove\" && !cursor.inside) return;\n        const r = root.getBoundingClientRect();\n        cursor.x = e.clientX - r.left;\n        cursor.y = e.clientY - r.top;\n        cursor.inside = true;\n        wake();\n      };\n      const leave = () => {\n        cursor.inside = false;\n        wake();\n      };\n      root.addEventListener(\"pointermove\", move);\n      root.addEventListener(\"pointerdown\", move);\n      root.addEventListener(\"pointerleave\", leave);\n      root.addEventListener(\"pointercancel\", leave);\n      const touchUp = (e: PointerEvent) => {\n        if (e.pointerType === \"touch\") leave();\n      };\n      root.addEventListener(\"pointerup\", touchUp);\n\n      cleanups.push(\n        () => resize.disconnect(),\n        () => io.disconnect(),\n        () => scheme.removeEventListener(\"change\", recolor),\n        () => themeAttrs.disconnect(),\n        () => {\n          root?.removeEventListener(\"pointermove\", move);\n          root?.removeEventListener(\"pointerdown\", move);\n          root?.removeEventListener(\"pointerleave\", leave);\n          root?.removeEventListener(\"pointercancel\", leave);\n          root?.removeEventListener(\"pointerup\", touchUp);\n        },\n      );\n      return () => {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        cleanups.splice(0).forEach((fn) => fn());\n        root = canvas = null;\n        ctx = null;\n        probes = null;\n      };\n    },\n\n    setOptions(next: Options) {\n      const relayout = next.spacing !== options.spacing;\n      options = next;\n      if (next.reduced) {\n        intro = still(1);\n        rings.length = 0;\n      }\n      if (relayout) width = 0;\n      layout();\n      wake();\n    },\n\n    setAligned(value: boolean) {\n      readRects();\n      const [ox, oy] = origin();\n      const goal = value ? 1 : 0;\n      if (aligned.to === goal) return;\n      aligned = retarget(aligned, goal, TIMING.alignTween, ox, oy);\n      if (value && !options.reduced && introStarted) rings.push({ t0: now(), x: ox, y: oy, amp: 1 });\n      wake();\n    },\n\n    addTarget(id: string, el: HTMLElement, poles: Poles, strength: number) {\n      const existing = targets.get(id);\n      if (existing) existing.el = el;\n      else {\n        targets.set(id, {\n          el,\n          poles,\n          strength: still(strength),\n          hover: still(0),\n          hovered: false,\n          x0: 0,\n          y0: 0,\n          x1: 0,\n          y1: 0,\n        });\n      }\n      wake();\n      return () => {\n        targets.delete(id);\n        wake();\n      };\n    },\n\n    updateTarget(id: string, poles: Poles, strength: number) {\n      const target = targets.get(id);\n      if (!target) return;\n      target.poles = poles;\n      if (target.strength.to !== strength) {\n        readRects();\n        const ox = (target.x0 + target.x1) / 2;\n        const oy = (target.y0 + target.y1) / 2;\n        target.strength = retarget(target.strength, strength, TIMING.tween, ox, oy);\n      }\n      wake();\n    },\n\n    hoverTarget(id: string, hovered: boolean) {\n      const target = targets.get(id);\n      if (!target || target.hovered === hovered) return;\n      target.hovered = hovered;\n      readRects();\n      const ox = (target.x0 + target.x1) / 2;\n      const oy = (target.y0 + target.y1) / 2;\n      target.hover = retarget(target.hover, hovered ? 1 : 0, TIMING.tween, ox, oy);\n      wake();\n    },\n\n    pulse(id: string) {\n      const target = targets.get(id);\n      if (!target || options.reduced) return;\n      readRects();\n      rings.push({ t0: now(), x: (target.x0 + target.x1) / 2, y: (target.y0 + target.y1) / 2, amp: 0.7 });\n      wake();\n    },\n\n    addQuiet(id: string, el: HTMLElement) {\n      quiet.set(id, el);\n      wake();\n      return () => {\n        quiet.delete(id);\n        wake();\n      };\n    },\n  };\n}\n\ntype Field = ReturnType<typeof createField>;\n\nconst LodestoneContext = React.createContext<Field | null>(null);\n\nexport interface LodestoneProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Distance between hairlines, in px. */\n  spacing?: number;\n  /** Length of a hairline in a strong field, as a share of `spacing`. */\n  length?: number;\n  /** Line width, in px. */\n  thickness?: number;\n  /** How far the targets' fields carry. `2` reaches about twice as far. */\n  reach?: number;\n  /** Strength of the pointer's own pole, relative to a target. `0` turns it off. */\n  cursor?: number;\n  /** Spring stiffness of a hairline in the strongest field; in a weak field it's about a sixth of this. Higher swings faster. */\n  stiffness?: number;\n  /** How far a hairline overshoots before it settles, from `0` (none) toward `1` (wobbles a long time). */\n  bounce?: number;\n  /** How fast a change (a hover, a new `strength`, `aligned`, the intro) spreads from where it started, in px/s. */\n  waveSpeed?: number;\n  /** Swing every hairline to north in one wave from the target, for a success state. */\n  aligned?: boolean;\n  /** Scatter the hairlines and let them snap into line the first time the field scrolls into view. */\n  intro?: boolean;\n  /** Draw a faint static + every few cells, like engineering paper. */\n  marks?: boolean;\n  /** Fade the field out toward the edges. */\n  fade?: boolean;\n  /** Classes that set the hairlines' color (a `text-*` color). */\n  fieldClassName?: string;\n  /** Classes that set the accent near a hovered target (a `text-*` color). */\n  accentClassName?: string;\n  /** Classes for the canvas, e.g. an opacity. */\n  canvasClassName?: string;\n}\n\nexport function Lodestone({\n  spacing = 24,\n  length = 0.6,\n  thickness = 1,\n  reach = 1.3,\n  cursor = 0.8,\n  stiffness = TIMING.stiffness,\n  bounce = TIMING.bounce,\n  waveSpeed = TIMING.waveSpeed,\n  aligned = false,\n  intro = true,\n  marks = true,\n  fade = true,\n  fieldClassName,\n  accentClassName,\n  canvasClassName,\n  className,\n  children,\n  ...props\n}: LodestoneProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const inkRef = React.useRef<HTMLSpanElement>(null);\n  const accentRef = React.useRef<HTMLSpanElement>(null);\n  const [field] = React.useState(createField);\n  const reduced = useReducedMotion() ?? false;\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const ink = inkRef.current;\n    const accent = accentRef.current;\n    if (!root || !canvas || !ink || !accent) return;\n    return field.connect({ root, canvas, ink, accent });\n  }, [field]);\n\n  React.useEffect(() => {\n    field.setOptions({\n      spacing,\n      length,\n      thickness,\n      reach,\n      cursor,\n      stiffness,\n      bounce,\n      waveSpeed,\n      intro,\n      marks,\n      fade,\n      reduced,\n    });\n  }, [field, spacing, length, thickness, reach, cursor, stiffness, bounce, waveSpeed, intro, marks, fade, reduced]);\n\n  React.useEffect(() => {\n    field.setAligned(aligned);\n  }, [field, aligned]);\n\n  return (\n    <LodestoneContext.Provider value={field}>\n      <div ref={rootRef} className={cn(\"relative isolate overflow-hidden\", className)} {...props}>\n        <canvas\n          ref={canvasRef}\n          aria-hidden\n          className={cn(\"pointer-events-none absolute inset-0 -z-10 size-full\", canvasClassName)}\n        />\n        {/* Probes the canvas reads its colors from. Defaults apply only when no class is given, so a `dark:` default can't win over yours. */}\n        <span ref={inkRef} aria-hidden className={cn(\"hidden\", fieldClassName ?? \"text-zinc-900 dark:text-zinc-100\")} />\n        <span ref={accentRef} aria-hidden className={cn(\"hidden\", accentClassName ?? \"text-indigo-500 dark:text-indigo-400\")} />\n        {children}\n      </div>\n    </LodestoneContext.Provider>\n  );\n}\n\nexport interface LodestoneTargetProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** How strongly this target pulls the field. Change it to hand the field from one target to another. */\n  strength?: number;\n  /** `ends`: a bar magnet whose field arcs from one end to the other. `center`: one pole the field points into. */\n  poles?: Poles;\n}\n\nexport function LodestoneTarget({\n  strength = 1,\n  poles = \"ends\",\n  className,\n  onPointerEnter,\n  onPointerLeave,\n  onPointerDown,\n  onFocus,\n  onBlur,\n  ...props\n}: LodestoneTargetProps) {\n  const field = React.useContext(LodestoneContext);\n  const ref = React.useRef<HTMLDivElement>(null);\n  const id = React.useId();\n  const initial = React.useRef({ poles, strength });\n\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!field || !el) return;\n    return field.addTarget(id, el, initial.current.poles, initial.current.strength);\n  }, [field, id]);\n\n  React.useEffect(() => {\n    field?.updateTarget(id, poles, strength);\n  }, [field, id, poles, strength]);\n\n  return (\n    <div\n      ref={ref}\n      className={cn(\"inline-flex\", className)}\n      onPointerEnter={(e) => {\n        if (e.pointerType !== \"touch\") field?.hoverTarget(id, true);\n        onPointerEnter?.(e);\n      }}\n      onPointerLeave={(e) => {\n        field?.hoverTarget(id, false);\n        onPointerLeave?.(e);\n      }}\n      onPointerDown={(e) => {\n        field?.pulse(id);\n        onPointerDown?.(e);\n      }}\n      onFocus={(e) => {\n        if (e.target.matches(\":focus-visible\")) field?.hoverTarget(id, true);\n        onFocus?.(e);\n      }}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) field?.hoverTarget(id, false);\n        onBlur?.(e);\n      }}\n      {...props}\n    />\n  );\n}\n\nexport type LodestoneQuietProps = React.HTMLAttributes<HTMLDivElement>;\n\n/** Content the field flows around, like water around a stone. Nothing draws under it. */\nexport function LodestoneQuiet({ className, ...props }: LodestoneQuietProps) {\n  const field = React.useContext(LodestoneContext);\n  const ref = React.useRef<HTMLDivElement>(null);\n  const id = React.useId();\n\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!field || !el) return;\n    return field.addQuiet(id, el);\n  }, [field, id]);\n\n  return <div ref={ref} className={className} {...props} />;\n}\n"
    }
  ]
}