{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"spotlight-grid","type":"registry:component","title":"Spotlight Card Grid","description":"A bento of dark cards lit by one shared light: it crosses the gaps, glows on card borders, brightens each card’s illustration and drifts on its own until you take over.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/spotlight-grid.tsx","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\n\nexport type SpotlightItem = {\n  title: string;\n  body: string;\n  /** Small illustration. Draw it with currentColor for the accent; it brightens as the light reaches the card. */\n  icon?: ReactNode;\n};\n\nconst BASE = \"rgba(255,255,255,0.5)\";\nconst FAINT = \"rgba(255,255,255,0.22)\";\n\nconst svgProps = {\n  viewBox: \"0 0 160 44\",\n  fill: \"none\",\n  strokeWidth: 1.5,\n  strokeLinecap: \"round\" as const,\n  strokeLinejoin: \"round\" as const,\n  \"aria-hidden\": true,\n  className: \"block h-11 w-auto max-w-full\",\n};\n\nconst DEFAULT_ITEMS: SpotlightItem[] = [\n  {\n    title: \"Realtime sync\",\n    body: \"Every edit lands on every device in under a frame of latency, with conflicts merged for you.\",\n    icon: (\n      <svg {...svgProps}>\n        <path d=\"M2 30c14 0 18-20 34-18s20 22 38 14 26-22 44-16 30 8 42 2\" stroke={BASE} />\n        <path d=\"M2 36c16 0 22-10 38-8s24 10 40 4 30-16 44-12 24 6 34 4\" stroke={FAINT} strokeDasharray=\"2 5\" />\n        <circle cx=\"36\" cy=\"12\" r=\"2.5\" fill=\"#0b0c0f\" stroke={BASE} />\n        <circle cx=\"118\" cy=\"10\" r=\"2.5\" fill=\"#0b0c0f\" stroke={BASE} />\n        <circle cx=\"74\" cy=\"26\" r=\"6\" stroke=\"currentColor\" opacity=\"0.4\" />\n        <circle cx=\"74\" cy=\"26\" r=\"2.5\" fill=\"currentColor\" stroke=\"none\" />\n      </svg>\n    ),\n  },\n  {\n    title: \"Branching\",\n    body: \"Try a change in isolation, then merge it back.\",\n    icon: (\n      <svg {...svgProps}>\n        <path d=\"M18 40V6\" stroke={BASE} />\n        <path d=\"M18 30c0-10 8-14 22-14h40\" stroke={BASE} />\n        <path d=\"M80 16c10 0 14 6 14 14\" stroke={FAINT} strokeDasharray=\"2 5\" />\n        <circle cx=\"18\" cy=\"40\" r=\"2.5\" fill=\"#0b0c0f\" stroke={BASE} />\n        <circle cx=\"18\" cy=\"6\" r=\"2.5\" fill=\"#0b0c0f\" stroke={BASE} />\n        <circle cx=\"80\" cy=\"16\" r=\"4.5\" fill=\"currentColor\" stroke=\"none\" />\n      </svg>\n    ),\n  },\n  {\n    title: \"Scoped access\",\n    body: \"Keys that can only reach what you hand them.\",\n    icon: (\n      <svg {...svgProps}>\n        <circle cx=\"40\" cy=\"22\" r=\"19\" stroke={FAINT} />\n        <circle cx=\"40\" cy=\"22\" r=\"12.5\" stroke={BASE} />\n        <circle cx=\"40\" cy=\"22\" r=\"6\" stroke={BASE} />\n        <circle cx=\"53\" cy=\"13\" r=\"3.2\" fill=\"currentColor\" stroke=\"none\" />\n      </svg>\n    ),\n  },\n  {\n    title: \"Edge cache\",\n    body: \"Responses served from the nearest of 300 regions.\",\n    icon: (\n      <svg {...svgProps}>\n        <path d=\"M40 4 70 14 40 24 10 14Z\" stroke={FAINT} />\n        <path d=\"M10 22 40 32 70 22\" stroke={FAINT} />\n        <path d=\"M10 30 40 40 70 30\" stroke={BASE} />\n        <path d=\"M40 14 55 19 40 24 25 19Z\" fill=\"currentColor\" stroke=\"none\" />\n      </svg>\n    ),\n  },\n  {\n    title: \"Audit trail\",\n    body: \"Who changed what, and when, kept for good.\",\n    icon: (\n      <svg {...svgProps}>\n        <circle cx=\"10\" cy=\"9\" r=\"2.5\" fill=\"currentColor\" stroke=\"none\" />\n        <path d=\"M22 9h70\" stroke={BASE} />\n        <circle cx=\"10\" cy=\"22\" r=\"2.5\" stroke={BASE} />\n        <path d=\"M22 22h104\" stroke={FAINT} />\n        <circle cx=\"10\" cy=\"35\" r=\"2.5\" stroke={BASE} />\n        <path d=\"M22 35h52\" stroke={FAINT} />\n      </svg>\n    ),\n  },\n];\n\ntype Props = {\n  /** Reach of the light in px. */\n  radius?: number;\n  /** Grid columns (1-4). The first card spans two when there is room. */\n  columns?: number;\n  /** Peak alpha of the surface wash, 0-1. */\n  intensity?: number;\n  /** Light colour as #rrggbb. */\n  color?: string;\n  /** Let the light drift slowly while nobody is pointing at the grid. */\n  idle?: boolean;\n  items?: SpotlightItem[];\n  className?: string;\n};\n\nfunction parseColor(c: string): [number, number, number] {\n  const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(c);\n  if (!m) return [255, 45, 45];\n  return [parseInt(m[1] ?? \"ff\", 16), parseInt(m[2] ?? \"2d\", 16), parseInt(m[3] ?? \"2d\", 16)];\n}\n\nexport function SpotlightGrid({\n  radius = 220,\n  columns = 2,\n  intensity = 0.16,\n  color = \"#ff2d2d\",\n  idle = true,\n  items = DEFAULT_ITEMS,\n  className = \"\",\n}: Props) {\n  const rootRef = useRef<HTMLUListElement>(null);\n  // Shared input state: written by handlers, read by the frame loop.\n  const ptr = useRef({ hover: false, x: 0, y: 0, focus: false, fx: 0, fy: 0 });\n  const wakeRef = useRef<() => void>(() => {});\n  const live = useRef({ idle, radius });\n\n  const cols = Math.max(1, Math.min(4, Math.round(columns) || 1));\n  const [r, g, b] = parseColor(color);\n  const n = items.length;\n\n  // Bento spans: the first card is wide, the last one soaks up any gap in the final row.\n  const spans = items.map((_, i): number => (i === 0 && cols >= 2 && n > 2 ? 2 : 1));\n  const total = spans.reduce((s, v) => s + v, 0);\n  if (n > 1 && cols > 1) spans[n - 1] = (spans[n - 1] ?? 1) + ((cols - (total % cols)) % cols);\n\n  useEffect(() => {\n    live.current = { idle, radius };\n    wakeRef.current();\n  }, [idle, radius]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const cards = Array.from(root.querySelectorAll<HTMLElement>(\"[data-spot]\"));\n    let rects: { l: number; t: number; w: number; h: number }[] = [];\n    let W = 1;\n    let H = 1;\n    const measure = () => {\n      W = root.offsetWidth || 1;\n      H = root.offsetHeight || 1;\n      rects = cards.map((c) => ({ l: c.offsetLeft, t: c.offsetTop, w: c.offsetWidth, h: c.offsetHeight }));\n    };\n    measure();\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n    let x = W * 0.3;\n    let y = H * 0.34;\n    let o = 0;\n    let last = 0;\n    let raf = 0;\n    let visible = true;\n\n    const apply = () => {\n      root.style.setProperty(\"--gx\", x.toFixed(1) + \"px\");\n      root.style.setProperty(\"--gy\", y.toFixed(1) + \"px\");\n      root.style.setProperty(\"--o\", o.toFixed(3));\n      const reach = live.current.radius * 1.2;\n      cards.forEach((c, i) => {\n        const q = rects[i];\n        if (!q) return;\n        const dx = Math.max(q.l - x, 0, x - q.l - q.w);\n        const dy = Math.max(q.t - y, 0, y - q.t - q.h);\n        const lit = Math.pow(Math.max(0, 1 - Math.hypot(dx, dy) / reach), 1.6) * o;\n        c.style.setProperty(\"--x\", (x - q.l).toFixed(1) + \"px\");\n        c.style.setProperty(\"--y\", (y - q.t).toFixed(1) + \"px\");\n        c.style.setProperty(\"--lit\", lit.toFixed(3));\n      });\n    };\n\n    const frame = (ts: number) => {\n      raf = 0;\n      if (!visible) return;\n      const dt = last ? Math.min(0.05, (ts - last) / 1000) : 0.016;\n      last = ts;\n      const p = ptr.current;\n      const drifting = live.current.idle && !p.hover && !p.focus && !reduced;\n      let tx = x;\n      let ty = y;\n      let to = 0;\n      let rate = 4;\n      if (p.hover) {\n        tx = p.x;\n        ty = p.y;\n        to = 1;\n        rate = reduced ? 60 : 16;\n      } else if (p.focus) {\n        tx = p.fx;\n        ty = p.fy;\n        to = 1;\n        rate = reduced ? 60 : 8;\n      } else if (live.current.idle) {\n        const s = ts / 1000;\n        tx = reduced ? W * 0.3 : W * (0.5 + 0.36 * Math.sin(s * 0.37));\n        ty = reduced ? H * 0.34 : H * (0.5 + 0.3 * Math.sin(s * 0.53 + 1.2));\n        to = 0.85;\n        rate = reduced ? 60 : 3;\n      }\n      const k = 1 - Math.exp(-dt * rate);\n      x += (tx - x) * k;\n      y += (ty - y) * k;\n      o += (to - o) * (1 - Math.exp(-dt * (reduced ? 60 : 7)));\n      apply();\n      const settled = Math.abs(tx - x) < 0.3 && Math.abs(ty - y) < 0.3 && Math.abs(to - o) < 0.004;\n      if (!settled || drifting) raf = requestAnimationFrame(frame);\n      else last = 0;\n    };\n\n    const wake = () => {\n      if (raf || !visible) return;\n      last = 0;\n      raf = requestAnimationFrame(frame);\n    };\n    wakeRef.current = wake;\n\n    const onMq = () => {\n      reduced = mq.matches;\n      wake();\n    };\n    mq.addEventListener(\"change\", onMq);\n    const ro = new ResizeObserver(() => {\n      measure();\n      wake();\n    });\n    ro.observe(root);\n    const io = new IntersectionObserver(\n      (entries) => {\n        visible = entries.some((e) => e.isIntersecting);\n        if (visible) wake();\n        else if (raf) {\n          cancelAnimationFrame(raf);\n          raf = 0;\n        }\n      },\n      { rootMargin: \"100px\" },\n    );\n    io.observe(root);\n    wake();\n\n    return () => {\n      wakeRef.current = () => {};\n      if (raf) cancelAnimationFrame(raf);\n      mq.removeEventListener(\"change\", onMq);\n      ro.disconnect();\n      io.disconnect();\n    };\n  }, [n, cols]);\n\n  const local = (e: ReactPointerEvent<HTMLUListElement>) => {\n    const el = rootRef.current;\n    if (!el) return;\n    const box = el.getBoundingClientRect();\n    ptr.current.x = (e.clientX - box.left) * (el.offsetWidth / (box.width || 1));\n    ptr.current.y = (e.clientY - box.top) * (el.offsetHeight / (box.height || 1));\n  };\n  const enter = (e: ReactPointerEvent<HTMLUListElement>) => {\n    local(e);\n    ptr.current.hover = true;\n    wakeRef.current();\n  };\n  const leave = () => {\n    ptr.current.hover = false;\n    wakeRef.current();\n  };\n\n  const wash = \"radial-gradient(\" + radius + \"px circle at var(--x, 50%) var(--y, 50%), rgba(\" + r + \",\" + g + \",\" + b + \",\" + intensity + \"), transparent 70%)\";\n  const edgeA = Math.min(1, (intensity / 0.16) * 1).toFixed(2);\n  const edge =\n    \"radial-gradient(\" + Math.round(radius * 0.45) + \"px circle at var(--x, 50%) var(--y, 50%), rgba(255,255,255,\" + (Number(edgeA) * 0.7).toFixed(2) + \"), transparent 100%), \" +\n    \"radial-gradient(\" + Math.round(radius * 0.95) + \"px circle at var(--x, 50%) var(--y, 50%), rgba(\" + r + \",\" + g + \",\" + b + \",\" + edgeA + \"), transparent 72%)\";\n  const bleed =\n    \"radial-gradient(\" + Math.round(radius * 1.15) + \"px circle at calc(var(--gx, 50%) + 24px) calc(var(--gy, 50%) + 24px), rgba(\" + r + \",\" + g + \",\" + b + \",\" + (intensity * 0.85).toFixed(3) + \"), transparent 70%)\";\n\n  return (\n    <ul\n      ref={rootRef}\n      onPointerEnter={enter}\n      onPointerMove={(e) => {\n        local(e);\n        if (!ptr.current.hover) ptr.current.hover = true;\n        wakeRef.current();\n      }}\n      onPointerDown={enter}\n      onPointerUp={(e) => {\n        if (e.pointerType !== \"mouse\") leave();\n      }}\n      onPointerCancel={leave}\n      onPointerLeave={leave}\n      className={\"relative m-0 grid w-full list-none gap-3 p-0 \" + className}\n      style={{ gridTemplateColumns: \"repeat(\" + cols + \", minmax(0, 1fr))\", touchAction: \"pan-y\" }}\n    >\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute -inset-6\"\n        style={{ background: bleed, opacity: \"var(--o, 0)\" }}\n      />\n      {items.map((item, i) => (\n        <li\n          key={item.title}\n          data-spot=\"\"\n          tabIndex={0}\n          onFocus={(e) => {\n            const t = e.currentTarget;\n            ptr.current.focus = true;\n            ptr.current.fx = t.offsetLeft + t.offsetWidth / 2;\n            ptr.current.fy = t.offsetTop + t.offsetHeight / 2;\n            wakeRef.current();\n          }}\n          onBlur={() => {\n            ptr.current.focus = false;\n            wakeRef.current();\n          }}\n          className=\"group relative z-10 flex min-h-[148px] flex-col rounded-2xl p-4 text-left outline-none transition-[translate] duration-300 ease-[cubic-bezier(.2,.8,.2,1)] hover:-translate-y-0.5 active:translate-y-0 focus-visible:!rounded-2xl focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-white/70 motion-reduce:transition-none motion-reduce:hover:translate-y-0 @md:p-5\"\n          style={{\n            gridColumn: \"span \" + (spans[i] ?? 1) + \" / span \" + (spans[i] ?? 1),\n            background: \"linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.012)), #0b0c0f\",\n            border: \"1px solid rgba(255,255,255,0.07)\",\n            boxShadow: \"0 20px 40px -22px rgb(\" + r + \" \" + g + \" \" + b + \" / calc(var(--lit, 0) * 0.45))\",\n          }}\n        >\n          <span\n            aria-hidden\n            className=\"pointer-events-none absolute inset-0 rounded-[15px]\"\n            style={{ background: wash, opacity: \"var(--o, 0)\" }}\n          />\n          <span\n            aria-hidden\n            className=\"pointer-events-none absolute -inset-px rounded-2xl p-px\"\n            style={{\n              background: edge,\n              opacity: \"var(--o, 0)\",\n              WebkitMask: \"linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)\",\n              WebkitMaskComposite: \"xor\",\n              mask: \"linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)\",\n              maskComposite: \"exclude\",\n            }}\n          />\n          <div\n            aria-hidden\n            className=\"relative mb-auto pb-4\"\n            style={{ color: \"rgb(\" + r + \",\" + g + \",\" + b + \")\", opacity: \"calc(0.55 + var(--lit, 0) * 0.45)\" }}\n          >\n            {item.icon}\n          </div>\n          <div className=\"relative\">\n            <h4 className=\"m-0 text-[15px] font-semibold tracking-tight text-white/90\">{item.title}</h4>\n            <p className=\"m-0 mt-1 text-[13px] leading-5 text-white/50\">{item.body}</p>\n          </div>\n        </li>\n      ))}\n    </ul>\n  );\n}\n","type":"registry:component","target":"components/viberdy/spotlight-grid.tsx"}],"meta":{"tier":"free","tags":["grid","spotlight","cursor","hover","bento","glow","border","touch"],"href":"https://viberdy.dev/library/spotlight-grid","aiPrompt":"Create a React + Tailwind v4 + TypeScript component named SpotlightGrid (one \"use client\" file, React only, inline SVG, no colour tokens: paint everything with plain colours).\n\nProps: radius (default 220, px reach), columns (default 2, clamped 1-4), intensity (default 0.16, peak alpha of the surface wash), color (default \"#ff2d2d\", validate with /^#[0-9a-f]{6}$/i, fall back to the default), idle (default true), items ({title, body, icon?: ReactNode}[], default five demo cards: Realtime sync, Branching, Scoped access, Edge cache, Audit trail, each with a 160x44 line illustration drawn in rgba(255,255,255,.5) with one accent in currentColor), className.\n\nRoot: a <ul> (list-none, w-full, gap 12px, grid with repeat(columns, minmax(0,1fr)), touch-action pan-y). Bento spans: with 2+ columns and 3+ items the first card spans 2; the last card spans extra so the final row has no hole. Cards are <li tabIndex=0>, min-height 148px, radius 16px, padding 16px (20px in a container >= md), background linear-gradient(180deg, white/4%, white/1.2%) over #0b0c0f, 1px white/7% border (inline). Title 15px/600 white/90, body 13px/20px white/50, illustration on top (pb 16px).\n\nOne shared light: a single point (x, y) in the root's coordinate space. Pointer coords map with (clientX - rect.left) * (root.offsetWidth / rect.width) so a scaled-down preview still works. Everything is driven by one rAF loop inside one mount effect (props read through a ref): each frame the point eases toward its target with k = 1 - exp(-dt * rate) (rate 16 following the pointer, 8 for focus, 3 while drifting) and the fade o eases to its target at rate 7 (1 pointer or focus, 0.85 idle drift, 0 off). It writes CSS variables only, never React state: on the root --gx --gy --o, on each card --x --y (the point relative to the card, from offsetLeft/offsetTop measured in a ResizeObserver) and --lit = (1 - distance(point, card rect) / (radius * 1.2)) ^ 1.6 clamped to 0..1, times o. The loop sleeps once position and fade have settled and idle is off, when an IntersectionObserver (rootMargin 100px) says the root is off screen.\n\nIdle drift: x = W * (0.5 + 0.36 sin(0.37 t)), y = H * (0.5 + 0.3 sin(0.53 t + 1.2)). Pointer enter/move/down sets hover, leave/cancel clears it, pointerup clears it for touch only. Focus on a card sets the target to the card's centre; blur clears it.\n\nEach card paints three absolute layers above its background, all with opacity var(--o): (1) a wash: radial-gradient(radius px circle at var(--x) var(--y), color at intensity alpha, transparent 70%), radius 15px; (2) a border glow: inset -1px, padding 1px, background of a white core (0.45 * radius, alpha ~0.7 of the colour alpha) over the colour (0.95 * radius, alpha min(1, intensity/0.16), transparent 72%), masked to the border ring with mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0) + mask-composite exclude (and the -webkit-mask / xor pair); (3) the card box-shadow 0 20px 40px -22px colour at calc(var(--lit) * 0.45). The illustration wrapper has opacity calc(0.55 + var(--lit) * 0.45) and color = the light colour. Behind all cards a bleed layer (inset -24px) with a radial gradient of radius * 1.15 at (--gx + 24px, --gy + 24px), alpha intensity * 0.85, so light crosses the gaps.\n\nHover lifts the card with translate -2px over 300ms cubic-bezier(.2,.8,.2,1); active returns it. Focus ring: focus-visible important utilities, outline-2 offset-2 white/70, with the radius restated.\n\nprefers-reduced-motion (matchMedia in the effect, with a change listener): no drift (the light rests at 30% / 34%), rates become instant, lift removed. Touch: the light follows the finger and fades out on release; vertical scroll still works. Hydration-safe: all light variables have CSS fallbacks (opacity var(--o, 0)) and nothing random is rendered.","license":"https://viberdy.dev/license"}}