{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ferrofluid-nav","title":"Ferrofluid Nav","description":"A nav whose active pill stretches out of one item, thins to a neck and gathers into the next. Two capsule fields blended with a polynomial smooth-minimum, evaluated per pixel — the neck is surface tension, not a translate.","dependencies":[],"files":[{"path":"src/components/FerrofluidNav/index.js","content":"export { default } from './FerrofluidNav.jsx'\n","type":"registry:component","target":"components/ferrofluid-nav/index.js"},{"path":"src/components/FerrofluidNav/FerrofluidNav.jsx","content":"'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport './FerrofluidNav.css'\n\n/* =========================================================\n   FerrofluidNav\n   A nav whose active pill does not slide. It stretches out of\n   the item it is leaving, thins to a neck, and gathers back\n   into the one it is entering — the way a drop of ferrofluid\n   is drawn between two magnets.\n\n   Measured over the four-item demo bar at the defaults: the\n   pill rests at 114 × 54px on \"Overview\" — the item's box\n   plus `padding`, exactly. Sent to the far end it draws out to\n   about 260px, slims to 48px, necks to three quarters of that\n   at the middle, and gathers to 124 × 54px on \"Changelog\". It\n   stays one connected body; drop `blend` and `stretch` to zero\n   and the two ends only touch, which reads as a glitch on a\n   nav and as physics on a cursor.\n\n   The whole effect is one function. Two capsules — one per\n   end of the fluid — are blended with a polynomial smooth-min:\n\n     smin(a, b, k) = min(a, b) − h²k/4,  h = max(k − |a−b|, 0)/k\n\n   `smin` is what a plain `min` is to a hard union: below the\n   blend radius k two separate fields fuse into one surface\n   with a real neck between them. That neck is the entire\n   point — a sliding pill is a translate, and a translate has\n   no surface tension.\n\n   Applied naively, smin inflates. Where the two capsules\n   overlap, a ≈ b, and the surface is pushed out by the full\n   k/4 — so a pill that should stretch and thin instead\n   balloons while it moves, and sits k/4 fatter than its own\n   `padding` at rest. Surface tension acts across a gap, not\n   through fluid that is already joined, so the blend here is\n   limited by the gap g between the capsules' facing ends. A\n   blend of 4(√(r² + g²/4) − r) would lift the middle of the\n   join exactly level with lobes of radius r; k is held to\n   four fifths of that. Overlapping, the union is exact;\n   pulled apart, the join sits just below the lobes' surface,\n   which is what makes it a neck rather than a bulge.\n\n   The two ends are not placed, they are coupled. The leading\n   one rides a damped spring toward the item being entered and\n   carries whatever velocity it already had, so a second click\n   mid-travel bends the path instead of restarting it. The\n   trailing one has inertia of its own: it is a critically\n   damped follower of the leader (ω 18 rad/s), so it holds in\n   the item being left for the first beat, is drawn out after\n   the leader, and lands a beat behind it. The naive version,\n   which offsets the tail by velocity, throws it backwards on\n   the first frame and brings both ends in together, which\n   reads as a shape rather than a liquid. The neck can only\n   open so far: a full-speed blend k lets go at a gap of\n   2r + k/2, the gap is held to 2r + 0.6·k/2, and past that the\n   neck drags the tail at the leader's speed — so a long run\n   necks without snapping, whatever the item widths.\n\n   Volume is roughly kept: the longer the body is drawn, the\n   thinner it gets, (L / (L + s))^0.15 for a lobe of rest\n   length L drawn s apart, and it fattens back as the tail\n   gathers in. Each lobe takes the size of whatever it is\n   passing over, interpolated between item centres, so leaving\n   a short label for a long one grows the body as it travels\n   rather than snapping to the new width at the click.\n\n   The blend radius is not constant either. It opens with\n   travel speed, so a fast body holds together across a\n   longer neck and a slow one lets it close up.\n\n   A hovered item is a weaker magnet. The leading end is drawn\n   toward it by `reach`, and while it is pressed by twice that\n   — the draw that precedes the break, begun inside a frame of\n   the press. The far end stays put, so the pill never looks as\n   if it is leaving the item it is on; it only reaches. That\n   draw answers on a critically damped spring, quick to engage\n   and slower to let go. The labels are inked by the fluid\n   itself: each one brightens by how much of it the field\n   covers, so text turns light as the liquid floods under it,\n   not before; a hovered or pressed label lifts part of the way\n   on its own, so a touch gets the same acknowledgement a\n   cursor does. A label that has just been chosen keeps that\n   lift until the liquid arrives to carry it the rest of the\n   way, so it never dims between the tap and the fill.\n\n   Drawing it means evaluating that field per pixel and\n   shading where it crosses zero. Only the blob's own bounding\n   box is visited, which on a nav bar is a few thousand pixels\n   — cheaper than it sounds, and exact, where tracing the\n   isoline on a grid would have to reassemble loose segments\n   into a fillable loop first. The edge is `smoothstep` across\n   one device pixel of distance, so it is genuinely antialiased\n   rather than stair-stepped. It has to be true distance: in\n   the blend, smin's gradient is shorter than 1, so the field\n   is divided by it there, or the neck's edge would go soft\n   just where the eye is. The keyboard focus ring is the\n   same field read a few pixels further out, so it follows the\n   liquid through its neck instead of drawing a box around it.\n   Under reduced motion the fluid is simply wherever it is\n   going; the inks and the ring still answer, without motion.\n   ========================================================= */\n\nconst clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n)\nconst finite = (n, fallback) => (Number.isFinite(n) ? n : fallback)\nconst smooth01 = (t) => {\n  const u = t < 0 ? 0 : t > 1 ? 1 : t\n  return u * u * (3 - 2 * u)\n}\n\nconst TAIL_OMEGA = 18    // rad/s, the trailing end's critically damped follow\nconst NECK_STRAIN = 0.6  // share of the full-speed blend's reach the neck may open to\nconst FILLET = 0.8       // share of the blend that would lift the join level with the lobes\nconst THIN = 0.15        // exponent of the thinning a drawn-out body does\nconst SUBSTEP = 1 / 240  // s, the coarsest slice the travel is integrated in\nconst MAX_DT = 1 / 30    // s, a backgrounded tab resumes rather than leaping\nconst FULL_SPEED = 900   // px/s at which all of `stretch` is spent\nconst ENGAGE = 45        // rad/s, the lean reaching toward a magnet (~85 ms)\nconst RELEASE = 22       // rad/s, the lean letting go (~180 ms)\nconst INK_IN = 0.035     // s, a hovered label brightening\nconst INK_OUT = 0.07     // s, and dimming again\nconst HOVER_INK = 0.4    // how far a hover lifts a label toward full ink\nconst INK_SAMPLES = [-0.3, 0, 0.3] // across each label, as fractions of its width\nconst RING_TAU = 0.04    // s, the focus ring fading in or out\nconst RING_GAP = 2.5     // px between the liquid's surface and its focus ring\nconst RING_W = 2         // px, the focus ring's width\nconst MAX_PAD = 120      // px, keeps the canvas and its pixel buffer a size a browser will allocate\n\n/* Signed distance to a horizontal capsule: a segment thickened by r.\n   Negative inside. A circle cannot represent a nav item — items are wider\n   than they are tall, so the lobe has to be a pill, and a pill is a segment\n   with a radius. */\nfunction sdCapsule(px, py, x0, x1, y, r) {\n  const qx = px < x0 ? x0 - px : px > x1 ? px - x1 : 0\n  return Math.hypot(qx, py - y) - r\n}\n\n/** Polynomial smooth-minimum. k is the blend radius, in px. */\nfunction smin(a, b, k) {\n  if (k <= 0) return Math.min(a, b)\n  const h = Math.max(k - Math.abs(a - b), 0) / k\n  return Math.min(a, b) - h * h * k * 0.25\n}\n\n/* The same blended field, divided by the length of its own gradient so it\n   reads as a true distance near the surface. Inside the blend, smin mixes two\n   unit gradients that point different ways, (1 − h/2)∇a + (h/2)∇b, and the\n   result is shorter than 1, most of all in the throat of the neck. Shaded\n   raw, a one-pixel feather there spreads over one and a half, and the focus\n   ring drifts out from the surface. Used for painting; the label inks read\n   the plain field, where a pixel either way does not show. */\nfunction sdPair(px, py, a, b, k) {\n  const ax = px < a.x0 ? px - a.x0 : px > a.x1 ? px - a.x1 : 0\n  const ay = py - a.y\n  const la = Math.sqrt(ax * ax + ay * ay)\n  const da = la - a.r\n  const bx = px < b.x0 ? px - b.x0 : px > b.x1 ? px - b.x1 : 0\n  const by = py - b.y\n  const lb = Math.sqrt(bx * bx + by * by)\n  const db = lb - b.r\n  const diff = da - db\n  if (k <= 0 || diff >= k || diff <= -k) return diff < 0 ? da : db\n  const h = (k - Math.abs(diff)) / k\n  const wa = diff < 0 ? 1 - h * 0.5 : h * 0.5\n  const ia = la > 1e-6 ? wa / la : 0\n  const ib = lb > 1e-6 ? (1 - wa) / lb : 0\n  const gx = ax * ia + bx * ib\n  const gy = ay * ia + by * ib\n  return (Math.min(da, db) - h * h * k * 0.25) / Math.max(0.2, Math.sqrt(gx * gx + gy * gy))\n}\n\n/* One exact step of a critically damped spring, x″ = −ω²(x − goal) − 2ωx′.\n   Closed form rather than integrated, so it takes the same time at 60 Hz and\n   120 Hz, and it cannot overshoot a fixed goal. */\nfunction critical(s, goal, omega, dt) {\n  const d = s.x - goal\n  const c = s.v + omega * d\n  const e = Math.exp(-omega * dt)\n  s.x = goal + (d + c * dt) * e\n  s.v = (s.v - omega * c * dt) * e\n}\n\n/* Any CSS colour to straight RGBA bytes, by letting the browser paint it. A\n   regex over hex and rgb() silently turns every hsl(), oklch() or named\n   colour into the fallback blue. */\nfunction resolveRgba(css) {\n  const probe = document.createElement('canvas')\n  probe.width = probe.height = 1\n  const p = probe.getContext('2d', { willReadFrequently: true })\n  if (!p) return [99, 154, 255, 1]\n  p.fillStyle = '#639aff'\n  p.fillStyle = css\n  p.fillRect(0, 0, 1, 1)\n  const d = p.getImageData(0, 0, 1, 1).data\n  return [d[0], d[1], d[2], d[3] / 255]\n}\n\nconst focusVisible = (el) => {\n  try {\n    return el.matches(':focus-visible')\n  } catch {\n    return true\n  }\n}\n\nexport default function FerrofluidNav({\n  items = [],\n  activeId,\n  defaultActiveId,\n  onSelect,\n\n  /* --- the fluid --- */\n  blend = 26,          // blend radius across the neck before speed adds to it, px\n  stretch = 34,        // extra blend radius at full travel speed, px\n  padding = 10,        // how far the resting pill overhangs an item's box, px\n  reach = 6,           // how far the fluid is drawn toward a hovered item, px (doubled while pressed)\n\n  /* --- the spring the leading end travels on --- */\n  stiffness = 210,     // spring constant, per s²\n  damping = 22,        // damping coefficient, per s\n\n  color,               // fill; overrides the --ffn-fill custom property\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const listRef = useRef(null)\n  const canvasRef = useRef(null)\n  const api = useRef(null)                         // { kick, remeasure }, set by the loop\n  const pointer = useRef({ hover: -1, press: -1 }) // written by pointer handlers only\n\n  const list = Array.isArray(items) ? items : []\n  const [ownId, setOwnId] = useState(defaultActiveId ?? list[0]?.id ?? null)\n  const [ringAt, setRingAt] = useState(-1)         // item showing a focus-visible ring\n\n  const controlled = activeId !== undefined\n  const currentId = controlled ? activeId : ownId\n  const found = list.findIndex((item) => item.id === currentId)\n  const targetIndex = Math.max(0, found)\n  // The liquid wears the ring only when it is sitting on the focused item;\n  // anywhere else the ring would be pointing at the wrong thing.\n  const fluidRing = ringAt >= 0 && ringAt === targetIndex\n  const itemsKey = list.map((item) => item.id).join('\\u0000')\n\n  const live = useRef({})\n  // Synced after every render and before the effects below, so the loop reads\n  // current props without being torn down and restarted.\n  useEffect(() => {\n    live.current = {\n      blend: Math.max(0, finite(blend, 26)),\n      stretch: Math.max(0, finite(stretch, 34)),\n      padding: clamp(finite(padding, 10), 0, MAX_PAD),\n      reach: Math.max(0, finite(reach, 6)),\n      // Bounded so the integrator's slices stay affordable and stable.\n      stiffness: clamp(finite(stiffness, 210), 1, 1e6),\n      damping: clamp(finite(damping, 22), 1, 1e4),\n      color: color || '',\n      targetIndex,\n      fluidRing,\n    }\n  })\n\n  useEffect(() => {\n    const root = rootRef.current\n    const bar = listRef.current\n    const canvas = canvasRef.current\n    if (!root || !bar || !canvas) return\n    const ctx = canvas.getContext('2d')\n    if (!ctx) return\n\n    const motion = window.matchMedia('(prefers-reduced-motion: reduce)')\n    let still = motion.matches\n\n    let dpr = 1\n    let bleedX = 0          // how far the canvas extends past the root, px\n    let bleedY = 0\n    let slots = []          // per item, in root space: { c, y, w, h }\n    let stops = []          // the same slots, ordered by centre\n    let buf = null\n    let rgba = [99, 154, 255, 1]\n    let colorKey = null\n    let ownColor = null\n    let raf = 0\n    let last = 0\n\n    // All positions are in the root's own space, so a change to the canvas's\n    // bleed never moves the liquid.\n    const head = { x: 0, v: 0 }   // the leading end, px along the bar\n    const tail = { x: 0, v: 0 }   // the trailing end, following it\n    const lean = { x: 0, v: 0 }   // the leading end's reach toward a magnet\n    let ring = 0                  // focus ring opacity\n    let ink = []                  // per item, hover or press brightening 0..1\n    let inked = []                // per item, the value last written to the DOM\n    let cover = []                // per item, how much of its label the liquid covers\n\n    // The fillet never rises above the lobes, so only the ring and the\n    // antialiasing reach past the resting overhang.\n    const bleedFor = () => Math.ceil(live.current.padding + RING_GAP + RING_W + 4)\n\n    /* The `color` prop is checked every paint; the `--ffn-fill` fallback only\n       on measure, since reading computed style mid-frame would force a style\n       recalc right after the label inks were written. */\n    const readColor = () => {\n      ownColor = live.current.color\n      const css =\n        ownColor ||\n        getComputedStyle(root).getPropertyValue('--ffn-fill').trim() ||\n        '#639aff'\n      if (css === colorKey) return\n      colorKey = css\n      rgba = resolveRgba(css)\n    }\n\n    /* Layout is read here and nowhere else. Rects are divided by the root's\n       own scale so the pill still fits when an ancestor is transformed. */\n    const measure = () => {\n      const rr = root.getBoundingClientRect()\n      const scale = root.offsetWidth ? rr.width / root.offsetWidth || 1 : 1\n      const W = rr.width / scale\n      const H = rr.height / scale\n      bleedY = bleedFor()\n      bleedX = bleedY + 24          // room for the spring's overshoot past an end item\n      dpr = Math.min(2, window.devicePixelRatio || 1)\n      canvas.width = Math.max(1, Math.round((W + 2 * bleedX) * dpr))\n      canvas.height = Math.max(1, Math.round((H + 2 * bleedY) * dpr))\n      canvas.style.left = `${-bleedX}px`\n      canvas.style.top = `${-bleedY}px`\n      canvas.style.width = `${W + 2 * bleedX}px`\n      canvas.style.height = `${H + 2 * bleedY}px`\n      buf = null\n\n      slots = Array.from(bar.children).map((el) => {\n        const b = el.getBoundingClientRect()\n        const w = b.width / scale\n        const h = b.height / scale\n        return {\n          c: (b.left - rr.left) / scale + w / 2,\n          y: (b.top - rr.top) / scale + h / 2,\n          w,\n          h,\n        }\n      })\n      stops = slots.slice().sort((a, b) => a.c - b.c)\n      if (ink.length !== slots.length) {\n        ink = slots.map(() => 0)\n        inked = slots.map(() => -1)\n        cover = slots.map(() => 0)\n      }\n      readColor()\n    }\n\n    const goalOf = () => slots[live.current.targetIndex]?.c ?? 0\n\n    /* A lobe at x takes the size of the item under it, interpolated between\n       neighbouring centres, and thins by how far the body is drawn out. */\n    const lobeAt = (x, pad, drawn) => {\n      const n = stops.length\n      let a = stops[0]\n      let b = a\n      let t = 0\n      if (x >= stops[n - 1].c) a = b = stops[n - 1]\n      else if (x > a.c) {\n        let j = 1\n        while (j < n - 1 && stops[j].c < x) j++\n        a = stops[j - 1]\n        b = stops[j]\n        t = (x - a.c) / (b.c - a.c || 1)\n      }\n      const w = a.w + (b.w - a.w) * t\n      const h = a.h + (b.h - a.h) * t\n      const y = a.y + (b.y - a.y) * t\n      const r = Math.max(0.5, h / 2 + pad)\n      const half = Math.max(0, w / 2 + pad - r)\n      const length = 2 * (half + r)\n      return { x0: x - half, x1: x + half, y, r: r * (length / (length + drawn)) ** THIN }\n    }\n\n    // The gap between two lobes' facing end-centres; ≤ 0 while they overlap.\n    const gapOf = (a, b) => (a.x0 + a.x1 <= b.x0 + b.x1 ? b.x0 - a.x1 : a.x0 - b.x1)\n\n    /* The fluid as it stands: both lobes, and the blend radius between them —\n       what speed asks for, limited to what the gap can hold without the\n       fillet rising above the lobes. */\n    const body = () => {\n      const L = live.current\n      const lead = head.x + lean.x\n      const drawn = Math.abs(lead - tail.x)\n      const a = lobeAt(tail.x, L.padding, drawn)\n      const b = lobeAt(lead, L.padding, drawn)\n      const g = gapOf(a, b)\n      const r = Math.min(a.r, b.r)\n      const fillet = g > 0 ? FILLET * 4 * (Math.sqrt(r * r + g * g * 0.25) - r) : 0\n      const k = Math.min(fillet, L.blend + L.stretch * clamp(Math.abs(head.v) / FULL_SPEED, 0, 1))\n      return { a, b, k }\n    }\n\n    const field = (px, py, a, b, k) =>\n      smin(sdCapsule(px, py, a.x0, a.x1, a.y, a.r), sdCapsule(px, py, b.x0, b.x1, b.y, b.r), k)\n\n    /* Per device pixel over the blob's bounding box only, written straight\n       into one canvas-sized buffer and put back as a dirty rect, so nothing\n       is allocated per frame. The ring, when shown, is a band of the same\n       distance field a few pixels outside the surface. */\n    const drawField = ({ a, b, k }, ringAlpha) => {\n      const cw = canvas.width\n      const ch = canvas.height\n      const out = (ringAlpha > 0.004 ? RING_GAP + RING_W : 0) + 2\n      const x0 = Math.max(0, Math.floor((Math.min(a.x0 - a.r, b.x0 - b.r) - out + bleedX) * dpr))\n      const x1 = Math.min(cw, Math.ceil((Math.max(a.x1 + a.r, b.x1 + b.r) + out + bleedX) * dpr))\n      const y0 = Math.max(0, Math.floor((Math.min(a.y - a.r, b.y - b.r) - out + bleedY) * dpr))\n      const y1 = Math.min(ch, Math.ceil((Math.max(a.y + a.r, b.y + b.r) + out + bleedY) * dpr))\n      if (x1 <= x0 || y1 <= y0) return\n\n      if (!buf || buf.width !== cw || buf.height !== ch) buf = ctx.createImageData(cw, ch)\n      const px = buf.data\n      const inv = 1 / dpr\n      const feather = inv              // one device pixel, in CSS units\n      const [R, G, B, A] = rgba\n      const alpha = A * 255\n      const ringMid = RING_GAP + RING_W / 2\n\n      for (let Y = y0; Y < y1; Y++) {\n        const wy = (Y + 0.5) * inv - bleedY\n        let o = (Y * cw + x0) * 4\n        for (let X = x0; X < x1; X++, o += 4) {\n          const d = sdPair((X + 0.5) * inv - bleedX, wy, a, b, k)\n          // smoothstep(feather, -feather, d) — 1 well inside, 0 well outside.\n          let t = smooth01((feather - d) / (2 * feather))\n          if (ringAlpha > 0.004) {\n            const band = Math.abs(d - ringMid) - RING_W / 2\n            const r = smooth01((feather - band) / (2 * feather)) * ringAlpha\n            if (r > t) t = r\n          }\n          px[o] = R\n          px[o + 1] = G\n          px[o + 2] = B\n          px[o + 3] = (t * alpha) | 0\n        }\n      }\n      ctx.putImageData(buf, 0, 0, x0, y0, x1 - x0, y1 - y0)\n    }\n\n    /* Ink: how much of each label the liquid actually covers, sampled at\n       three points across it, or a hover's brightening, whichever is more. */\n    const inkLabels = ({ a, b, k }) => {\n      const kids = bar.children\n      for (let i = 0; i < slots.length; i++) {\n        const s = slots[i]\n        let covered = 0\n        for (const f of INK_SAMPLES) {\n          // Fully inked once the point is 8px inside the surface.\n          covered += smooth01(-field(s.c + f * s.w, s.y, a, b, k) / 8)\n        }\n        cover[i] = covered / INK_SAMPLES.length\n        const v = Math.max(cover[i], ink[i] * HOVER_INK)\n        if (Math.abs(v - inked[i]) > 0.004 || (v !== inked[i] && (v === 0 || v === 1))) {\n          inked[i] = v\n          kids[i]?.style.setProperty('--ffn-cover', v.toFixed(3))\n        }\n      }\n    }\n\n    const paint = () => {\n      if (bleedFor() !== bleedY || Math.min(2, window.devicePixelRatio || 1) !== dpr) measure()\n      if (live.current.color !== ownColor) readColor()\n      ctx.clearRect(0, 0, canvas.width, canvas.height)\n      if (!stops.length) return\n      const shape = body()\n      drawField(shape, ring)\n      inkLabels(shape)\n    }\n\n    /* Where the hover or press magnet wants the leading end, in px. */\n    const leanGoal = () => {\n      const L = live.current\n      const { hover, press } = pointer.current\n      const at = press >= 0 ? press : hover\n      const from = slots[L.targetIndex]\n      const to = slots[at]\n      if (!from || !to || at === L.targetIndex) return 0\n      return Math.sign(to.c - from.c) * L.reach * (press >= 0 ? 2 : 1)\n    }\n\n    const inkGoal = (i) => {\n      const { hover, press } = pointer.current\n      return (hover === i || press === i) && i !== live.current.targetIndex ? 1 : 0\n    }\n\n    // Jump every part of the state to where it is heading. Used on mount, on\n    // resize at rest, under reduced motion, and to land a settle exactly.\n    // Only the lean is motion; the inks and the ring still answer when still.\n    const place = () => {\n      head.x = goalOf()\n      head.v = 0\n      tail.x = head.x\n      tail.v = 0\n      lean.x = still ? 0 : leanGoal()\n      lean.v = 0\n      ring = live.current.fluidRing ? 1 : 0\n      for (let i = 0; i < ink.length; i++) ink[i] = inkGoal(i)\n    }\n\n    /* The neck can stretch to NECK_STRAIN of the gap at which the full-speed\n       blend would let go; past that it drags the tail along at the leader's\n       speed rather than snapping. */\n    const holdNeck = (L) => {\n      if (!stops.length) return\n      const drawn = Math.abs(head.x - tail.x)\n      const a = lobeAt(tail.x, L.padding, drawn)\n      const b = lobeAt(head.x, L.padding, drawn)\n      const over = gapOf(a, b) - (2 * Math.min(a.r, b.r) + NECK_STRAIN * (L.blend + L.stretch) * 0.5)\n      if (over <= 0) return\n      const dir = Math.sign(head.x - tail.x)\n      tail.x += dir * over\n      if (dir * tail.v < dir * head.v) tail.v = head.v\n    }\n\n    const step = (dt) => {\n      const L = live.current\n      const goal = goalOf()\n\n      /* The travel spring, sub-stepped finely enough for any stiffness to stay\n         stable, and so a 120 Hz display integrates the same curve as a 60 Hz\n         one. The tail's follow is exact per slice. */\n      const n = clamp(Math.ceil(dt * Math.max(1 / SUBSTEP, 2 * Math.sqrt(L.stiffness), L.damping)), 1, 400)\n      const h = dt / n\n      for (let i = 0; i < n; i++) {\n        head.v += (L.stiffness * (goal - head.x) - L.damping * head.v) * h\n        head.x += head.v * h\n        critical(tail, head.x, TAIL_OMEGA, h)\n        holdNeck(L)\n      }\n\n      const lg = leanGoal()\n      critical(lean, lg, Math.abs(lg) > Math.abs(lean.x) ? ENGAGE : RELEASE, dt)\n\n      const rg = L.fluidRing ? 1 : 0\n      ring += (rg - ring) * (1 - Math.exp(-dt / RING_TAU))\n\n      let inkBusy = false\n      for (let i = 0; i < ink.length; i++) {\n        // A label just chosen holds its lift until the liquid covers it more.\n        const g = i === L.targetIndex && ink[i] * HOVER_INK > cover[i] ? ink[i] : inkGoal(i)\n        ink[i] += (g - ink[i]) * (1 - Math.exp(-dt / (g > ink[i] ? INK_IN : INK_OUT)))\n        if (Math.abs(g - ink[i]) > 0.004) inkBusy = true\n      }\n\n      return (\n        Math.abs(head.v) > 2 ||\n        Math.abs(goal - head.x) > 0.2 ||\n        Math.abs(tail.v) > 2 ||\n        Math.abs(head.x - tail.x) > 0.2 ||\n        Math.abs(lg - lean.x) > 0.05 ||\n        Math.abs(lean.v) > 1 ||\n        Math.abs(rg - ring) > 0.004 ||\n        inkBusy\n      )\n    }\n\n    const frame = (now) => {\n      raf = 0\n      // The first frame after a kick steps only the time since the kick.\n      const dt = clamp((now - last) / 1000, 1 / 240, MAX_DT)\n      last = now\n      if (step(dt)) raf = requestAnimationFrame(frame)\n      else place()          // land exactly: no creep in the last fraction of a pixel\n      paint()\n    }\n\n    // Anything that changes where the liquid is heading starts the loop; it\n    // stops itself once everything has arrived.\n    const kick = () => {\n      if (still) {\n        place()\n        paint()\n        return\n      }\n      if (!raf) {\n        last = performance.now()\n        raf = requestAnimationFrame(frame)\n      }\n    }\n\n    const onResize = () => {\n      measure()\n      if (!raf || still) place()\n      paint()\n    }\n\n    const ro = new ResizeObserver(onResize)\n    const observe = () => {\n      ro.disconnect()\n      ro.observe(root)\n      Array.from(bar.children).forEach((el) => ro.observe(el))\n    }\n\n    const onMotion = () => {\n      still = motion.matches\n      if (still && raf) {\n        cancelAnimationFrame(raf)\n        raf = 0\n      }\n      place()\n      paint()\n    }\n\n    measure()\n    place()\n    paint()\n    observe()\n    motion.addEventListener?.('change', onMotion)\n\n    let dead = false\n    document.fonts?.ready?.then(() => {\n      if (!dead) onResize()\n    })\n\n    api.current = {\n      kick,\n      remeasure: () => {\n        observe()\n        onResize()\n      },\n    }\n\n    return () => {\n      dead = true\n      api.current = null\n      ro.disconnect()\n      motion.removeEventListener?.('change', onMotion)\n      if (raf) cancelAnimationFrame(raf)\n    }\n  }, [])\n\n  // A new set of items needs measuring, and observing, before anything else.\n  useEffect(() => {\n    api.current?.remeasure()\n  }, [itemsKey])\n\n  // Anything the loop draws from has changed: let it run until it settles.\n  useEffect(() => {\n    api.current?.kick()\n  }, [targetIndex, fluidRing, blend, stretch, padding, reach, color])\n\n  const pick = (item, i) => {\n    if (!controlled) setOwnId(item.id)\n    onSelect?.(item.id, item, i)\n  }\n\n  const setPointer = (next) => {\n    const p = pointer.current\n    if (next.hover === p.hover && next.press === p.press) return\n    pointer.current = next\n    api.current?.kick()\n  }\n\n  return (\n    <div\n      ref={rootRef}\n      className={`ferrofluid-nav ${className}`.trim()}\n      style={style}\n      {...rest}\n    >\n      <canvas ref={canvasRef} className=\"ferrofluid-nav__fluid\" aria-hidden=\"true\" />\n      <div ref={listRef} className=\"ferrofluid-nav__list\" role=\"tablist\" aria-orientation=\"horizontal\">\n        {list.map((item, i) => (\n          <button\n            key={item.id}\n            type=\"button\"\n            role=\"tab\"\n            aria-selected={i === found}\n            tabIndex={i === targetIndex ? 0 : -1}\n            data-active={i === targetIndex ? 'true' : 'false'}\n            data-ring={fluidRing && i === ringAt ? 'fluid' : undefined}\n            className=\"ferrofluid-nav__item\"\n            onClick={() => pick(item, i)}\n            onFocus={(e) => setRingAt(focusVisible(e.currentTarget) ? i : -1)}\n            onBlur={() => setRingAt(-1)}\n            onPointerEnter={(e) => {\n              if (e.pointerType !== 'touch') setPointer({ ...pointer.current, hover: i })\n            }}\n            onPointerLeave={() => {\n              const p = pointer.current\n              setPointer({ hover: p.hover === i ? -1 : p.hover, press: p.press === i ? -1 : p.press })\n            }}\n            onPointerDown={(e) => {\n              if (e.button === 0) setPointer({ ...pointer.current, press: i })\n            }}\n            onPointerUp={() => setPointer({ ...pointer.current, press: -1 })}\n            onPointerCancel={() => setPointer({ hover: -1, press: -1 })}\n            onKeyDown={(e) => {\n              const n = list.length\n              const back = getComputedStyle(e.currentTarget).direction === 'rtl' ? 1 : -1\n              let to = -1\n              if (e.key === 'ArrowRight') to = (i - back + n) % n\n              else if (e.key === 'ArrowLeft') to = (i + back + n) % n\n              else if (e.key === 'Home') to = 0\n              else if (e.key === 'End') to = n - 1\n              if (to < 0) return\n              e.preventDefault()\n              pick(list[to], to)\n              e.currentTarget.parentElement?.children[to]?.focus()\n            }}\n          >\n            {item.label}\n          </button>\n        ))}\n      </div>\n    </div>\n  )\n}\n","type":"registry:component","target":"components/ferrofluid-nav/FerrofluidNav.jsx"},{"path":"src/components/FerrofluidNav/FerrofluidNav.css","content":"/* FerrofluidNav — the blob is painted on a canvas underneath the buttons, so\n   the labels stay real text: selectable, translatable, and readable by a\n   screen reader. Nothing here animates; JS owns the motion, the label inks\n   and the focus ring, so every part of it runs on one clock. */\n\n.ferrofluid-nav {\n  --ffn-fill: #639aff;\n  --ffn-ink: #f4f7ff;\n  --ffn-ink-dim: #9db4dd;\n\n  position: relative;\n  display: inline-block;\n  isolation: isolate;\n}\n\n/* The canvas bleeds past the bar by the blob's overhang (JS sets its box), so\n   the pill's rounded ends are drawn whole rather than cut flat at the edge of\n   the buttons. It never takes pointer events or layout space. */\n.ferrofluid-nav__fluid {\n  position: absolute;\n  inset: 0;\n  z-index: 0;\n  pointer-events: none;\n}\n\n.ferrofluid-nav__list {\n  position: relative;\n  z-index: 1;\n  display: flex;\n  align-items: center;\n  gap: 4px;\n}\n\n.ferrofluid-nav__item {\n  padding: 10px 18px;\n  border: 0;\n  border-radius: 999px;\n  background: none;\n  color: var(--ffn-ink-dim);\n  font: inherit;\n  font-size: 14px;\n  font-weight: 500;\n  line-height: 1;\n  white-space: nowrap;\n  cursor: pointer;\n  /* Taps respond at once and never zoom; a horizontal swipe still scrolls a\n     bar that overflows its container. The fluid is the press feedback, so\n     the platform's grey tap flash would only compete with it. */\n  touch-action: manipulation;\n  -webkit-tap-highlight-color: transparent;\n  transition: color 0.18s ease;\n}\n\n.ferrofluid-nav__item[data-active='true'] {\n  color: var(--ffn-ink);\n}\n\n/* Where the browser can mix colours, each label is inked by how much of it\n   the liquid covers (--ffn-cover, written per frame). The stylesheet value\n   only covers the first paint, before the loop has written anything. */\n@supports (color: color-mix(in srgb, red 50%, blue)) {\n  .ferrofluid-nav__item {\n    --ffn-cover: 0;\n    color: color-mix(\n      in srgb,\n      var(--ffn-ink) calc(var(--ffn-cover) * 100%),\n      var(--ffn-ink-dim)\n    );\n    transition: none;\n  }\n\n  .ferrofluid-nav__item[data-active='true'] {\n    --ffn-cover: 1;\n    color: color-mix(\n      in srgb,\n      var(--ffn-ink) calc(var(--ffn-cover) * 100%),\n      var(--ffn-ink-dim)\n    );\n  }\n}\n\n/* The selected item is where you already are. */\n.ferrofluid-nav__item[aria-selected='true'] {\n  cursor: default;\n}\n\n.ferrofluid-nav__item:focus {\n  outline: none;\n}\n\n/* Focus on an item the liquid is not sitting on gets a ring of its own. On\n   the item it is sitting on, the canvas draws the ring as an isoline of the\n   same field, so it follows the fluid (data-ring). */\n.ferrofluid-nav__item:focus-visible:not([data-ring='fluid']) {\n  outline: 2px solid var(--ffn-fill);\n  outline-offset: 2px;\n}\n\n@media (forced-colors: active) {\n  .ferrofluid-nav__item:focus-visible {\n    outline: 2px solid CanvasText;\n    outline-offset: 2px;\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ferrofluid-nav__item { transition: none; }\n}\n","type":"registry:file","target":"components/ferrofluid-nav/FerrofluidNav.css"},{"path":"src/components/FerrofluidNav/FerrofluidNav.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement } from 'react'\n\nexport interface FerrofluidNavItem {\n  id: string\n  label: string\n}\n\nexport interface FerrofluidNavProps extends Omit<ComponentPropsWithoutRef<'div'>, 'onSelect'> {\n  /** The nav's items, in order. Each becomes a real `role=\"tab\"` button. */\n  items?: FerrofluidNavItem[]\n  /** Controlled selection. */\n  activeId?: string\n  /** Starting selection when uncontrolled. Defaults to the first item. */\n  defaultActiveId?: string\n  /** Called on click, tap, or arrow / Home / End keys, with the item and its index. */\n  onSelect?: (id: string, item: FerrofluidNavItem, index: number) => void\n\n  /**\n   * Blend radius across the neck, in px, before speed adds to it — how\n   * readily the two ends fuse once they are drawn apart. It never inflates the\n   * resting pill. Default 26.\n   */\n  blend?: number\n  /** Extra blend radius at full travel speed, in px, so a fast body holds together across a longer neck. Default 34. */\n  stretch?: number\n  /** How far the resting pill overhangs an item's box, in px (0 to 120). Default 10. */\n  padding?: number\n  /** How far the fluid is drawn toward a hovered item, in px; doubled while it is pressed. 0 turns it off. Default 6. */\n  reach?: number\n\n  /** Spring constant for the leading end's travel, per s². Default 210. */\n  stiffness?: number\n  /** Damping coefficient for that spring, per s. Default 22 (ζ ≈ 0.76). */\n  damping?: number\n\n  /** Fill colour, any CSS colour. Overrides the `--ffn-fill` custom property. */\n  color?: string\n}\n\ndeclare const FerrofluidNav: (props: FerrofluidNavProps) => ReactElement\nexport default FerrofluidNav\n","type":"registry:lib","target":"components/ferrofluid-nav/FerrofluidNav.d.ts"}],"type":"registry:component"}