{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"gravity-menu","title":"Gravity Menu","description":"Menu items on stable orbits, bent past the pointer by a softened inverse-square attraction. The softening term is what stops an item crossing the cursor being flung off screen in a single frame.","dependencies":[],"files":[{"path":"src/components/GravityMenu/index.js","content":"export { default } from './GravityMenu.jsx'\n","type":"registry:component","target":"components/gravity-menu/index.js"},{"path":"src/components/GravityMenu/GravityMenu.jsx","content":"'use client'\n\nimport { useEffect, useRef } from 'react'\nimport './GravityMenu.css'\n\n/* =========================================================\n   GravityMenu\n   Menu items holding stable orbits until the cursor arrives\n   as a mass and bends their paths past it.\n\n   Each item is a real two-body problem against the pointer:\n\n     F = G·m₁m₂·r / (r² + ε²)^(3/2)\n\n   which is the inverse square with Plummer softening. The ε\n   is not a fudge — an unsoftened 1/r² goes to infinity as an\n   item passes through the cursor, which in a discrete\n   integrator means one frame where the item is flung off\n   screen and never returns. Softening caps the force at\n   close range; it is what N-body codes do, for exactly this\n   reason.\n\n   Items also feel a spring back to their orbital slot, so\n   the menu is stable without the cursor and cannot be\n   permanently scattered. Take the spring to zero and it\n   really does become a free two-body system — items will\n   slingshot past the pointer and keep going, which is\n   correct and useless as a menu.\n\n   The two pulls are only worth watching when they are of a\n   size. Close in, the softened force is itself a spring,\n   G·m/ε³ per pixel, and the item comes to rest where that\n   one balances the one to its slot: at G·m = spring·ε³ it\n   sits halfway between the two, and that is where the\n   defaults put it. A mass a hundred times lighter moves\n   nothing you could see. One five times heavier gives an\n   item two places to rest, and it snaps from one to the\n   other — captured — which is the black hole end of the\n   softening slider and is meant to be there.\n\n   The orbit itself is kinematic: items advance along a\n   circle at an angular rate, and gravity displaces them from\n   it. Simulating true orbital mechanics would require the\n   menu to have a stable configuration to begin with, and a\n   ring of equal masses does not.\n\n   Being kinematic, the orbit is ours to brake, and a menu\n   has to be. A target that slides out from under a resting\n   cursor is the one thing a menu may not do, so while the\n   pointer is on an item, or an item is being pressed, or\n   one shows keyboard focus, the ring's rate eases to zero\n   and the item is simply there to be clicked; let go and it\n   eases back up to speed. The rate goes through two lags\n   in series rather than one, so the ring neither stops nor\n   starts with a jerk. An item still arriving under a\n   pointer that was waiting for it is let in the rest of the\n   way, slowing as it comes, so it seats under the cursor\n   instead of snagging on its own edge — but it is never\n   carried the other way. Which item the pointer is on is\n   worked out from the positions the loop has just drawn,\n   not from hover events: a browser does not reliably send\n   those when it is the item that moved and the mouse that\n   stayed.\n\n   The pull helps too: the item you are on is drawn under\n   the pointer, never away from it. A push would do the\n   opposite, so with repel on the push lets go of the one\n   item the pointer has actually reached — it shies off as\n   you approach, and gives in when you arrive. A menu that\n   can outrun the hand is a toy.\n\n   A press is answered by the loop as well: the item dips\n   three percent within 80 ms and comes back over about\n   170 ms, as a scale written last in its transform. Left to\n   the stylesheet, a scale would be taken about a fixed\n   origin and drag the item toward the middle of the ring —\n   a few pixels of sideways slip under the finger, on exactly\n   the frame it is meant to feel solid.\n\n   The mass does not switch on. It fades in over ~80 ms as\n   the pointer enters and out over ~200 ms from wherever it\n   left, so nothing is yanked and nothing is dropped. A\n   finger has no hover, so its mass arrives with the press\n   and leaves with it, and the page still scrolls and\n   pinches from anywhere on the ring.\n\n   A change of spin goes through the same two lags. A change\n   of orbit moves the slots at once and leaves the items\n   where they are, so each rides its own spring out to the\n   new ring; a change of items does the same, and keeps the\n   ring's phase — a parent that re-renders with a fresh\n   array must not send the ring back to its start.\n\n   The springs are stepped in slices no longer than 1/240 s —\n   shorter if the props make the well stiffer than that can\n   resolve — with the damping applied as an exact decay, so\n   the menu moves the same at 60 Hz and 120 Hz. Past what the\n   shortest slice resolves, the spring is capped and the\n   softening raised to match: a pathological prop set gets a\n   stiff menu, not a scattered one. The loop stops when\n   nothing is moving — a held ring at rest costs nothing —\n   and while the ring is scrolled out of view.\n\n   Arrow keys move focus to whichever ring neighbour lies in\n   that direction on screen, since \"next\" means right at the\n   top of a ring and left at the bottom of it. Tab still\n   visits every item in order. Under reduced motion the ring\n   is drawn once and stays put — no orbit, no pull — and\n   everything else works as it did.\n   ========================================================= */\n\nconst clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n)\n/* A number, or a string that reads as one; anything else is d. */\nconst finite = (n, d) => {\n  const v = typeof n === 'string' ? parseFloat(n) : n\n  return Number.isFinite(v) ? v : d\n}\n\nconst TURN = Math.PI * 2\nconst STEP = 1 / 240    // s — the longest slice the springs are stepped in\nconst SLICES = 48       // the most slices one frame is cut into, however stiff the props\nconst STIFF = 1e6       // 1/s² — the stiffest spring those slices resolve; nothing is let past it\nconst BRAKE = 0.06      // s — each of the two lags the ring's rate passes through, stopping under a hand\nconst RESUME = 0.22     // s — and getting back up to speed, or following a change of spin\nconst ARRIVE = 0.08     // s — how quickly the pointer's mass comes on as it enters\nconst DEPART = 0.2      // s — and fades from where it left\nconst GRIP = 0.1        // s — how quickly a push lets go of the item the pointer is on\nconst DIP = 0.03        // how far a pressed item shrinks, as a fraction of its size\nconst SINK = 0.025      // s — the lag it goes down on: there within 80 ms\nconst RISE = 0.055      // s — and comes back up on, over about 170 ms, without overshoot\nconst REACH = 22        // px — half the smallest target an item offers; the CSS pads it out to match\nconst SEAT = 3          // px — how far short of centred under the pointer an arriving item stops\nconst FAR = 600         // px — the furthest an item may be thrown from its slot\nconst FAST = 6000       // px/s — and the fastest it may be going\nconst REST_V = 0.4      // px/s — an item slower than this, and\nconst REST_A = 1        // px/s² — pushed no harder than this, is at rest\n\n/* :focus-visible, for the browsers that have it; where the selector does not\n   parse, any focus counts. */\nconst shown = (el) => {\n  try { return el.matches(':focus-visible') } catch { return true }\n}\n\nexport default function GravityMenu({\n  items = [],\n  orbit = 130,         // orbital radius, px — `radius` means corner radius library-wide\n  spin = 0.16,         // radians per second\n  mass = 2.6e6,        // G·m of the pointer, px³/s² — spring·ε³ puts an item halfway to it\n  softening = 46,      // ε, px — the reason nothing gets flung away\n  spring = 26,         // pull back to the orbital slot, 1/s²\n  damping = 7.6,       // 1/s — 2·√spring is critical; this is three quarters of it\n  repel = false,       // push instead of pull\n  value,               // id of the current item, if the menu has one\n  onSelect,\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const sim = useRef(null)    // the ring's own state, kept across a change of items\n  const wake = useRef(null)   // restarts a stopped loop\n\n  const live = useRef({})\n  // Synced after every render and before the effect below, so the loop reads\n  // current props without being torn down and restarted.\n  useEffect(() => {\n    live.current = { orbit, spin, mass, softening, spring, damping, repel }\n    wake.current?.()\n  })\n\n  const list = Array.isArray(items) ? items : []\n  // The loop is rebuilt when the set of items changes, not when the array does.\n  const order = list.map((item, i) => item.id ?? i).join('\\n')\n\n  useEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n    const cells = Array.from(root.querySelectorAll('.gravity-menu__item'))\n    const n = cells.length\n    if (!n) return\n\n    const query = window.matchMedia('(prefers-reduced-motion: reduce)')\n    let still = query.matches\n\n    const s = (sim.current ??= {\n      phase: 0,\n      rate: finite(live.current.spin, 0),   // rad/s the ring is actually turning at\n      lead: finite(live.current.spin, 0),   // the first of the two lags it follows\n      radius: Math.max(0, finite(live.current.orbit, 0)),\n      weight: 0,                            // 0…1 — how much of the pointer's mass has arrived\n      px: 0, py: 0,                         // the pointer, from the ring's centre\n      inside: false,\n      left: null,                           // where the last set of items was left, by element\n    })\n\n    // Displacement from the orbital slot, and its velocity.\n    const dx = new Float32Array(n)\n    const dy = new Float32Array(n)\n    const vx = new Float32Array(n)\n    const vy = new Float32Array(n)\n    const grip = new Float32Array(n)            // 0…1 — how far a push has let go of this item\n    const sink = new Float32Array(n)            // 0…1 — how far down a press has taken it\n    const x = new Float32Array(n)               // where each item was last drawn, from the centre\n    const y = new Float32Array(n)\n    const hw = new Float32Array(n).fill(REACH)  // half its target, never less than REACH\n    const hh = new Float32Array(n).fill(REACH)\n\n    /* Items that were already on the ring stay where they were drawn and ride\n       their springs to the new spacing; only the new ones start on a slot. */\n    for (const was of s.left ?? []) {\n      const i = cells.indexOf(was.cell)\n      if (i < 0) continue\n      const a = s.phase + (i / n) * TURN\n      dx[i] = was.x - Math.cos(a) * s.radius\n      dy[i] = was.y - Math.sin(a) * s.radius\n      vx[i] = was.u\n      vy[i] = was.v\n    }\n    s.left = null\n\n    let aim = -1        // the item the pointer is on\n    let pressed = -1    // the item being pressed, by pointer or key\n    let keyed = false   // whether that press came from the keyboard\n    let focused = -1    // the item showing keyboard focus\n    let visible = true\n    let raf = 0\n    let last = 0\n\n    const place = () => {\n      for (let i = 0; i < n; i++) {\n        const a = s.phase + (i / n) * TURN\n        x[i] = Math.cos(a) * s.radius + dx[i]\n        y[i] = Math.sin(a) * s.radius + dy[i]\n        cells[i].style.transform =\n          `translate3d(${x[i].toFixed(2)}px, ${y[i].toFixed(2)}px, 0) translate(-50%, -50%)` +\n          (sink[i] > 0 ? ` scale(${(1 - DIP * sink[i]).toFixed(4)})` : '')\n      }\n    }\n\n    /* The topmost item under the pointer, from the positions just drawn. Later\n       siblings paint over earlier ones, so the search runs from the end. */\n    const hit = () => {\n      if (!s.inside) return -1\n      for (let i = n - 1; i >= 0; i--) {\n        if (Math.abs(s.px - x[i]) <= hw[i] && Math.abs(s.py - y[i]) <= hh[i]) return i\n      }\n      return -1\n    }\n\n    const mark = (i) => {\n      if (i === aim) return\n      if (aim >= 0) cells[aim].classList.remove('is-near')\n      if (i >= 0) cells[i].classList.add('is-near')\n      aim = i\n    }\n\n    /* The attribute is there for a stylesheet that wants more of a pressed\n       state than the dip the loop draws. */\n    const press = (i) => {\n      if (i === pressed) return\n      if (pressed >= 0) cells[pressed].removeAttribute('data-pressed')\n      if (i >= 0) cells[i].setAttribute('data-pressed', '')\n      pressed = i\n    }\n\n    /* Advance everything by dt. Returns whether anything is still moving. */\n    const step = (dt) => {\n      const p = live.current\n      const whole = Math.max(0, finite(p.mass, 0))   // the pointer's G·m, all of it\n      /* Close in, gravity is a spring of G·m/ε³. Softening is what keeps that\n         finite, so it is held to at least what the integrator can resolve —\n         which only a pathological mass ever reaches. */\n      const eps = Math.max(1, finite(p.softening, 0), Math.cbrt(whole / STIFF))\n      const k = clamp(finite(p.spring, 0), 0, STIFF)\n      const c = Math.max(0, finite(p.damping, 0))\n      const push = !!p.repel\n      const orbitTo = Math.max(0, finite(p.orbit, 0))\n\n      const spin = finite(p.spin, 0)\n\n      /* A change of orbit moves the slots at once and leaves the items where\n         they are, so each rides its own spring out to the new ring. */\n      if (orbitTo !== s.radius) {\n        for (let i = 0; i < n; i++) {\n          const a = s.phase + (i / n) * TURN\n          dx[i] += Math.cos(a) * (s.radius - orbitTo)\n          dy[i] += Math.sin(a) * (s.radius - orbitTo)\n        }\n        s.radius = orbitTo\n      }\n\n      mark(hit())\n      const held = aim >= 0 || pressed >= 0 || focused >= 0\n      /* Held, the ring stops — except that an item still arriving under a\n         resting pointer is let in the rest of the way, slowing as it comes, so\n         it seats under the cursor instead of snagging on its own edge. It is\n         never carried the other way: once the pointer is behind its centre,\n         or it is pressed or focused, the ring is simply stopped. */\n      let coast = 0\n      if (aim >= 0 && pressed < 0 && focused < 0) {\n        const a = s.phase + (aim / n) * TURN\n        const ahead = Math.sign(spin) * ((s.py - y[aim]) * Math.cos(a) - (s.px - x[aim]) * Math.sin(a))\n        coast = Math.sqrt(clamp((ahead - SEAT) / REACH, 0, 1))\n      }\n      const rateTo = held ? spin * coast : spin\n      const weightTo = s.inside ? 1 : 0\n\n      // Two lags in series: the rate has no corner in it, stopping or starting.\n      const follow = 1 - Math.exp(-dt / (held ? BRAKE : RESUME))\n      s.lead += (rateTo - s.lead) * follow\n      s.rate += (s.lead - s.rate) * follow\n      s.phase = (s.phase + s.rate * dt) % TURN\n      s.weight += (weightTo - s.weight) * (1 - Math.exp(-dt / (s.inside ? ARRIVE : DEPART)))\n\n      const GM = whole * s.weight                    // and as much of it as has arrived\n      const e2 = eps * eps\n      // The slice has to resolve the stiffer of the two springs.\n      const stiff = Math.sqrt(k + GM / (e2 * eps))\n      const slices = clamp(Math.ceil(dt * Math.max(1 / STEP, stiff * 4)), 1, SLICES)\n      const h = dt / slices\n      const keep = Math.exp(-c * h)\n      const loosen = 1 - Math.exp(-dt / GRIP)\n\n      let moving =\n        Math.abs(s.rate - rateTo) > 1e-4 || Math.abs(s.lead - rateTo) > 1e-4 || Math.abs(rateTo) > 1e-4 ||\n        Math.abs(s.weight - weightTo) > 0.002\n\n      for (let i = 0; i < n; i++) {\n        const a = s.phase + (i / n) * TURN\n        const sx = Math.cos(a) * s.radius\n        const sy = Math.sin(a) * s.radius\n\n        const gripTo = i === aim || i === pressed ? 1 : 0\n        grip[i] += (gripTo - grip[i]) * loosen\n        // A pull keeps hold of the item the pointer is on; a push lets go of it.\n        const g = push ? -GM * (1 - grip[i]) : GM\n\n        let ex = dx[i], ey = dy[i], u = vx[i], v = vy[i]\n        let ax = 0, ay = 0\n        for (let m = 0; m < slices; m++) {\n          const rx = s.px - sx - ex\n          const ry = s.py - sy - ey\n          /* Softened inverse square. Without the ε² the force diverges as an\n             item crosses the pointer and one frame throws it off screen. */\n          const q = rx * rx + ry * ry + e2\n          const f = g / (q * Math.sqrt(q))\n          ax = rx * f - k * ex\n          ay = ry * f - k * ey\n          u = (u + ax * h) * keep\n          v = (v + ay * h) * keep\n          ex += u * h\n          ey += v * h\n        }\n\n        // A hard cap, so a pathological prop set cannot launch the menu.\n        dx[i] = clamp(ex, -FAR, FAR)\n        dy[i] = clamp(ey, -FAR, FAR)\n        vx[i] = clamp(u, -FAST, FAST)\n        vy[i] = clamp(v, -FAST, FAST)\n\n        /* The press, last in the transform so the item shrinks about its own\n           centre wherever the ring has put it. */\n        const sinkTo = i === pressed ? 1 : 0\n        sink[i] += (sinkTo - sink[i]) * (1 - Math.exp(-dt / (sinkTo ? SINK : RISE)))\n        if (Math.abs(sink[i] - sinkTo) < 0.01) sink[i] = sinkTo\n\n        if (u * u + v * v > REST_V * REST_V || ax * ax + ay * ay > REST_A * REST_A ||\n            (push && Math.abs(grip[i] - gripTo) > 0.01) || sink[i] !== sinkTo) moving = true\n      }\n\n      if (!moving) {\n        // Settled: land exactly, so nothing creeps while the loop is stopped.\n        s.rate = s.lead = rateTo\n        s.weight = weightTo\n        vx.fill(0)\n        vy.fill(0)\n        if (!weightTo) { dx.fill(0); dy.fill(0) }\n      }\n      return moving\n    }\n\n    const frame = (now) => {\n      const dt = last ? clamp((now - last) / 1000, 0, 1 / 30) : 1 / 60\n      last = now\n      const moving = step(dt)\n      place()\n      raf = moving ? requestAnimationFrame(frame) : 0\n    }\n\n    const kick = () => {\n      if (raf || still || !visible) return\n      last = 0\n      raf = requestAnimationFrame(frame)\n    }\n\n    // Something changed: run the loop, or under reduced motion just redraw.\n    const stir = () => (still ? settle() : kick())\n\n    const halt = () => {\n      if (raf) cancelAnimationFrame(raf)\n      raf = 0\n    }\n\n    /* Reduced motion: the ring as it stands, every item on its slot. */\n    const settle = () => {\n      s.rate = s.lead = 0\n      s.weight = 0\n      s.radius = Math.max(0, finite(live.current.orbit, 0))\n      for (const arr of [dx, dy, vx, vy, grip, sink]) arr.fill(0)\n      if (pressed >= 0) sink[pressed] = 1\n      place()\n      mark(hit())\n    }\n\n    const point = (e) => {\n      const b = root.getBoundingClientRect()\n      s.px = e.clientX - b.left - root.clientLeft - root.clientWidth / 2\n      s.py = e.clientY - b.top - root.clientTop - root.clientHeight / 2\n      s.inside = true\n    }\n\n    const onMove = (e) => {\n      if (!e.isPrimary) return\n      point(e)\n      if (still) mark(hit())\n      else kick()\n    }\n\n    const onLeave = (e) => {\n      if (!e.isPrimary) return\n      s.inside = false\n      if (still) mark(-1)\n      else kick()\n    }\n\n    const onUp = (e) => {\n      window.removeEventListener('pointerup', onUp)\n      window.removeEventListener('pointercancel', onUp)\n      press(-1)\n      // A lifted finger is gone; a mouse is still where it was.\n      if (e.pointerType === 'touch') s.inside = false\n      stir()\n    }\n\n    const onDown = (e) => {\n      if (!e.isPrimary || e.button > 0) return\n      point(e)\n      // A finger has no hover: the press is the first the menu hears of it.\n      mark(hit())\n      keyed = false\n      press(cells.indexOf(e.target.closest('.gravity-menu__item')))\n      window.addEventListener('pointerup', onUp)\n      window.addEventListener('pointercancel', onUp)\n      stir()\n    }\n\n    // A touch the browser has taken for a scroll: the mass goes with it.\n    const onCancel = (e) => {\n      if (e.isPrimary) onLeave(e)\n    }\n\n    const onKeyDown = (e) => {\n      const i = cells.indexOf(e.target)\n      if (i < 0 || e.altKey || e.ctrlKey || e.metaKey) return\n      if (e.key === 'Enter' || e.key === ' ') {\n        keyed = true\n        press(i)\n        stir()\n        return\n      }\n      const ux = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0\n      const uy = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0\n      if (!ux && !uy) return\n      /* Of the two ring neighbours, the one further along the arrow — and only\n         if it really is that way. At the top of the ring nothing is further up,\n         and the key is left alone to do what it does for the page. */\n      const along = (j) => (x[j] - x[i]) * ux + (y[j] - y[i]) * uy\n      const next = (i + 1) % n\n      const prev = (i + n - 1) % n\n      const j = along(next) >= along(prev) ? next : prev\n      if (j === i || along(j) < 1) return\n      e.preventDefault()\n      cells[j].focus()\n    }\n\n    const onKeyUp = () => {\n      if (keyed) press(-1)\n      stir()\n    }\n\n    const onFocusIn = (e) => {\n      const i = cells.indexOf(e.target)\n      focused = i >= 0 && shown(e.target) ? i : -1\n      kick()\n    }\n\n    // Pressing one item takes focus from another: only a key's press ends here.\n    const onFocusOut = () => {\n      focused = -1\n      if (keyed) press(-1)\n      kick()\n    }\n\n    const onMotion = () => {\n      still = query.matches\n      if (still) { halt(); settle() }\n      else kick()\n    }\n\n    // An item's own size sets its target, and a label can change under it.\n    const sizes = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        const i = cells.indexOf(entry.target)\n        if (i < 0) continue\n        const box = entry.borderBoxSize?.[0]\n        hw[i] = Math.max(REACH, (box ? box.inlineSize : entry.contentRect.width) / 2)\n        hh[i] = Math.max(REACH, (box ? box.blockSize : entry.contentRect.height) / 2)\n      }\n    })\n    for (const cell of cells) sizes.observe(cell)\n\n    // An orbit nobody can see is not worth a frame.\n    const seen = typeof IntersectionObserver === 'function'\n      ? new IntersectionObserver((entries) => {\n          visible = entries[entries.length - 1].isIntersecting\n          if (visible) kick()\n          else halt()\n        })\n      : null\n    seen?.observe(root)\n\n    const active = document.activeElement\n    focused = active && shown(active) ? cells.indexOf(active) : -1\n\n    root.addEventListener('pointermove', onMove, { passive: true })\n    root.addEventListener('pointerdown', onDown, { passive: true })\n    root.addEventListener('pointerleave', onLeave, { passive: true })\n    root.addEventListener('pointercancel', onCancel, { passive: true })\n    root.addEventListener('keydown', onKeyDown)\n    root.addEventListener('keyup', onKeyUp)\n    root.addEventListener('focusin', onFocusIn)\n    root.addEventListener('focusout', onFocusOut)\n    query.addEventListener?.('change', onMotion)\n\n    wake.current = stir\n\n    if (still) settle()\n    else { place(); kick() }\n\n    return () => {\n      wake.current = null\n      halt()\n      sizes.disconnect()\n      seen?.disconnect()\n      root.removeEventListener('pointermove', onMove)\n      root.removeEventListener('pointerdown', onDown)\n      root.removeEventListener('pointerleave', onLeave)\n      root.removeEventListener('pointercancel', onCancel)\n      root.removeEventListener('keydown', onKeyDown)\n      root.removeEventListener('keyup', onKeyUp)\n      root.removeEventListener('focusin', onFocusIn)\n      root.removeEventListener('focusout', onFocusOut)\n      window.removeEventListener('pointerup', onUp)\n      window.removeEventListener('pointercancel', onUp)\n      query.removeEventListener?.('change', onMotion)\n      mark(-1)\n      press(-1)\n      s.left = cells.map((cell, i) => ({ cell, x: x[i], y: y[i], u: vx[i], v: vy[i] }))\n    }\n  }, [order])\n\n  return (\n    <div\n      ref={rootRef}\n      className={`gravity-menu ${className}`.trim()}\n      style={{ '--gravity-menu-orbit': `${Math.max(0, finite(orbit, 0))}px`, ...style }}\n      {...rest}\n    >\n      {list.map((item, i) => {\n        const id = item.id ?? i\n        return (\n          <button\n            key={id}\n            type=\"button\"\n            className=\"gravity-menu__item\"\n            // Where the item sits before the loop has run: the stylesheet lays\n            // the ring out from this, so it is there in server-rendered markup.\n            style={{ '--gravity-menu-angle': `${(i / list.length) * 360}deg` }}\n            aria-current={value !== undefined && value === id ? 'true' : undefined}\n            onClick={() => onSelect?.(id, item, i)}\n          >\n            {item.label}\n          </button>\n        )\n      })}\n    </div>\n  )\n}\n","type":"registry:component","target":"components/gravity-menu/GravityMenu.jsx"},{"path":"src/components/GravityMenu/GravityMenu.css","content":"/* GravityMenu — items are absolutely placed at the ring's centre and moved\n   only by transform, so the ring never reflows. The frame loop runs while the\n   ring is turning or something is still settling, and stops when it is not. */\n\n.gravity-menu {\n  position: relative;\n  width: 100%;\n  height: 100%;\n}\n\n.gravity-menu__item {\n  position: absolute;\n  left: 50%;\n  top: 50%;\n  /* The ring at phase zero, in the stylesheet: server-rendered markup and the\n     first paint already have every item on its slot. The loop writes the same\n     transform inline from then on. */\n  transform:\n    rotate(var(--gravity-menu-angle, 0deg))\n    translateX(var(--gravity-menu-orbit, 130px))\n    rotate(calc(var(--gravity-menu-angle, 0deg) * -1))\n    translate(-50%, -50%);\n  padding: 8px 16px;\n  border: 1px solid currentColor;\n  border-radius: 999px;\n  background: none;\n  color: inherit;\n  font: inherit;\n  font-size: 13px;\n  line-height: 1;\n  white-space: nowrap;\n  cursor: pointer;\n  opacity: 0.72;\n  will-change: transform;\n  /* A tap is a tap: no double-tap zoom delay, and no grey flash over the\n     press state below. Panning and pinching the page still work from here. */\n  touch-action: manipulation;\n  -webkit-tap-highlight-color: transparent;\n  /* Leaving is the slow direction; arriving shortens it below. The press is\n     not here: the loop draws it, as a scale at the end of its own transform,\n     so the item dips about its centre wherever the ring has put it. */\n  transition:\n    opacity 0.2s ease-out,\n    background-color 0.2s ease-out;\n}\n\n/* The pill is 31 px tall. This pads the target out to 44 px in whichever\n   direction falls short, without changing how it looks. */\n.gravity-menu__item::before {\n  content: '';\n  position: absolute;\n  inset: min(0px, calc(50% - 22px));\n}\n\n.gravity-menu__item.is-near,\n.gravity-menu__item:focus-visible,\n.gravity-menu__item[aria-current] { opacity: 1; }\n\n.gravity-menu__item.is-near { transition-duration: 0.09s, 0.2s; }\n\n.gravity-menu__item[aria-current] {\n  background-color: color-mix(in srgb, currentColor 14%, transparent);\n}\n\n.gravity-menu__item:focus-visible {\n  outline: 2px solid currentColor;\n  outline-offset: 3px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .gravity-menu__item { will-change: auto; transition: none; }\n}\n","type":"registry:file","target":"components/gravity-menu/GravityMenu.css"},{"path":"src/components/GravityMenu/GravityMenu.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement } from 'react'\n\nexport interface GravityMenuItem {\n  id?: string\n  label: string\n}\n\nexport interface GravityMenuProps extends Omit<ComponentPropsWithoutRef<'div'>, 'onSelect'> {\n  /** The items, in ring order. An item without an `id` is known by its index. */\n  items?: GravityMenuItem[]\n  /** Orbital radius, px. A change is ridden out on each item's spring. Default 130. */\n  orbit?: number\n  /** Angular rate, radians per second. The ring holds still under a hovered, pressed or keyboard-focused item. Default 0.16. */\n  spin?: number\n  /** G·m of the pointer, px³/s². At `spring · softening³` an item close to the pointer rests halfway to it. Default 2.6e6. */\n  mass?: number\n  /** Softening ε, px. Caps the force at close range so nothing is flung away. Default 46. */\n  softening?: number\n  /** Pull back to the orbital slot, 1/s². 0 makes it a free two-body system. Default 26. */\n  spring?: number\n  /** Damping on an item's motion about its slot, 1/s. `2 · √spring` is critical. Default 7.6. */\n  damping?: number\n  /** Push instead of pull. The push lets go of the item the pointer is on, so it can still be clicked. Default false. */\n  repel?: boolean\n  /** Id of the current item, or its index if it has no id. It is marked `aria-current` and filled. */\n  value?: string | number\n  /** Called with the item's id (or index), the item and its index when it is clicked or activated from the keyboard. */\n  onSelect?: (id: string | number, item: GravityMenuItem, index: number) => void\n}\n\ndeclare const GravityMenu: (props: GravityMenuProps) => ReactElement\nexport default GravityMenu\n","type":"registry:lib","target":"components/gravity-menu/GravityMenu.d.ts"}],"type":"registry:component"}