{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "success-iron-filings",
  "title": "Success Iron Filings",
  "description": "Success moment as magnetism: a field of drifting iron filings snaps to attention when the action confirms, migrating and aligning to draw the checkmark as revealed field lines, with a halo of off-path filings rotating to the local field direction.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/success-iron-filings/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// Success moment as magnetism: a field of scattered iron filings drifts\n// aimlessly until the action confirms — then a field switches on and the\n// filings migrate and ALIGN, drawing the checkmark as revealed field lines.\n// Filings near the path snap onto it; the rest stay put but rotate to the\n// local field direction, so the check emerges from the whole field, not a\n// stamped glyph. All canvas ink is read from CSS tokens at mount and on\n// theme change.\n\ntype Particle = {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  angle: number;\n  // assigned on confirm\n  tx: number;\n  ty: number;\n  tAngle: number;\n  delay: number;\n  onPath: boolean;\n  // idle drift\n  dx: number;\n  dy: number;\n  spin: number;\n  seed: number;\n};\n\nconst COUNT = 520;\n// checkmark polyline, normalized to the field box\nconst CHECK: [number, number][] = [\n  [0.3, 0.55],\n  [0.45, 0.72],\n  [0.72, 0.3],\n];\n\nfunction nearestOnCheck(\n  x: number,\n  y: number,\n  w: number,\n  h: number\n): { px: number; py: number; tangent: number; dist: number } {\n  let best = { px: 0, py: 0, tangent: 0, dist: Infinity };\n  for (let s = 0; s < CHECK.length - 1; s++) {\n    const ax = CHECK[s][0] * w;\n    const ay = CHECK[s][1] * h;\n    const bx = CHECK[s + 1][0] * w;\n    const by = CHECK[s + 1][1] * h;\n    const abx = bx - ax;\n    const aby = by - ay;\n    const len2 = abx * abx + aby * aby;\n    const t = Math.max(\n      0,\n      Math.min(1, ((x - ax) * abx + (y - ay) * aby) / len2)\n    );\n    const px = ax + abx * t;\n    const py = ay + aby * t;\n    const d = Math.hypot(x - px, y - py);\n    if (d < best.dist) {\n      best = { px, py, tangent: Math.atan2(aby, abx), dist: d };\n    }\n  }\n  return best;\n}\n\nexport function IronFilings({\n  className = \"\",\n}: {\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const [phase, setPhase] = useState<\"idle\" | \"pending\" | \"done\">(\"idle\");\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const phaseRef = useRef(phase);\n  phaseRef.current = phase;\n  const reduced = useRef(false);\n  const confirmAt = useRef(0);\n\n  useEffect(() => {\n    const field = fieldRef.current;\n    const canvas = canvasRef.current;\n    if (!field || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    reduced.current = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    let w = 0;\n    let h = 0;\n    let ink = { fg: \"#888\", muted: \"#888\" };\n    let raf = 0;\n    let last = 0;\n    let running = false;\n\n    const readInk = () => {\n      const cs = getComputedStyle(document.documentElement);\n      ink = {\n        fg: cs.getPropertyValue(\"--foreground\").trim() || \"#888\",\n        muted: cs.getPropertyValue(\"--ns-muted\").trim() || \"#888\",\n      };\n    };\n    readInk();\n\n    // deterministic-ish scatter, reseeded per mount\n    const rand = (() => {\n      let s = 1234567;\n      return () => {\n        s = (s * 1103515245 + 12345) & 0x7fffffff;\n        return s / 0x7fffffff;\n      };\n    })();\n\n    const particles: Particle[] = Array.from({ length: COUNT }, () => ({\n      x: 0,\n      y: 0,\n      vx: 0,\n      vy: 0,\n      angle: rand() * Math.PI,\n      tx: 0,\n      ty: 0,\n      tAngle: 0,\n      delay: 0,\n      onPath: false,\n      dx: (rand() - 0.5) * 5,\n      dy: (rand() - 0.5) * 5,\n      spin: (rand() - 0.5) * 0.35,\n      seed: rand(),\n    }));\n    let seeded = false;\n\n    const resize = () => {\n      const rect = field.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) return;\n      w = rect.width;\n      h = rect.height;\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.round(w * dpr);\n      canvas.height = 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      if (!seeded) {\n        for (const p of particles) {\n          p.x = rand() * w;\n          p.y = rand() * h;\n        }\n        seeded = true;\n      }\n      if (phaseRef.current === \"done\") assignTargets();\n      wake();\n    };\n\n    const assignTargets = () => {\n      // 68% land on the stroke itself, the rest align in place as halo\n      for (let i = 0; i < particles.length; i++) {\n        const p = particles[i];\n        p.onPath = p.seed < 0.68;\n        if (p.onPath) {\n          const t = (i / particles.length + p.seed * 0.13) % 1;\n          // walk the polyline by arc length\n          const l1 = Math.hypot(\n            (CHECK[1][0] - CHECK[0][0]) * w,\n            (CHECK[1][1] - CHECK[0][1]) * h\n          );\n          const l2 = Math.hypot(\n            (CHECK[2][0] - CHECK[1][0]) * w,\n            (CHECK[2][1] - CHECK[1][1]) * h\n          );\n          const d = t * (l1 + l2);\n          const seg = d < l1 ? 0 : 1;\n          const st = seg === 0 ? d / l1 : (d - l1) / l2;\n          const ax = CHECK[seg][0] * w;\n          const ay = CHECK[seg][1] * h;\n          const bx = CHECK[seg + 1][0] * w;\n          const by = CHECK[seg + 1][1] * h;\n          const tangent = Math.atan2(by - ay, bx - ax);\n          // thickness: gaussian-ish normal offset builds a soft stroke\n          const off =\n            (p.seed - 0.5 + (((p.seed * 7919) % 1) - 0.5)) * 7;\n          p.tx = ax + (bx - ax) * st + Math.cos(tangent + Math.PI / 2) * off;\n          p.ty = ay + (by - ay) * st + Math.sin(tangent + Math.PI / 2) * off;\n          p.tAngle = tangent + (p.seed - 0.5) * 0.3;\n          p.delay = t * 0.45 + p.seed * 0.15;\n        } else {\n          const near = nearestOnCheck(p.x, p.y, w, h);\n          // halo filings creep slightly toward the field, mostly rotate\n          p.tx = p.x + (near.px - p.x) * 0.12;\n          p.ty = p.y + (near.py - p.y) * 0.12;\n          p.tAngle = near.tangent;\n          p.delay = p.seed * 0.5;\n        }\n      }\n      confirmAt.current = performance.now();\n    };\n\n    const K = 130;\n    const ZETA = 0.82;\n    const draw = (dt: number, now: number) => {\n      ctx.clearRect(0, 0, w, h);\n      const done = phaseRef.current === \"done\";\n      const elapsed = (now - confirmAt.current) / 1000;\n      let settled = true;\n      for (const p of particles) {\n        if (done) {\n          if (elapsed > p.delay) {\n            const c = 2 * ZETA * Math.sqrt(K);\n            p.vx += (K * (p.tx - p.x) - c * p.vx) * dt;\n            p.vy += (K * (p.ty - p.y) - c * p.vy) * dt;\n            p.x += p.vx * dt;\n            p.y += p.vy * dt;\n            let dA = p.tAngle - p.angle;\n            while (dA > Math.PI / 2) dA -= Math.PI;\n            while (dA < -Math.PI / 2) dA += Math.PI;\n            p.angle += dA * Math.min(1, dt * 7);\n            if (\n              Math.abs(p.tx - p.x) > 0.4 ||\n              Math.abs(p.vx) > 3 ||\n              Math.abs(dA) > 0.02\n            )\n              settled = false;\n          } else {\n            // pre-delay: burst velocity coasts under drag, so a replay's\n            // outward fling is visible before the field pulls back\n            p.x += p.vx * dt;\n            p.y += p.vy * dt;\n            p.vx -= p.vx * 3 * dt;\n            p.vy -= p.vy * 3 * dt;\n            settled = false;\n          }\n        } else {\n          // aimless drift, wrapped\n          p.x += p.dx * dt;\n          p.y += p.dy * dt;\n          p.angle += p.spin * dt;\n          if (p.x < -4) p.x = w + 4;\n          if (p.x > w + 4) p.x = -4;\n          if (p.y < -4) p.y = h + 4;\n          if (p.y > h + 4) p.y = -4;\n          settled = false;\n        }\n        const len = p.onPath && done ? 3.4 : 2.8;\n        const alpha = done ? (p.onPath ? 0.95 : 0.28) : 0.4;\n        ctx.strokeStyle = done && p.onPath ? ink.fg : ink.muted;\n        ctx.globalAlpha = alpha;\n        ctx.lineWidth = 1.1;\n        ctx.beginPath();\n        ctx.moveTo(\n          p.x - Math.cos(p.angle) * len,\n          p.y - Math.sin(p.angle) * len\n        );\n        ctx.lineTo(\n          p.x + Math.cos(p.angle) * len,\n          p.y + Math.sin(p.angle) * len\n        );\n        ctx.stroke();\n      }\n      ctx.globalAlpha = 1;\n      return settled;\n    };\n\n    const loop = (t: number) => {\n      const dt = Math.min((t - (last || t)) / 1000, 1 / 30);\n      last = t;\n      const settled = draw(dt, t);\n      if (settled || (reduced.current && phaseRef.current !== \"done\")) {\n        running = false;\n        return;\n      }\n      raf = requestAnimationFrame(loop);\n    };\n    const wake = () => {\n      if (!running) {\n        running = true;\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    // reduced motion: skip migration entirely — jump to the final frame\n    const snapToFinal = () => {\n      for (const p of particles) {\n        p.x = p.tx;\n        p.y = p.ty;\n        p.angle = p.tAngle;\n        p.vx = p.vy = 0;\n        p.delay = 0;\n      }\n      confirmAt.current = -Infinity;\n      draw(0, performance.now());\n    };\n\n    const api = {\n      confirm: () => {\n        assignTargets();\n        if (reduced.current) snapToFinal();\n        else wake();\n      },\n      replay: () => {\n        // burst outward, then re-align — the celebration replays\n        for (const p of particles) {\n          p.vx += (p.seed - 0.5) * 1600;\n          p.vy += (((p.seed * 7919) % 1) - 0.5) * 1600;\n        }\n        assignTargets();\n        if (reduced.current) snapToFinal();\n        else wake();\n      },\n    };\n    (\n      field as HTMLDivElement & { __filings?: typeof api }\n    ).__filings = api;\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(field);\n    resize();\n\n    const themeObserver = new MutationObserver(() => {\n      readInk();\n      wake();\n      if (reduced.current) draw(0, performance.now());\n    });\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n    const onVis = () => {\n      if (document.hidden) {\n        cancelAnimationFrame(raf);\n        running = false;\n      } else wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n    if (!reduced.current) wake();\n    else draw(0, performance.now());\n\n    return () => {\n      ro.disconnect();\n      themeObserver.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      cancelAnimationFrame(raf);\n      delete (field as HTMLDivElement & { __filings?: typeof api })\n        .__filings;\n    };\n  }, []);\n\n  const act = (fn: \"confirm\" | \"replay\") => {\n    (\n      fieldRef.current as\n        | (HTMLDivElement & {\n            __filings?: { confirm: () => void; replay: () => void };\n          })\n        | null\n    )?.__filings?.[fn]();\n  };\n\n  const onClick = () => {\n    if (phase === \"pending\") return;\n    if (phase === \"done\") {\n      act(\"replay\");\n      return;\n    }\n    setPhase(\"pending\");\n    window.setTimeout(() => {\n      setPhase(\"done\");\n      act(\"confirm\");\n    }, 350);\n  };\n\n  return (\n    <div\n      className={`relative overflow-hidden rounded-md border border-border bg-background ${className}`}\n    >\n      {/* the field */}\n      <div ref={fieldRef} className=\"relative h-64 w-full\">\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0\"\n        />\n        <div\n          aria-live=\"polite\"\n          className=\"absolute inset-x-0 bottom-4 text-center\"\n        >\n          {phase === \"done\" && (\n            <p\n              data-filings-done=\"\"\n              className=\"font-mono text-xs uppercase tracking-[0.2em] text-foreground\"\n            >\n              Payment confirmed\n            </p>\n          )}\n          {phase === \"pending\" && (\n            <p className=\"font-mono text-xs uppercase tracking-[0.2em] text-ns-muted\">\n              Processing\n            </p>\n          )}\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-between border-t border-border px-5 py-4\">\n        <div\n          className={`transition-opacity duration-300 ${phase === \"done\" ? \"opacity-40\" : \"\"}`}\n        >\n          <p className=\"text-sm font-medium text-foreground\">Order #1284</p>\n          <p className=\"font-mono text-xs text-ns-muted\">3 items · $148.00</p>\n        </div>\n        <button\n          type=\"button\"\n          onClick={onClick}\n          disabled={phase === \"pending\"}\n          className=\"rounded-sm bg-ns-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent disabled:opacity-60 disabled:pointer-events-none\"\n        >\n          {phase === \"done\"\n            ? \"Replay\"\n            : phase === \"pending\"\n              ? \"Processing…\"\n              : \"Confirm payment\"}\n        </button>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/success-iron-filings.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-ns-accent-hover": "var(--ns-accent-hover)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "ns-accent-hover": "#0059d1"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "success",
      "celebration",
      "confirmation",
      "canvas",
      "particles",
      "checkmark"
    ],
    "instruction": "Build a payment-confirmation card whose success state is rendered as iron filings revealing a magnetic field. A Canvas 2D layer over the card's upper field region holds ~520 short line-segment filings (1.1px stroke, ~6px long). IDLE: filings drift aimlessly on per-particle velocities (a few px/s) with slow random spin, wrapped at the edges, drawn in --ns-muted at alpha 0.4 — an aimless field with no organizing force. ON CONFIRM (after a short 350 ms processing beat, button disabled meanwhile): the field switches on. 68% of filings are assigned arc-length-parameterized targets along a two-segment checkmark polyline (normalized points (0.30,0.55) (0.45,0.72) (0.72,0.30) of the field box) with a soft gaussian-ish normal offset up to ~7px so the stroke has filing-built thickness, target angle = local tangent plus jitter; the remaining 32% are HALO filings that creep only 12% toward their nearest point on the polyline and rotate to that point's tangent at low alpha 0.28 — the check emerges from the whole field aligning, not a stamped glyph. Each filing starts after a delay proportional to its arc-length parameter (plus jitter), so alignment sweeps along the stroke; position moves on an underdamped spring (k = 130 s^-2, zeta = 0.82), angle eases along the shortest half-turn (filings are unsigned — fold angle deltas into ±90°). Path filings draw in --foreground alpha 0.95, halo in --ns-muted. All ink is read from getComputedStyle CSS custom properties at mount and re-read via a MutationObserver on the documentElement class attribute so both themes render. The rAF loop is refs-only, sleeps when every filing settles under epsilon, pauses on visibilitychange, and the canvas uses a dpr-clamped(2) backing store with a zero-size guard; a 'Payment confirmed' mono label appears in an aria-live=polite region under the check, the order summary dims, and the button becomes Replay — replaying kicks every filing with a burst velocity (coasting under drag until its stagger delay elapses) and reassigns targets, so the check explodes outward and the field pulls it back together. REDUCED MOTION: idle drift is a single static frame and confirm jumps straight to the fully-aligned final frame with no migration. Colors from theme tokens only; --ns-accent appears solely on the interactive button."
  },
  "type": "registry:ui"
}