{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"orbit-light-button","type":"registry:component","title":"Orbit Light Button","description":"A dark pill whose border carries one light: it laps the outline slowly, glides to the nearest point of the border under the pointer and lights the surface from there, and a press sends a ring outward.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/orbit-light-button.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\n\n/**\n * OrbitLightButton — a dark pill whose border carries one light.\n *\n * Idle, the light laps the outline slowly (or waits, with idle=\"still\").\n * Under the pointer it glides the short way round to the nearest point of\n * the border and lights the surface from there. Keyboard focus brings it\n * to the top edge, and a press sends a ring outward. Renders a link when\n * given an href, else a button. Measured in layout pixels, so it stays\n * exact inside scaled containers.\n *\n * Part of the Dark Precision kit: OLED black, 1px hairlines, one cool accent\n * used only as light (default #4fd1ff; also mint, amber, white or any\n * #rrggbb). Fonts come from CSS variables with Geist fallbacks. Respects\n * prefers-reduced-motion. No dependencies beyond React.\n */\n\nconst SANS = 'var(--font-sans, \"Geist\", \"Inter\", ui-sans-serif, system-ui, sans-serif)';\n\nconst INK = \"rgba(255,255,255,0.92)\";\n\nconst LINE_2 = \"rgba(255,255,255,0.14)\";\n\nconst MORPH = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\nconst HEX = /^#[0-9a-fA-F]{6}$/;\n\nexport const DP_ACCENTS = {\n  ice: \"#4fd1ff\",\n  mint: \"#5ef2c1\",\n  amber: \"#ffb45e\",\n  white: \"#f2f4f7\",\n} as const;\n\nfunction own<T extends object>(map: T, key: string): key is Extract<keyof T, string> {\n  return Object.prototype.hasOwnProperty.call(map, key);\n}\n\n/** A named accent or a #rrggbb hex; anything else falls back to ice. */\nfunction accentHex(value: string): string {\n  if (own(DP_ACCENTS, value)) return DP_ACCENTS[value];\n  return HEX.test(value) ? value : DP_ACCENTS.ice;\n}\n\nfunction rgbOf(hex: string): [number, number, number] {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction rgba(hex: string, a: number): string {\n  const [r, g, b] = rgbOf(hex);\n  return `rgba(${r},${g},${b},${a})`;\n}\n\n/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */\nfunction safeHref(raw: string): string {\n  const v = raw.replace(/[\\t\\n\\r]/g, \"\").trim();\n  if (!v || v.includes(\"\\\\\")) return \"#\";\n  if (/^(https?:|mailto:|tel:)/i.test(v)) return v;\n  if (/^[/#?]/.test(v) && !/^\\/\\//.test(v)) return v;\n  return \"#\";\n}\n\nfunction subscribeMotion(cb: () => void) {\n  const m = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  m.addEventListener(\"change\", cb);\n  return () => m.removeEventListener(\"change\", cb);\n}\n\n/** True when the visitor asked for reduced motion. False on the server. */\nfunction useReducedMotion(): boolean {\n  return useSyncExternalStore(\n    subscribeMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n// Each part carries only the CSS it needs, so any one of them works on its own.\n// Nothing is interpolated into these sheets; colours arrive as CSS variables.\nconst MOTION_CSS = \"@media (prefers-reduced-motion: reduce){.dp-motion{animation:none!important}}\";\n\n// revert-layer keeps an element's own rounded corners when a host page's focus rule flattens them.\nconst FOCUS_CSS = \".dp-scope :focus-visible,.dp-scope:focus-visible{outline:2px solid var(--dp-accent);outline-offset:3px;border-radius:revert-layer}\";\n\nconst RING_CSS = \"@keyframes dp-ring{from{box-shadow:0 0 0 0 rgb(var(--dp-accent-rgb) / 0.55)}to{box-shadow:0 0 0 16px rgb(var(--dp-accent-rgb) / 0)}}\";\n\n/** The accent as the CSS variables the focus ring and the keyframes read. */\nfunction accentVars(hex: string): CSSProperties {\n  const [r, g, b] = rgbOf(hex);\n  return { \"--dp-accent\": hex, \"--dp-accent-rgb\": `${r} ${g} ${b}` } as CSSProperties;\n}\n\n/** A point on a pill's outline at distance s along it, starting at the top-left of the top edge, clockwise. */\nfunction pillPoint(s: number, W: number, H: number) {\n  const r = H / 2;\n  const S = Math.max(0, W - H);\n  const half = Math.PI * r;\n  if (s < S) return { x: r + s, y: 0 };\n  s -= S;\n  if (s < half) {\n    const a = -Math.PI / 2 + s / r;\n    return { x: W - r + r * Math.cos(a), y: r + r * Math.sin(a) };\n  }\n  s -= half;\n  if (s < S) return { x: W - r - s, y: H };\n  s -= S;\n  const a = Math.PI / 2 + s / r;\n  return { x: r + r * Math.cos(a), y: r + r * Math.sin(a) };\n}\n\n/** The distance along a pill's outline nearest to a point inside or around it. */\nfunction pillDistance(x: number, y: number, W: number, H: number) {\n  const r = H / 2;\n  const S = Math.max(0, W - H);\n  const half = Math.PI * r;\n  if (x >= r && x <= W - r) return y < r ? x - r : S + half + (W - r - x);\n  if (x > W - r) {\n    const a = Math.atan2(y - r, x - (W - r));\n    return S + (a + Math.PI / 2) * r;\n  }\n  let a = Math.atan2(y - r, x - r);\n  if (a < 0) a += 2 * Math.PI;\n  return 2 * S + half + (a - Math.PI / 2) * r;\n}\n\nexport type OrbitLightButtonProps = {\n  children: ReactNode;\n  href?: string;\n  onClick?: () => void;\n  accent?: string;\n  size?: \"md\" | \"lg\";\n  /** \"orbit\": the light laps the border when idle; \"still\": it waits for the pointer. */\n  idle?: \"orbit\" | \"still\";\n  /** Seconds per lap when idle. */\n  lap?: number;\n  type?: \"button\" | \"submit\";\n  className?: string;\n};\n\n/**\n * A dark pill whose border carries one light. Idle, it laps the outline\n * slowly; under the pointer it glides to the nearest point of the border and\n * lights the surface from there; a press sends a ring outward. Keyboard focus\n * brings the light to the top edge.\n */\nexport function OrbitLightButton({ children, href, onClick, accent = \"ice\", size = \"lg\", idle = \"orbit\", lap = 32, type = \"button\", className }: OrbitLightButtonProps) {\n  const acc = accentHex(accent);\n  const reduced = useReducedMotion();\n  const hostRef = useRef<HTMLElement | null>(null);\n  const svgRef = useRef<SVGSVGElement>(null);\n  const [box, setBox] = useState({ w: 0, h: 0 });\n  const [rings, setRings] = useState(0);\n\n  useEffect(() => {\n    const el = hostRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setBox({ w: el.offsetWidth, h: el.offsetHeight }));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const el = hostRef.current;\n    const svg = svgRef.current;\n    if (!el || !svg || !box.w || !box.h) return;\n    const W = box.w - 1;\n    const H = box.h - 1;\n    const P = 2 * Math.max(0, W - H) + Math.PI * H;\n    const dashes = Array.from(svg.querySelectorAll<SVGRectElement>(\"rect[data-dash]\"));\n    let t = 0.1;\n    let target = -1;\n    let hover = false;\n    let raf = 0;\n    let last = 0;\n    let running = false;\n    let visible = true;\n    const orbiting = () => idle === \"orbit\" && !reduced && lap > 0;\n    const place = () => {\n      for (const d of dashes) {\n        const L = Number(d.dataset.dash) * P;\n        d.style.strokeDasharray = L + \" \" + (P - L);\n        d.style.strokeDashoffset = String(L / 2 - t * P);\n      }\n      const p = pillPoint(t * P, W, H);\n      el.style.setProperty(\"--ox\", (p.x + 0.5).toFixed(1) + \"px\");\n      el.style.setProperty(\"--oy\", (p.y + 0.5).toFixed(1) + \"px\");\n    };\n    const tick = (now: number) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;\n      last = now;\n      let settled = true;\n      if (target >= 0) {\n        let d = target - t;\n        d -= Math.round(d);\n        if (reduced) t = target;\n        else t += d * (1 - Math.exp(-dt * 9));\n        settled = Math.abs(d) < 0.0005;\n      } else if (orbiting()) {\n        t += dt / lap;\n        settled = false;\n      }\n      t = ((t % 1) + 1) % 1;\n      place();\n      if (visible && !settled) raf = requestAnimationFrame(tick);\n      else {\n        running = false;\n        last = 0;\n      }\n    };\n    const kick = () => {\n      if (running || !visible) return;\n      running = true;\n      raf = requestAnimationFrame(tick);\n    };\n    const onMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      const r = el.getBoundingClientRect();\n      const x = ((e.clientX - r.left) * el.offsetWidth) / (r.width || 1);\n      const y = ((e.clientY - r.top) * el.offsetHeight) / (r.height || 1);\n      target = pillDistance(x, y, W, H) / P;\n      hover = true;\n      el.style.setProperty(\"--lit\", \"1\");\n      kick();\n    };\n    const onLeave = () => {\n      hover = false;\n      target = -1;\n      el.style.setProperty(\"--lit\", idle === \"orbit\" ? \"0.7\" : \"0\");\n      kick();\n    };\n    const onFocus = () => {\n      if (!el.matches(\":focus-visible\")) return;\n      target = 0.12;\n      el.style.setProperty(\"--lit\", \"1\");\n      kick();\n    };\n    const onBlur = () => {\n      if (!hover) onLeave();\n    };\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[entries.length - 1].isIntersecting;\n      if (visible) kick();\n    });\n    io.observe(el);\n    el.style.setProperty(\"--lit\", idle === \"orbit\" ? \"0.7\" : \"0\");\n    place();\n    kick();\n    el.addEventListener(\"pointermove\", onMove);\n    el.addEventListener(\"pointerleave\", onLeave);\n    el.addEventListener(\"focus\", onFocus);\n    el.addEventListener(\"blur\", onBlur);\n    return () => {\n      cancelAnimationFrame(raf);\n      io.disconnect();\n      el.removeEventListener(\"pointermove\", onMove);\n      el.removeEventListener(\"pointerleave\", onLeave);\n      el.removeEventListener(\"focus\", onFocus);\n      el.removeEventListener(\"blur\", onBlur);\n    };\n  }, [box.w, box.h, idle, lap, reduced]);\n\n  const press = () => setRings((n) => n + 1);\n  const h = size === \"lg\" ? 48 : 40;\n  const cls =\n    \"dp-scope group/orbit relative inline-flex select-none items-center justify-center gap-2 rounded-full font-medium tracking-[-0.01em] transition-transform duration-150 active:scale-[0.985] \" +\n    (size === \"lg\" ? \"px-6 text-[15px]\" : \"px-5 text-[14px]\") +\n    (className ? \" \" + className : \"\");\n  const style: CSSProperties = {\n    ...accentVars(acc),\n    height: h,\n    color: INK,\n    background: `radial-gradient(90px circle at var(--ox, 20%) var(--oy, 0%), ${rgba(acc, 0.2)}, transparent 70%), linear-gradient(180deg, #16161a, #0c0c0f)`,\n    boxShadow: \"inset 0 1px 0 rgba(255,255,255,0.06)\",\n    fontFamily: SANS,\n    WebkitTapHighlightColor: \"transparent\",\n  };\n  const inner = (\n    <>\n      <svg ref={svgRef} aria-hidden className=\"pointer-events-none absolute inset-0 overflow-visible\" width={box.w || 1} height={box.h || 1}>\n        {box.w > 0 && (\n          <>\n            <rect x=\"0.5\" y=\"0.5\" width={box.w - 1} height={box.h - 1} rx={(box.h - 1) / 2} fill=\"none\" stroke={LINE_2} />\n            <g style={{ opacity: \"var(--lit, 0.7)\", transition: \"opacity 280ms ease\" }}>\n              <rect data-dash=\"0.24\" x=\"0.5\" y=\"0.5\" width={box.w - 1} height={box.h - 1} rx={(box.h - 1) / 2} fill=\"none\" stroke={acc} strokeWidth=\"5\" strokeLinecap=\"round\" style={{ opacity: 0.32, filter: \"blur(3px)\" }} />\n              <rect data-dash=\"0.14\" x=\"0.5\" y=\"0.5\" width={box.w - 1} height={box.h - 1} rx={(box.h - 1) / 2} fill=\"none\" stroke={acc} strokeWidth=\"1.25\" strokeLinecap=\"round\" />\n              <rect data-dash=\"0.035\" x=\"0.5\" y=\"0.5\" width={box.w - 1} height={box.h - 1} rx={(box.h - 1) / 2} fill=\"none\" stroke=\"#ffffff\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n            </g>\n          </>\n        )}\n      </svg>\n      {rings > 0 && !reduced && <span key={rings} aria-hidden className=\"dp-motion pointer-events-none absolute inset-0 rounded-full\" style={{ animation: `dp-ring 700ms ${MORPH} both` }} />}\n      <span className=\"relative z-10 inline-flex items-center gap-2\">\n        {children}\n        <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" aria-hidden className=\"transition-transform duration-200 group-hover/orbit:translate-x-0.5\">\n          <path d=\"M2.5 7h8.5M7.5 3.5 11 7l-3.5 3.5\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n        </svg>\n      </span>\n    </>\n  );\n  const onKeyDown = (e: ReactKeyboardEvent) => {\n    if (e.key === \"Enter\" || e.key === \" \") press();\n  };\n  if (href) {\n    return (\n      <>\n        <style>{FOCUS_CSS + RING_CSS + MOTION_CSS}</style>\n        <a ref={(n) => { hostRef.current = n; }} href={safeHref(href)} onClick={onClick} onPointerDown={press} onKeyDown={onKeyDown} className={cls} style={style}>\n          {inner}\n        </a>\n      </>\n    );\n  }\n  return (\n    <>\n      <style>{FOCUS_CSS + RING_CSS + MOTION_CSS}</style>\n      <button ref={(n) => { hostRef.current = n; }} type={type} onClick={onClick} onPointerDown={press} onKeyDown={onKeyDown} className={cls} style={style}>\n        {inner}\n      </button>\n    </>\n  );\n}\n","type":"registry:component","target":"components/viberdy/orbit-light-button.tsx"}],"meta":{"tier":"free","tags":["button","cta","border beam","light","pointer","svg","dark","moving border"],"href":"https://viberdy.dev/library/orbit-light-button","aiPrompt":"Build a React + Tailwind call-to-action button, `OrbitLightButton({ children, href, onClick, accent, size, idle, lap, type })`, a dark pill (linear-gradient #16161a to #0c0c0f, white 92% text, 48px or 40px tall) whose outline carries one light.\n\nMeasure the pill in layout pixels with a ResizeObserver and draw an SVG over it: a rect with rx = height/2 stroked in white 14% as the hairline, then the same rect three times with dash arrays for the light: a 24%-of-perimeter dash, 5px wide, blurred, at 32% opacity; a 14% dash in the accent at 1.25px; a 3.5% white dash at 1.5px. Place all three centred on a parameter t (0-1 around the perimeter, starting at the top edge's left end, clockwise, as an SVG rect path runs) with strokeDashoffset = dash/2 - t * perimeter.\n\nMotion in one rAF loop: idle, t advances by 1/lap per second (idle=\"orbit\") or rests; on pointermove, compute the nearest perimeter distance analytically (top and bottom straights, then the half-circle ends via atan2) and ease t toward it the short way round (exponential, about 350ms); on keyboard focus, target the top edge. Write t to the dashes and the light's point to --ox/--oy, which a radial-gradient on the background reads to light the surface. Stop the loop when settled or off-screen. A press (pointerdown, Enter or Space) remounts a span that animates an expanding box-shadow ring. Render the link or button with its own small <style> for the ring keyframes and focus rule beside it. Reduced motion: no lap, no ring.\n\nDark Precision grammar: ground #050505, surfaces #0b0b0d and #121215, hairlines white 6% at rest and 14% lit (drawn as inset box-shadows or SVG strokes, never border colours), ink white 92/60/38%, one cool accent used only as light (default #4fd1ff; mint #5ef2c1, amber #ffb45e, white #f2f4f7 or any #rrggbb, validated against /^#[0-9a-fA-F]{6}$/), Geist never heavier than 600 with tight tracking, Geist Mono only for captions and data, fonts through CSS variables with fallbacks (var(--font-sans, \"Geist\", ...)). Morphs use cubic-bezier(0.16, 1, 0.3, 1); ambient loops are 30s or slower. Keyframes live in a small static <style> the component renders itself (nothing interpolated into it; the accent reaches it as --dp-accent and --dp-accent-rgb variables), plus a focus ring rule scoped to a .dp-scope class with border-radius: revert-layer. Respect prefers-reduced-motion. No dependencies beyond React.","license":"https://viberdy.dev/license"}}