{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "magnetic-letters",
  "title": "Magnetic letters",
  "description": "Magnetic letters is a production-ready text component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/text/magnetic-letters.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useSyncExternalStore,\n  type FocusEvent,\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\nimport { useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { BJORK_PALETTE, type BjorkTone } from \"../_core/palette\";\nimport { useBjorkTone } from \"../_core/tone\";\nimport { useVisibleLoop } from \"../_core/loop\";\nimport { mulberry32 } from \"../_core/random\";\nimport { stepSpring, type SpringConfig, type SpringState } from \"../_core/spring\";\n\nexport type MagneticMode = \"attract\" | \"repel\";\n\nexport interface MagneticLettersProps {\n  text?: string;\n  /** Letters lean toward the cursor, or shy away from it. */\n  mode?: MagneticMode;\n  /** Scales how far letters travel. 1 is the tuned default. */\n  strength?: number;\n  /** Reach of the field as a gaussian sigma, in em. */\n  radius?: number;\n  /** Spring stiffness for the snap back. */\n  stiffness?: number;\n  /** Spring damping. Lower overshoots more. */\n  damping?: number;\n  /** Click (or Enter / Space) scatters the letters and lets them reassemble. */\n  shockwave?: boolean;\n  tone?: BjorkTone;\n  color?: string;\n  /** Tint for letters deep in the field, and the shockwave ring. */\n  accentColor?: string;\n  /** Upper bound in px. The type also shrinks to fit its container. */\n  fontSize?: number;\n  fontFamily?: string;\n  fontWeight?: number;\n  /** Letter spacing in em. */\n  tracking?: number;\n  align?: \"left\" | \"center\";\n  /**\n   * Fixed pointer position as fractions of the box (0..1). Renders a still, displaced frame\n   * with no loop — for previews and screenshots.\n   */\n  pointer?: { x: number; y: number };\n  className?: string;\n  ariaLabel?: string;\n}\n\ninterface Glyph {\n  cx: number;\n  cy: number;\n  x: SpringState;\n  y: SpringState;\n  r: SpringState;\n  s: SpringState;\n  /** Writes are skipped when nothing visible changed. */\n  key: string;\n  tint: number;\n  kickAt: number;\n  seed: number;\n}\n\ninterface Ring {\n  x: number;\n  y: number;\n  age: number;\n}\n\nconst RING_LIFE = 0.9;\nconst subscribeNoop = () => () => {};\nconst clampAbs = (v: number, m: number) => (v > m ? m : v < -m ? -m : v);\n\ninterface FieldParams {\n  mode: MagneticMode;\n  strength: number;\n  radius: number;\n}\n\n/** Field targets for one glyph given the pointer. Also sets the glyph's tint. */\nfunction fieldTarget(g: Glyph, px: number, py: number, presence: number, L: FieldParams, em: number) {\n  const sigma = Math.max(8, L.radius * em);\n  const dx = px - g.cx;\n  const dy = py - g.cy;\n  const dist = Math.hypot(dx, dy) || 1;\n  const f = Math.exp((-0.5 * dist * dist) / (sigma * sigma)) * presence;\n  let tx: number;\n  let ty: number;\n  if (L.mode === \"attract\") {\n    // Lean toward the cursor, never past it. Sideways pull is kept well under half a letter so\n    // neighbours converging on the same point never collide; the lift carries the gesture.\n    const pull = L.strength * f;\n    tx = clampAbs(dx * pull * 0.11, em * 0.14 * L.strength);\n    ty = clampAbs(dy * pull * 0.34, em * 0.42 * L.strength);\n  } else {\n    const push = em * 0.62 * L.strength * f;\n    tx = (-dx / dist) * push;\n    ty = (-dy / dist) * push;\n  }\n  // Lean into the direction of travel.\n  const tr = clampAbs(tx * (60 / em), 14);\n  const ts = L.mode === \"attract\" ? 1 + 0.07 * f * L.strength : 1 - 0.18 * f * L.strength;\n  g.tint = L.mode === \"attract\" ? Math.round(Math.max(0, f - 0.45) * 70) : Math.round(Math.max(0, f - 0.5) * 60);\n  return { tx, ty, tr, ts };\n}\n\nexport function MagneticLetters({\n  text = \"Pull me closer\",\n  mode = \"attract\",\n  strength = 1,\n  radius = 1.35,\n  stiffness = 210,\n  damping = 15,\n  shockwave = true,\n  tone,\n  color,\n  accentColor,\n  fontSize = 112,\n  fontFamily = \"var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif\",\n  fontWeight = 640,\n  tracking = -0.045,\n  align = \"center\",\n  pointer: fixedPointer,\n  className,\n  ariaLabel,\n}: MagneticLettersProps) {\n  const resolvedTone = useBjorkTone(tone);\n  const palette = BJORK_PALETTE[resolvedTone];\n  const ink = color ?? palette.text;\n  const accent = accentColor ?? palette.accent;\n  const reduced = useReducedMotion() ?? false;\n  // Reduced motion is only known on the client; hold the first client render to the server's.\n  const mounted = useSyncExternalStore(subscribeNoop, () => true, () => false);\n  const still = (mounted && reduced) || fixedPointer !== undefined;\n  const fixedX = fixedPointer?.x;\n  const fixedY = fixedPointer?.y;\n\n  const words = useMemo(() => text.split(/(\\s+)/).filter((w) => w.length > 0), [text]);\n  const longest = useMemo(() => words.reduce((m, w) => Math.max(m, Array.from(w).length), 1), [words]);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const boxRef = useRef<HTMLDivElement>(null);\n  const ringRef = useRef<SVGSVGElement>(null);\n  const letters = useRef<HTMLSpanElement[]>([]);\n  const field = useRef<Glyph[]>([]);\n  const ptr = useRef({ x: 0, y: 0, active: false, presence: 0, touch: false });\n  const ring = useRef<Ring | null>(null);\n  const clock = useRef(0);\n  const fontPx = useRef(fontSize);\n\n  const live = useRef({ mode, strength, radius, stiffness, damping, ink, accent });\n  useEffect(() => {\n    live.current = { mode, strength, radius, stiffness, damping, ink, accent };\n  }, [mode, strength, radius, stiffness, damping, ink, accent]);\n\n  const collect = useCallback(() => {\n    const box = boxRef.current;\n    if (!box) return;\n    const els = Array.from(box.querySelectorAll<HTMLSpanElement>(\"[data-letter]\"));\n    letters.current = els;\n    fontPx.current = parseFloat(getComputedStyle(box).fontSize) || fontSize;\n    const prev = field.current;\n    field.current = els.map((el, i) => {\n      // offsetLeft/Top ignore transforms, so these are the true rest positions.\n      const cx = el.offsetLeft + el.offsetWidth / 2;\n      const cy = el.offsetTop + el.offsetHeight / 2;\n      const p = prev[i];\n      if (p) return { ...p, cx, cy };\n      return {\n        cx,\n        cy,\n        x: { x: 0, v: 0 },\n        y: { x: 0, v: 0 },\n        r: { x: 0, v: 0 },\n        s: { x: 1, v: 0 },\n        key: \"\",\n        tint: -1,\n        kickAt: -1,\n        seed: i,\n      };\n    });\n  }, [fontSize]);\n\n  const write = useCallback(() => {\n    const { ink: c, accent: a } = live.current;\n    const gs = field.current;\n    for (let i = 0; i < gs.length; i++) {\n      const g = gs[i];\n      const el = letters.current[i];\n      if (!el) continue;\n      const key = `${g.x.x.toFixed(1)},${g.y.x.toFixed(1)},${g.r.x.toFixed(1)},${g.s.x.toFixed(3)}`;\n      if (key !== g.key) {\n        el.style.transform = `translate3d(${g.x.x.toFixed(2)}px, ${g.y.x.toFixed(2)}px, 0) rotate(${g.r.x.toFixed(2)}deg) scale(${g.s.x.toFixed(3)})`;\n        g.key = key;\n      }\n      const tint = g.tint;\n      const prevTint = Number(el.dataset.tint ?? -1);\n      if (tint !== prevTint) {\n        el.style.color = tint > 0 ? `color-mix(in oklab, ${a} ${tint}%, ${c})` : c;\n        el.dataset.tint = String(tint);\n      }\n    }\n    const svg = ringRef.current;\n    const r = ring.current;\n    if (svg) {\n      if (r && r.age < RING_LIFE) {\n        const k = r.age / RING_LIFE;\n        const ease = 1 - (1 - k) ** 3;\n        const size = fontPx.current * (0.4 + 5.5 * ease);\n        svg.style.opacity = String((1 - k) * 0.7);\n        svg.style.transform = `translate3d(${(r.x - 50).toFixed(1)}px, ${(r.y - 50).toFixed(1)}px, 0) scale(${(size / 100).toFixed(3)})`;\n      } else if (svg.style.opacity !== \"0\") {\n        svg.style.opacity = \"0\";\n      }\n    }\n  }, []);\n\n  const snapTo = useCallback(\n    (fx: number, fy: number) => {\n      const box = boxRef.current;\n      if (!box) return;\n      const px = fx * box.offsetWidth;\n      const py = fy * box.offsetHeight;\n      for (let gi = 0, gs = field.current; gi < gs.length; gi++) {\n        const g = gs[gi];\n        const { tx, ty, tr, ts } = fieldTarget(g, px, py, 1, live.current, fontPx.current);\n        g.x = { x: tx, v: 0 };\n        g.y = { x: ty, v: 0 };\n        g.r = { x: tr, v: 0 };\n        g.s = { x: ts, v: 0 };\n      }\n      write();\n    },\n    [write],\n  );\n\n  const frame = useCallback(\n    (dt: number) => {\n      clock.current += dt;\n      const L = live.current;\n      const p = ptr.current;\n      p.presence += ((p.active ? 1 : 0) - p.presence) * (1 - Math.exp(-dt * (p.active ? 10 : 4)));\n      const cfg: SpringConfig = { stiffness: L.stiffness, damping: L.damping, mass: 1 };\n      const rcfg: SpringConfig = { stiffness: L.stiffness * 0.8, damping: L.damping * 0.85, mass: 1 };\n      let moving = false;\n      for (let gi = 0, gs = field.current; gi < gs.length; gi++) {\n        const g = gs[gi];\n        if (g.kickAt >= 0 && clock.current >= g.kickAt) {\n          // Focus ripple: a small hop with a lean.\n          g.y.v -= fontPx.current * 2.2;\n          g.r.v += (g.seed % 2 ? 1 : -1) * 60;\n          g.s.v += 1.2;\n          g.kickAt = -1;\n        }\n        const { tx, ty, tr, ts } = fieldTarget(g, p.x, p.y, p.presence, L, fontPx.current);\n        stepSpring(g.x, tx, cfg, dt);\n        stepSpring(g.y, ty, cfg, dt);\n        stepSpring(g.r, tr, rcfg, dt);\n        stepSpring(g.s, ts, cfg, dt);\n        if (\n          g.kickAt >= 0 ||\n          Math.abs(g.x.x - tx) + Math.abs(g.y.x - ty) > 0.05 ||\n          Math.abs(g.r.x - tr) > 0.05 ||\n          Math.abs(g.s.x - ts) > 0.0005 ||\n          Math.abs(g.x.v) + Math.abs(g.y.v) + Math.abs(g.r.v) > 0.05\n        )\n          moving = true;\n      }\n      if (ring.current) {\n        ring.current.age += dt;\n        if (ring.current.age >= RING_LIFE) ring.current = null;\n        else moving = true;\n      }\n      write();\n      if (!moving && !p.active && p.presence < 0.002) return false;\n    },\n    [write],\n  );\n\n  const { wake } = useVisibleLoop(rootRef, frame, { enabled: !still });\n\n  useLayoutEffect(() => {\n    const box = boxRef.current;\n    if (!box) return;\n    let cancelled = false;\n    const refresh = () => {\n      collect();\n      if (fixedX !== undefined && fixedY !== undefined) snapTo(fixedX, fixedY);\n      else write();\n    };\n    refresh();\n    const ro = new ResizeObserver(refresh);\n    ro.observe(box);\n    document.fonts?.ready.then(() => {\n      if (!cancelled) refresh();\n    });\n    return () => {\n      cancelled = true;\n      ro.disconnect();\n    };\n  }, [collect, snapTo, write, fixedX, fixedY, text, fontSize, fontWeight, fontFamily, tracking]);\n\n  useEffect(() => {\n    if (still) return;\n    const box = boxRef.current;\n    if (!box) return;\n    const local = (clientX: number, clientY: number) => {\n      const rect = box.getBoundingClientRect();\n      const scale = box.offsetWidth > 0 ? rect.width / box.offsetWidth : 1;\n      return { x: (clientX - rect.left) / scale, y: (clientY - rect.top) / scale, rect, scale };\n    };\n    const onMove = (e: PointerEvent) => {\n      const p = ptr.current;\n      if (e.pointerType === \"touch\" && !p.touch) return;\n      const { x, y, rect, scale } = local(e.clientX, e.clientY);\n      const pad = fontPx.current * live.current.radius * 2 * scale;\n      const near =\n        e.clientX > rect.left - pad && e.clientX < rect.right + pad && e.clientY > rect.top - pad && e.clientY < rect.bottom + pad;\n      p.x = x;\n      p.y = y;\n      p.active = near;\n      if (near) wake();\n    };\n    const release = (e?: PointerEvent) => {\n      if (e && e.pointerType !== \"touch\") return;\n      ptr.current.touch = false;\n      ptr.current.active = false;\n      wake();\n    };\n    const onLeave = () => {\n      ptr.current.active = false;\n      wake();\n    };\n    window.addEventListener(\"pointermove\", onMove, { passive: true });\n    window.addEventListener(\"pointerup\", release, { passive: true });\n    window.addEventListener(\"pointercancel\", release, { passive: true });\n    document.documentElement.addEventListener(\"pointerleave\", onLeave);\n    window.addEventListener(\"blur\", onLeave);\n    return () => {\n      window.removeEventListener(\"pointermove\", onMove);\n      window.removeEventListener(\"pointerup\", release);\n      window.removeEventListener(\"pointercancel\", release);\n      document.documentElement.removeEventListener(\"pointerleave\", onLeave);\n      window.removeEventListener(\"blur\", onLeave);\n    };\n  }, [still, wake]);\n\n  const blast = useCallback(\n    (x: number, y: number) => {\n      if (still || !shockwave) return;\n      const em = fontPx.current;\n      const rand = mulberry32((Math.round(x * 31 + y * 17) ^ field.current.length) >>> 0);\n      for (let gi = 0, gs = field.current; gi < gs.length; gi++) {\n        const g = gs[gi];\n        const dx = g.cx - x;\n        const dy = g.cy - y;\n        const dist = Math.hypot(dx, dy) || 1;\n        const fall = 1 / (1 + (dist / (em * 2.4)) ** 2);\n        const speed = em * 16 * fall * live.current.strength * (0.75 + rand() * 0.5);\n        g.x.v += (dx / dist) * speed;\n        g.y.v += (dy / dist) * speed - em * 4 * fall;\n        g.r.v += (rand() - 0.5) * 1400 * fall;\n        g.s.v += (rand() - 0.3) * 6 * fall;\n      }\n      ring.current = { x, y, age: 0 };\n      wake();\n    },\n    [still, shockwave, wake],\n  );\n\n  const onPointerDown = useCallback(\n    (e: ReactPointerEvent<HTMLDivElement>) => {\n      if (still) return;\n      const box = boxRef.current;\n      if (!box) return;\n      const rect = box.getBoundingClientRect();\n      const scale = box.offsetWidth > 0 ? rect.width / box.offsetWidth : 1;\n      const x = (e.clientX - rect.left) / scale;\n      const y = (e.clientY - rect.top) / scale;\n      if (e.pointerType === \"touch\") {\n        ptr.current.touch = true;\n        ptr.current.x = x;\n        ptr.current.y = y;\n        ptr.current.active = true;\n        wake();\n      } else {\n        blast(x, y);\n      }\n    },\n    [still, blast, wake],\n  );\n\n  const onKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLDivElement>) => {\n      if (e.key !== \"Enter\" && e.key !== \" \") return;\n      e.preventDefault();\n      const box = boxRef.current;\n      if (!box) return;\n      blast(box.offsetWidth / 2, box.offsetHeight / 2);\n    },\n    [blast],\n  );\n\n  const onFocus = useCallback(\n    (e: FocusEvent<HTMLDivElement>) => {\n      if (still) return;\n      if (!e.currentTarget.matches(\":focus-visible\")) return;\n      const now = clock.current;\n      field.current.forEach((g, i) => {\n        g.kickAt = now + 0.05 + i * 0.045;\n      });\n      wake();\n    },\n    [still, wake],\n  );\n\n  const fitCqi = 100 / (longest * Math.max(0.35, 0.6 + tracking));\n  let letterIndex = 0;\n\n  return (\n    <div\n      ref={rootRef}\n      role={shockwave && !still ? \"button\" : undefined}\n      tabIndex={shockwave && !still ? 0 : undefined}\n      aria-label={shockwave && !still ? ariaLabel ?? text : undefined}\n      className={cn(\n        \"relative block w-full select-none rounded-[14px] outline-none focus-visible:ring-2 focus-visible:ring-[#ec5c13]/70 focus-visible:ring-offset-4 focus-visible:ring-offset-transparent\",\n        !still && shockwave && \"cursor-pointer\",\n        className,\n      )}\n      style={{ containerType: \"inline-size\", touchAction: \"pan-y\", WebkitTapHighlightColor: \"transparent\" }}\n      onPointerDown={onPointerDown}\n      onKeyDown={onKeyDown}\n      onFocus={onFocus}\n    >\n      <div\n        ref={boxRef}\n        aria-hidden=\"true\"\n        className=\"relative\"\n        style={{\n          fontFamily,\n          fontWeight,\n          fontSize: `min(${fontSize}px, ${fitCqi.toFixed(3)}cqi)`,\n          letterSpacing: `${tracking}em`,\n          lineHeight: 1.04,\n          textAlign: align,\n          color: ink,\n          paddingBlock: \"0.12em\",\n        }}\n      >\n        {words.map((w, wi) =>\n          /^\\s+$/.test(w) ? (\n            <span key={`s${wi}`}>{\" \"}</span>\n          ) : (\n            <span key={`w${wi}`} className=\"inline-block whitespace-nowrap\">\n              {Array.from(w).map((ch) => {\n                const i = letterIndex++;\n                return (\n                  <span key={i} data-letter=\"\" className=\"inline-block will-change-transform\" style={{ transformOrigin: \"50% 60%\" }}>\n                    {ch}\n                  </span>\n                );\n              })}\n            </span>\n          ),\n        )}\n        <svg\n          ref={ringRef}\n          className=\"pointer-events-none absolute left-0 top-0 overflow-visible\"\n          width={100}\n          height={100}\n          viewBox=\"0 0 100 100\"\n          style={{ opacity: 0, transformOrigin: \"50% 50%\" }}\n        >\n          <circle cx={50} cy={50} r={49} fill=\"none\" stroke={accent} strokeWidth={1.25} vectorEffect=\"non-scaling-stroke\" />\n        </svg>\n      </div>\n      <span className=\"sr-only\">{ariaLabel ?? text}</span>\n    </div>\n  );\n}\n\nexport default MagneticLetters;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/text/magnetic-letters.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n",
      "type": "registry:lib",
      "target": "@lib/utils.ts"
    },
    {
      "path": "components/bjork-ui/_core/palette.ts",
      "content": "export type BjorkTone = \"dark\" | \"light\";\n\n// Canvas, WebGL and SVG attribute colours. DOM code uses the CSS tokens with these as fallbacks.\nexport const BJORK_PALETTE = {\n  dark: {\n    bg: \"#050505\", stage: \"#111111\", surface: \"#121212\", raised: \"#161616\", active: \"#202020\",\n    border: \"#232323\", borderStrong: \"#343434\",\n    text: \"#ededed\", textMedium: \"rgba(237,237,237,0.72)\", textMuted: \"rgba(237,237,237,0.6)\",\n    textSoft: \"rgba(237,237,237,0.5)\", textFaint: \"rgba(237,237,237,0.22)\", hair: \"rgba(237,237,237,0.12)\",\n    accent: \"#ec5c13\", accentInk: \"#ec5c13\", accentFill: \"#b84a12\", accentSoft: \"rgba(236,92,19,0.12)\", accentMuted: \"rgba(236,92,19,0.24)\",\n    accentFg: \"#fff2ea\", success: \"#4cc38a\", warning: \"#f2b544\", error: \"#ff5c4d\",\n  },\n  light: {\n    bg: \"#f7f5ef\", stage: \"#f7f5ef\", surface: \"#fffcf6\", raised: \"#f5efe3\", active: \"#efe7d8\",\n    border: \"#eee6db\", borderStrong: \"#e1d7c8\",\n    text: \"#171717\", textMedium: \"rgba(23,23,23,0.78)\", textMuted: \"rgba(23,23,23,0.68)\",\n    textSoft: \"rgba(23,23,23,0.62)\", textFaint: \"rgba(23,23,23,0.22)\", hair: \"rgba(23,23,23,0.10)\",\n    accent: \"#ec7d43\", accentInk: \"#b4531f\", accentFill: \"#ec7d43\", accentSoft: \"rgba(236,125,67,0.10)\", accentMuted: \"rgba(236,125,67,0.20)\",\n    accentFg: \"#3f2112\", success: \"#1f8a55\", warning: \"#a86b00\", error: \"#c8361f\",\n  },\n} as const;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/palette.ts"
    },
    {
      "path": "components/bjork-ui/_core/tone.ts",
      "content": "\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { useTheme } from \"next-themes\";\nimport type { BjorkTone } from \"./palette\";\n\nconst subscribeNoop = () => () => {};\nconst getMounted = () => true;\nconst getServerMounted = () => false;\n\n/**\n * Resolves the tone for a component. An explicit `tone` always wins.\n * Otherwise it follows next-themes' resolved theme, but only after mount,\n * so the server render and first client render agree on dark.\n */\nexport function useBjorkTone(tone?: BjorkTone): BjorkTone {\n  const { resolvedTheme } = useTheme();\n  const mounted = useSyncExternalStore(subscribeNoop, getMounted, getServerMounted);\n  if (tone) return tone;\n  if (!mounted) return \"dark\";\n  return resolvedTheme === \"light\" ? \"light\" : \"dark\";\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/tone.ts"
    },
    {
      "path": "components/bjork-ui/_core/loop.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, type RefObject } from \"react\";\n\nexport interface VisibleLoopOptions {\n  enabled?: boolean;\n  fpsCap?: number;\n  rootMargin?: string;\n}\n\n/**\n * Runs `frame(dt, t)` on requestAnimationFrame while the element is near the viewport,\n * the tab is visible and `enabled` is not false. Return `false` from `frame` to go idle\n * until `wake()` is called. Writes `data-loop` on the element: running, idle or paused.\n */\nexport function useVisibleLoop(\n  ref: RefObject<HTMLElement | null>,\n  frame: (dt: number, t: number) => boolean | void,\n  opts?: VisibleLoopOptions,\n): { wake: () => void } {\n  const enabled = opts?.enabled !== false;\n  const fpsCap = opts?.fpsCap ?? 0;\n  const rootMargin = opts?.rootMargin ?? \"96px\";\n\n  const frameRef = useRef(frame);\n  useEffect(() => {\n    frameRef.current = frame;\n  }, [frame]);\n\n  const wakeRef = useRef<() => void>(() => {});\n  const wake = useCallback(() => wakeRef.current(), []);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof window === \"undefined\") return;\n\n    let wants = true; // false once the frame callback returns false\n    let inView = false;\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n    const minFrame = fpsCap > 0 ? 1000 / fpsCap : 0;\n\n    const canRun = () => enabled && inView && document.visibilityState === \"visible\";\n    const stop = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const update = () => {\n      if (!enabled) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      if (!wants) {\n        stop();\n        el.dataset.loop = \"idle\";\n        return;\n      }\n      if (!canRun()) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      el.dataset.loop = \"running\";\n      if (!raf) {\n        last = performance.now();\n        raf = requestAnimationFrame(tick);\n      }\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      if (!canRun() || !wants) {\n        update();\n        return;\n      }\n      const elapsed = now - last;\n      if (minFrame && elapsed < minFrame - 0.5) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      last = now;\n      const dt = Math.min(0.05, Math.max(0, elapsed / 1000));\n      t += dt;\n      const keep = frameRef.current(dt, t);\n      if (keep === false) {\n        wants = false;\n        update();\n        return;\n      }\n      raf = requestAnimationFrame(tick);\n    };\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        update();\n      },\n      { rootMargin },\n    );\n    observer.observe(el);\n\n    const onVisibility = () => update();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    wakeRef.current = () => {\n      wants = true;\n      update();\n    };\n\n    update();\n\n    return () => {\n      stop();\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      wakeRef.current = () => {};\n    };\n  }, [ref, enabled, fpsCap, rootMargin]);\n\n  return useMemo(() => ({ wake }), [wake]);\n}\n\n// matchMedia based check. False on the server.\nexport function isCoarsePointer(): boolean {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false;\n  return window.matchMedia(\"(pointer: coarse)\").matches;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/loop.ts"
    },
    {
      "path": "components/bjork-ui/_core/random.ts",
      "content": "// Seeded randomness and noise. Deterministic for a given seed, so previews and tests repeat.\n\n// Small fast PRNG. Returns floats in [0, 1).\nexport function mulberry32(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n// FNV-1a 32-bit.\nexport function hashString(s: string): number {\n  let h = 0x811c9dc5;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 0x01000193);\n  }\n  return h >>> 0;\n}\n\n// Integer lattice hash to [-1, 1].\nfunction latticeHash(seed: number, x: number, y = 0): number {\n  let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(seed | 0, 2246822519);\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  h ^= h >>> 16;\n  return ((h >>> 0) / 4294967295) * 2 - 1;\n}\n\nfunction smooth(t: number): number {\n  return t * t * (3 - 2 * t);\n}\n\nexport function valueNoise1D(seed: number): (x: number) => number {\n  return (x: number) => {\n    const i = Math.floor(x);\n    const f = x - i;\n    const a = latticeHash(seed, i);\n    const b = latticeHash(seed, i + 1);\n    return a + (b - a) * smooth(f);\n  };\n}\n\nexport function fbm1D(seed: number, octaves = 3): (x: number) => number {\n  const layers = Array.from({ length: octaves }, (_, o) => valueNoise1D(seed + o * 101));\n  let norm = 0;\n  for (let o = 0; o < octaves; o++) norm += 0.5 ** o;\n  return (x: number) => {\n    let sum = 0;\n    let amp = 1;\n    let freq = 1;\n    for (let o = 0; o < octaves; o++) {\n      sum += amp * layers[o](x * freq);\n      amp *= 0.5;\n      freq *= 2;\n    }\n    return sum / norm;\n  };\n}\n\nexport function valueNoise2D(seed: number): (x: number, y: number) => number {\n  return (x: number, y: number) => {\n    const xi = Math.floor(x);\n    const yi = Math.floor(y);\n    const fx = smooth(x - xi);\n    const fy = smooth(y - yi);\n    const a = latticeHash(seed, xi, yi);\n    const b = latticeHash(seed, xi + 1, yi);\n    const c = latticeHash(seed, xi, yi + 1);\n    const d = latticeHash(seed, xi + 1, yi + 1);\n    const top = a + (b - a) * fx;\n    const bottom = c + (d - c) * fx;\n    return top + (bottom - top) * fy;\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/random.ts"
    },
    {
      "path": "components/bjork-ui/_core/spring.ts",
      "content": "// Pure spring math, no React. Units: stiffness k, damping d (coefficient), mass m, time in seconds.\n\nexport interface SpringConfig {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface SpringState {\n  x: number;\n  v: number;\n}\n\n// Damping ratio zeta = d / (2 * sqrt(k * m)).\nexport function dampingRatio(c: SpringConfig): number {\n  return c.damping / (2 * Math.sqrt(c.stiffness * c.mass));\n}\n\n// Exact solution of y'' = -(k/m) y - (d/m) y' for displacement y0 and velocity v0 at time t.\n// Returns displacement and velocity together so stepping can be exact, not integrated.\nfunction solveLinear(t: number, c: SpringConfig, y0: number, v0: number): { y: number; v: number } {\n  const w0 = Math.sqrt(c.stiffness / c.mass);\n  const z = dampingRatio(c);\n\n  if (Math.abs(z - 1) < 1e-9) {\n    // Critically damped.\n    const e = Math.exp(-w0 * t);\n    const b = v0 + w0 * y0;\n    return { y: e * (y0 + b * t), v: e * (b - w0 * (y0 + b * t)) };\n  }\n  if (z < 1) {\n    // Underdamped.\n    const wd = w0 * Math.sqrt(1 - z * z);\n    const a = z * w0;\n    const B = (v0 + a * y0) / wd;\n    const e = Math.exp(-a * t);\n    const cos = Math.cos(wd * t);\n    const sin = Math.sin(wd * t);\n    return {\n      y: e * (y0 * cos + B * sin),\n      v: e * (-a * (y0 * cos + B * sin) + wd * (-y0 * sin + B * cos)),\n    };\n  }\n  // Overdamped.\n  const s = w0 * Math.sqrt(z * z - 1);\n  const r1 = -z * w0 + s;\n  const r2 = -z * w0 - s;\n  const B = (v0 - r1 * y0) / (r2 - r1);\n  const A = y0 - B;\n  const e1 = Math.exp(r1 * t);\n  const e2 = Math.exp(r2 * t);\n  return { y: A * e1 + B * e2, v: A * r1 * e1 + B * r2 * e2 };\n}\n\n// Analytic position at time t for a spring from `from` to `to` with initial velocity v0.\nexport function springAt(t: number, c: SpringConfig, from = 0, to = 1, v0 = 0): number {\n  return to + solveLinear(t, c, from - to, v0).y;\n}\n\n// Overshoot as a fraction of the total travel. Zero when critically or overdamped.\nexport function overshoot(c: SpringConfig): number {\n  const z = dampingRatio(c);\n  if (z >= 1) return 0;\n  return Math.exp((-Math.PI * z) / Math.sqrt(1 - z * z));\n}\n\n// Seconds until the spring stays within epsilon of its target (fraction of travel).\n// Found by scanning backwards from a bound derived from the slowest decay rate.\nexport function settleTime(c: SpringConfig, epsilon = 0.001): number {\n  const w0 = Math.sqrt(c.stiffness / c.mass);\n  const z = dampingRatio(c);\n  const slowest = z < 1 ? z * w0 : (z - Math.sqrt(z * z - 1)) * w0;\n  const tEnd = (Math.log(1 / epsilon) / slowest) * 1.5 + 0.05;\n  const steps = 4000;\n  const dt = tEnd / steps;\n  const band = epsilon;\n  let last = 0;\n  for (let i = 0; i <= steps; i++) {\n    const t = i * dt;\n    if (Math.abs(springAt(t, c, 1, 0, 0)) >= band) last = t;\n  }\n  return last + dt;\n}\n\n// Advances the state by dt towards `target`. Uses the exact solution of the linear spring\n// (no integration), so it tracks springAt to rounding error at any step size and stays stable\n// for stiff configs where a fixed-step integrator drifts.\nexport function stepSpring(s: SpringState, target: number, c: SpringConfig, dt: number): void {\n  if (dt <= 0) return;\n  const r = solveLinear(dt, c, s.x - target, s.v);\n  s.x = target + r.y;\n  s.v = r.v;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/spring.ts"
    }
  ],
  "cssVars": {
    "light": {
      "bjork-bg": "#f7f3ea",
      "bjork-surface": "#fffcf6",
      "bjork-surface-muted": "rgba(250, 246, 237, 0.94)",
      "bjork-surface-hover": "#f5efe3",
      "bjork-surface-active": "#efe7d8",
      "bjork-panel": "#f8f2e7",
      "bjork-menu": "rgba(255, 252, 246, 0.98)",
      "bjork-field": "rgba(255, 252, 246, 0.96)",
      "bjork-field-muted": "rgba(248, 242, 231, 0.82)",
      "bjork-field-inset": "rgba(239, 231, 216, 0.72)",
      "bjork-error-bg": "#fff0e8",
      "bjork-text": "#171717",
      "bjork-text-strong": "rgba(23, 23, 23, 0.9)",
      "bjork-text-medium": "rgba(23, 23, 23, 0.78)",
      "bjork-text-muted": "rgba(23, 23, 23, 0.68)",
      "bjork-text-soft": "rgba(23, 23, 23, 0.62)",
      "bjork-text-faint": "rgba(23, 23, 23, 0.22)",
      "bjork-inverted-text": "#fff8f0",
      "bjork-border": "#eee6db",
      "bjork-border-muted": "#f5ede2",
      "bjork-border-strong": "#e1d7c8",
      "bjork-thumb": "#a49b8e",
      "bjork-track": "#ded6ca",
      "bjork-accent": "#ec7d43",
      "bjork-accent-hover": "#f0935f",
      "bjork-accent-soft": "rgba(236, 125, 67, 0.1)",
      "bjork-accent-muted": "rgba(236, 125, 67, 0.2)",
      "bjork-accent-badge": "rgba(236, 125, 67, 0.16)",
      "bjork-accent-badge-foreground": "#8d421d",
      "bjork-accent-foreground": "#3f2112",
      "bjork-accent-fill": "#ec7d43",
      "bjork-accent-fill-hover": "#f0935f",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)",
      "bjork-ring-offset": "#f7f3ea"
    },
    "dark": {
      "bjork-bg": "#111111",
      "bjork-surface": "#121212",
      "bjork-surface-muted": "rgba(22, 22, 22, 0.92)",
      "bjork-surface-hover": "#161616",
      "bjork-surface-active": "#202020",
      "bjork-panel": "#0d0d0d",
      "bjork-menu": "rgba(18, 18, 18, 0.98)",
      "bjork-field": "rgba(18, 18, 18, 0.95)",
      "bjork-field-muted": "rgba(18, 18, 18, 0.55)",
      "bjork-field-inset": "rgba(9, 9, 9, 0.75)",
      "bjork-error-bg": "#130d0a",
      "bjork-text": "#ededed",
      "bjork-text-strong": "rgba(237, 237, 237, 0.9)",
      "bjork-text-medium": "rgba(237, 237, 237, 0.72)",
      "bjork-text-muted": "rgba(237, 237, 237, 0.6)",
      "bjork-text-soft": "rgba(237, 237, 237, 0.5)",
      "bjork-text-faint": "rgba(237, 237, 237, 0.22)",
      "bjork-inverted-text": "#080808",
      "bjork-border": "#232323",
      "bjork-border-muted": "#1c1c1c",
      "bjork-border-strong": "#343434",
      "bjork-thumb": "#626262",
      "bjork-track": "#090909",
      "bjork-accent": "#ec5c13",
      "bjork-accent-hover": "#f07832",
      "bjork-accent-soft": "rgba(236, 92, 19, 0.12)",
      "bjork-accent-muted": "rgba(236, 92, 19, 0.24)",
      "bjork-accent-badge": "#b84a12",
      "bjork-accent-badge-foreground": "#fff2ea",
      "bjork-accent-foreground": "#fff2ea",
      "bjork-accent-fill": "#b84a12",
      "bjork-accent-fill-hover": "#a84a14",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(255, 255, 255, 0.045)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(0, 0, 0, 0.45)",
      "bjork-ring-offset": "#080808"
    }
  },
  "css": {
    ".bjork-range": {
      "box-shadow": "inset 0 1px 1px rgba(255, 255, 255, 0.04), 0 0 14px rgba(189, 69, 20, 0.06)"
    },
    ".bjork-range::-webkit-slider-thumb": {
      "appearance": "none",
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-range::-moz-range-thumb": {
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-layered-button-accent, .bjork-layered-button-raised": {
      "position": "relative",
      "isolation": "isolate",
      "appearance": "none",
      "overflow": "hidden",
      "border": "0",
      "border-radius": "13px",
      "box-shadow": "inset 0 1px 0 #ffffff38, 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a",
      "transition": "filter 150ms ease-out, box-shadow 150ms ease-out"
    },
    ".bjork-layered-button-accent::after, .bjork-layered-button-raised::after": {
      "position": "absolute",
      "inset": "0",
      "pointer-events": "none",
      "content": "\"\"",
      "border": "1.5px solid transparent",
      "border-radius": "inherit",
      "background": "linear-gradient(#ffffffb8, #0000003d 41% 75%, #ffffff47) border-box",
      "mix-blend-mode": "overlay",
      "-webkit-mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "-webkit-mask-clip": "padding-box, border-box",
      "mask-clip": "padding-box, border-box",
      "-webkit-mask-origin": "padding-box, border-box",
      "mask-origin": "padding-box, border-box",
      "-webkit-mask-composite": "xor",
      "mask-composite": "exclude"
    },
    ".bjork-layered-button-accent": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000040",
      "background": "radial-gradient(ellipse at -20px top, rgba(255, 255, 255, 0.14), transparent), linear-gradient(180deg, #ec5c13 0%, #ec5c13 62%, color-mix(in oklab, #ec5c13 84%, black 16%) 100%)"
    },
    ".bjork-layered-button-raised": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, oklch(0.1881 0.006 265), oklch(0.5493 0.0081 265))"
    },
    ".dark .bjork-layered-button-raised": {
      "color": "oklch(0.2574 0.0056 265)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, color-mix(in oklab, oklch(0.9674 0.0013 265) 80%, oklch(0.2574 0.0056 265) 20%), color-mix(in oklab, oklch(0.669 0.0107 265) 80%, oklch(0.2574 0.0056 265) 20%))"
    },
    "@media (hover: hover)": {
      ".bjork-layered-button-accent:hover": {
        "filter": "brightness(1.08) saturate(1.04)"
      },
      ".bjork-layered-button-raised:hover": {
        "filter": "brightness(1.08) contrast(0.9)"
      }
    }
  },
  "meta": {
    "collection": "Text",
    "registryUrl": "https://ui.isaiahbjork.com/magnetic-letters.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/magnetic-letters.json"
  },
  "type": "registry:component"
}