{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"live-cursors","type":"registry:component","title":"Live cursors","description":"Other people’s cursors gliding over the same page: names that turn into speech bubbles, click ripples, and selections that follow them around.","author":"Hairline UI","dependencies":[],"files":[{"path":"registry/live-cursors.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * LIVE CURSORS: other people, moving in the same place\n *\n *   move      positions arrive whenever your realtime layer sends\n *             them (ten a second is plenty); each cursor eases\n *             toward the latest on a spring, so it glides instead\n *             of stepping\n *   name      a pill hangs off each cursor with its colour; near\n *             the right or bottom edge it swings to the other side\n *             so it stays inside\n *   say       a message grows the pill into a bubble (one surface,\n *             size and corners easing together) and folds it back\n *             when it's cleared\n *   click     a hairline ring ripples out from the tip\n *   select    the box someone is working on gets a 1px outline in\n *             their colour that glides to whatever they pick next\n *   idle      after a while without moving, the name folds into\n *             the dot and the cursor dims; it comes back the moment\n *             they move\n *   you       with onMessage, press / and a bubble opens at your\n *             own pointer; Enter sends it, Escape closes it\n * ───────────────────────────────────────────────────────── */\n\nexport interface LiveCursorBox {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\nexport interface LiveCursor {\n  id: string;\n  name: string;\n  /** Position from 0 to 1 across and down the area. */\n  x: number;\n  y: number;\n  /** Any CSS colour; people take the chart colours in order by default. */\n  color?: string;\n  /** What they're saying right now; clear it to fold the bubble. */\n  message?: string;\n  /** Raise it by one for each click to show a ripple. */\n  clicks?: number;\n  /** The box they're working on, in the same 0–1 units. */\n  selection?: LiveCursorBox | null;\n}\n\nexport interface LiveCursorsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  cursors: LiveCursor[];\n  children?: React.ReactNode;\n  /** Milliseconds without moving before a cursor folds its name away. */\n  idleAfter?: number;\n  /** Your pointer over the area, 0–1, or null when it leaves; send it to the others. */\n  onCursorMove?: (point: { x: number; y: number } | null) => void;\n  /** Turns on \"/\" to say something at your pointer; called with what you send. */\n  onMessage?: (text: string) => void;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst PALETTE = [\"var(--chart-1)\", \"var(--chart-2)\", \"var(--chart-3)\", \"var(--chart-4)\", \"var(--chart-5)\"];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst finePointer = \"(pointer: fine)\";\nconst subscribeFine = (onChange: () => void) => {\n  const query = window.matchMedia(finePointer);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useFinePointer = () => React.useSyncExternalStore(subscribeFine, () => window.matchMedia(finePointer).matches, () => false);\n\nconst editable = (el: Element | null) =>\n  !!el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement).isContentEditable);\n\n/** A box that eases to the size of what's inside it, so a pill can grow into a bubble. */\nfunction Shape({ children, className, style, reduced }: { children: React.ReactNode; className: string; style?: React.CSSProperties; reduced: boolean }) {\n  const inner = React.useRef<HTMLDivElement>(null);\n  const [size, setSize] = React.useState<{ w: number; h: number } | null>(null);\n  React.useLayoutEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const measure = () => setSize({ w: el.offsetWidth, h: el.offsetHeight });\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n  return (\n    <div\n      className={`overflow-hidden ${className}`}\n      style={{\n        ...style,\n        width: size?.w,\n        height: size?.h,\n        transition: reduced || !size ? \"none\" : `width 360ms ${EASE}, height 360ms ${EASE}, opacity 240ms`,\n      }}\n    >\n      <div ref={inner} className=\"w-max\">\n        {children}\n      </div>\n    </div>\n  );\n}\n\n/** A hairline ring rippling out from a cursor's tip; it removes itself when done. */\nfunction Ripple({ color, onDone }: { color: string; onDone: () => void }) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  const done = React.useRef(onDone);\n  done.current = onDone;\n  React.useEffect(() => {\n    const animation = ref.current?.animate(\n      [\n        { transform: \"translate(-50%, -50%) scale(0.3)\", opacity: 0.9 },\n        { transform: \"translate(-50%, -50%) scale(1)\", opacity: 0 },\n      ],\n      { duration: 620, easing: EASE, fill: \"forwards\" },\n    );\n    if (!animation) return done.current();\n    animation.onfinish = () => done.current();\n    return () => animation.cancel();\n  }, []);\n  return <span ref={ref} className=\"absolute left-0 top-0 size-9 rounded-full border\" style={{ borderColor: color }} />;\n}\n\n/** A message that blurs in when it changes. */\nfunction Said({ text }: { text: string }) {\n  const ref = React.useRef<HTMLParagraphElement>(null);\n  React.useEffect(() => {\n    ref.current?.animate([{ opacity: 0, filter: \"blur(4px)\" }, { opacity: 1, filter: \"blur(0px)\" }], { duration: 300, delay: 120, easing: EASE, fill: \"backwards\" });\n  }, [text]);\n  return (\n    <p ref={ref} className=\"mt-0.5 max-w-[200px] whitespace-normal text-[12px] leading-snug text-foreground\">\n      {text}\n    </p>\n  );\n}\n\nexport function LiveCursors({\n  cursors,\n  children,\n  idleAfter = 8000,\n  onCursorMove,\n  onMessage,\n  className = \"\",\n  ...props\n}: LiveCursorsProps) {\n  const reduced = useReducedMotion();\n  const fine = useFinePointer();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const els = React.useRef(new Map<string, HTMLDivElement>());\n  const sim = React.useRef(new Map<string, { x: number; y: number; vx: number; vy: number }>());\n  const targets = React.useRef(new Map<string, { x: number; y: number }>());\n  const area = React.useRef({ w: 0, h: 0 });\n  const frame = React.useRef(0);\n  const reducedRef = React.useRef(reduced);\n  reducedRef.current = reduced;\n\n  // Colour follows the person: each new id takes the next colour and keeps it.\n  const slots = React.useRef(new Map<string, number>());\n  const colorOf = (c: LiveCursor) => {\n    if (c.color) return c.color;\n    if (!slots.current.has(c.id)) slots.current.set(c.id, slots.current.size);\n    return PALETTE[slots.current.get(c.id)! % PALETTE.length];\n  };\n\n  // People who just left stay a moment to fade out.\n  const [leaving, setLeaving] = React.useState<LiveCursor[]>([]);\n  const previous = React.useRef<LiveCursor[]>([]);\n  const timers = React.useRef(new Set<number>());\n  React.useEffect(() => {\n    const now = new Set(cursors.map((c) => c.id));\n    const gone = previous.current.filter((c) => !now.has(c.id));\n    previous.current = cursors;\n    if (!gone.length) return;\n    setLeaving((l) => [...l.filter((c) => !now.has(c.id)), ...gone]);\n    // Not cleared when cursors update again (they do, ten times a second); only on unmount.\n    const timer = window.setTimeout(() => {\n      timers.current.delete(timer);\n      setLeaving((l) => l.filter((c) => !gone.some((g) => g.id === c.id)));\n      gone.forEach((g) => {\n        if (previous.current.some((c) => c.id === g.id)) return; // they came back\n        sim.current.delete(g.id);\n        targets.current.delete(g.id);\n      });\n    }, 320);\n    timers.current.add(timer);\n  }, [cursors]);\n  React.useEffect(() => {\n    const all = timers.current;\n    return () => all.forEach((t) => window.clearTimeout(t));\n  }, []);\n\n  // One loop moves every cursor toward its target on a spring, writing transforms directly.\n  const kick = React.useCallback(() => {\n    if (frame.current) return;\n    let last = performance.now();\n    const loop = (now: number) => {\n      const dt = Math.min(0.05, (now - last) / 1000);\n      last = now;\n      let busy = false;\n      for (const [id, t] of targets.current) {\n        const tx = t.x * area.current.w;\n        const ty = t.y * area.current.h;\n        let s = sim.current.get(id);\n        if (!s) sim.current.set(id, (s = { x: tx, y: ty, vx: 0, vy: 0 }));\n        if (reducedRef.current) Object.assign(s, { x: tx, y: ty, vx: 0, vy: 0 });\n        else {\n          s.vx += (150 * (tx - s.x) - 24 * s.vx) * dt;\n          s.vy += (150 * (ty - s.y) - 24 * s.vy) * dt;\n          s.x += s.vx * dt;\n          s.y += s.vy * dt;\n          if (Math.abs(tx - s.x) + Math.abs(ty - s.y) > 0.3 || Math.abs(s.vx) + Math.abs(s.vy) > 2) busy = true;\n          else Object.assign(s, { x: tx, y: ty, vx: 0, vy: 0 });\n        }\n        const el = els.current.get(id);\n        if (el) el.style.transform = `translate3d(${s.x}px, ${s.y}px, 0)`;\n      }\n      frame.current = busy ? requestAnimationFrame(loop) : 0;\n    };\n    frame.current = requestAnimationFrame(loop);\n  }, []);\n  React.useEffect(\n    () => () => {\n      cancelAnimationFrame(frame.current);\n      frame.current = 0; // so a remount can start the loop again\n    },\n    [],\n  );\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const measure = () => {\n      area.current = { w: root.clientWidth, h: root.clientHeight };\n      kick();\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [kick]);\n\n  // New targets; somebody new appears where they are rather than flying in from a corner.\n  const moved = React.useRef(new Map<string, number>());\n  React.useLayoutEffect(() => {\n    const now = Date.now();\n    for (const c of cursors) {\n      const t = targets.current.get(c.id);\n      if (!t || t.x !== c.x || t.y !== c.y) moved.current.set(c.id, now);\n      targets.current.set(c.id, { x: c.x, y: c.y });\n      if (!sim.current.has(c.id)) {\n        const s = { x: c.x * area.current.w, y: c.y * area.current.h, vx: 0, vy: 0 };\n        sim.current.set(c.id, s);\n        const el = els.current.get(c.id);\n        if (el) el.style.transform = `translate3d(${s.x}px, ${s.y}px, 0)`;\n      }\n    }\n    kick();\n  }, [cursors, kick]);\n\n  // Idle: fold the name away after a while without moving.\n  const [away, setAway] = React.useState<Set<string>>(() => new Set());\n  React.useEffect(() => {\n    const check = () => {\n      const now = Date.now();\n      const next = new Set(cursors.filter((c) => now - (moved.current.get(c.id) ?? now) > idleAfter && !c.message).map((c) => c.id));\n      setAway((a) => (a.size === next.size && [...next].every((id) => a.has(id)) ? a : next));\n    };\n    check();\n    const timer = window.setInterval(check, 1000);\n    return () => window.clearInterval(timer);\n  }, [cursors, idleAfter]);\n\n  // Clicks ripple from the tip.\n  const [ripples, setRipples] = React.useState<{ id: string; key: number }[]>([]);\n  const clicks = React.useRef(new Map<string, number>());\n  React.useEffect(() => {\n    const fresh: { id: string; key: number }[] = [];\n    for (const c of cursors) {\n      const seen = clicks.current.get(c.id);\n      if (seen !== undefined && (c.clicks ?? 0) > seen) fresh.push({ id: c.id, key: Math.random() });\n      clicks.current.set(c.id, c.clicks ?? 0);\n    }\n    if (fresh.length && !reduced) setRipples((r) => [...r, ...fresh]);\n  }, [cursors, reduced]);\n\n  // Messages are announced politely, once each.\n  const [announce, setAnnounce] = React.useState(\"\");\n  const said = React.useRef(new Map<string, string>());\n  React.useEffect(() => {\n    for (const c of cursors) {\n      if (c.message && said.current.get(c.id) !== c.message) setAnnounce(`${c.name}: ${c.message}`);\n      said.current.set(c.id, c.message ?? \"\");\n    }\n  }, [cursors]);\n\n  // You: your pointer goes out through onCursorMove, and \"/\" opens a bubble that follows it.\n  const pointer = React.useRef<{ x: number; y: number } | null>(null);\n  const selfRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const [chat, setChat] = React.useState<{ open: boolean; text: string; sent: string }>({ open: false, text: \"\", sent: \"\" });\n  const chatting = chat.open || Boolean(chat.sent);\n  const place = () => {\n    const p = pointer.current;\n    if (p && selfRef.current) selfRef.current.style.transform = `translate3d(${p.x}px, ${p.y}px, 0)`;\n  };\n  React.useEffect(() => {\n    if (!onMessage || !fine) return;\n    // Caught on the way down, and only while the pointer is over the area, so a page's own \"/\" shortcut\n    // (search, usually) still works everywhere else.\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"/\" || e.metaKey || e.ctrlKey || e.altKey || !pointer.current || chat.open || editable(document.activeElement)) return;\n      e.preventDefault();\n      e.stopImmediatePropagation();\n      setChat({ open: true, text: \"\", sent: \"\" });\n    };\n    window.addEventListener(\"keydown\", onKey, true);\n    return () => window.removeEventListener(\"keydown\", onKey, true);\n  }, [onMessage, fine, chat.open]);\n  React.useLayoutEffect(() => {\n    place();\n    if (chat.open) inputRef.current?.focus({ preventScroll: true });\n  }, [chat.open]);\n  React.useEffect(() => {\n    if (!chat.sent) return;\n    const timer = window.setTimeout(() => setChat((c) => ({ ...c, sent: \"\" })), 4000);\n    return () => window.clearTimeout(timer);\n  }, [chat.sent]);\n\n  const all = [...cursors.map((c) => ({ c, gone: false })), ...leaving.filter((l) => !cursors.some((c) => c.id === l.id)).map((c) => ({ c, gone: true }))];\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative ${className}`}\n      onPointerMove={(e) => {\n        if (e.pointerType !== \"mouse\") return;\n        const r = e.currentTarget.getBoundingClientRect();\n        pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top };\n        place();\n        onCursorMove?.({ x: pointer.current.x / r.width, y: pointer.current.y / r.height });\n      }}\n      onPointerLeave={(e) => {\n        if (e.pointerType !== \"mouse\") return;\n        pointer.current = null;\n        onCursorMove?.(null);\n      }}\n      {...props}\n    >\n      {children}\n\n      {/* What others have selected: a hairline box that glides to their next pick. */}\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n        {all.map(({ c, gone }) =>\n          c.selection ? (\n            <div\n              key={`sel-${c.id}`}\n              className=\"absolute rounded-[6px] border\"\n              style={{\n                left: `${c.selection.x * 100}%`,\n                top: `${c.selection.y * 100}%`,\n                width: `${c.selection.width * 100}%`,\n                height: `${c.selection.height * 100}%`,\n                borderColor: colorOf(c),\n                opacity: gone ? 0 : 1,\n                transition: reduced ? \"none\" : `left 420ms ${EASE}, top 420ms ${EASE}, width 420ms ${EASE}, height 420ms ${EASE}, opacity 240ms`,\n              }}\n            >\n              <span\n                className=\"absolute left-2 top-0 flex -translate-y-1/2 items-center gap-1 rounded-full border bg-popover px-1.5 text-[10.5px] font-medium leading-4 text-foreground\"\n                style={{ borderColor: colorOf(c) }}\n              >\n                <span className=\"size-1.5 rounded-full\" style={{ backgroundColor: colorOf(c) }} />\n                {c.name}\n              </span>\n            </div>\n          ) : null,\n        )}\n      </div>\n\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n        {all.map(({ c, gone }) => {\n          const color = colorOf(c);\n          const left = c.x > 0.72;\n          const up = c.y > 0.82;\n          const folded = away.has(c.id) && !gone;\n          return (\n            <div\n              key={c.id}\n              ref={(el) => {\n                if (!el) return void els.current.delete(c.id);\n                els.current.set(c.id, el);\n                const s = sim.current.get(c.id);\n                if (s) el.style.transform = `translate3d(${s.x}px, ${s.y}px, 0)`;\n              }}\n              className=\"absolute left-0 top-0 will-change-transform\"\n            >\n              <div\n                style={{\n                  opacity: gone ? 0 : folded ? 0.55 : 1,\n                  transform: gone ? \"scale(0.6)\" : \"none\",\n                  transformOrigin: \"0 0\",\n                  transition: reduced ? \"none\" : `opacity 260ms ${EASE}, transform 260ms ${EASE}`,\n                }}\n              >\n                {ripples\n                  .filter((r) => r.id === c.id)\n                  .map((r) => (\n                    <Ripple key={r.key} color={color} onDone={() => setRipples((all) => all.filter((x) => x.key !== r.key))} />\n                  ))}\n                <svg width=\"18\" height=\"18\" viewBox=\"0 0 18 18\" className=\"absolute -left-px -top-px overflow-visible\">\n                  <path d=\"M1.5 1.5 L15 7.6 L8.9 9.3 L6.4 15.6 Z\" fill={color} stroke=\"var(--background)\" strokeWidth=\"1.5\" strokeLinejoin=\"round\" />\n                </svg>\n                {/* The name pill: swings to the other side near an edge, grows into a bubble to say something. */}\n                <div\n                  className=\"absolute\"\n                  style={{\n                    left: left ? -4 : 12,\n                    top: up ? -8 : 18,\n                    transform: `translate(${left ? \"-100%\" : \"0\"}, ${up ? \"-100%\" : \"0\"})`,\n                    transition: reduced ? \"none\" : `left 300ms ${EASE}, top 300ms ${EASE}, transform 300ms ${EASE}`,\n                  }}\n                >\n                  <Shape\n                    reduced={reduced}\n                    className=\"border bg-popover\"\n                    style={{ borderColor: color, borderRadius: 12 }}\n                  >\n                    <div className={c.message ? \"px-2.5 py-1.5\" : \"px-1.5 py-[3px]\"}>\n                      <div className=\"flex items-center gap-1.5\">\n                        <span className=\"size-2 shrink-0 rounded-full\" style={{ backgroundColor: color }} />\n                        {!folded && <span className=\"pr-0.5 text-[11.5px] font-medium leading-4 text-foreground\">{c.name}</span>}\n                      </div>\n                      {c.message && !gone && <Said text={c.message} />}\n                    </div>\n                  </Shape>\n                </div>\n              </div>\n            </div>\n          );\n        })}\n      </div>\n\n      {onMessage && fine && (\n        <div ref={selfRef} className=\"pointer-events-none absolute left-0 top-0 z-10\" aria-hidden={!chatting}>\n          <div\n            className=\"absolute left-4 top-5\"\n            style={{\n              opacity: chatting ? 1 : 0,\n              transform: chatting ? \"none\" : \"scale(0.8)\",\n              transformOrigin: \"0 0\",\n              transition: reduced ? \"none\" : `opacity 200ms ${EASE}, transform 260ms ${EASE}`,\n            }}\n          >\n            <Shape reduced={reduced} className=\"rounded-[12px] border border-border bg-popover\">\n              <div className=\"px-2.5 py-1.5\">\n                {chat.open ? (\n                  <input\n                    ref={inputRef}\n                    value={chat.text}\n                    aria-label=\"Say something\"\n                    placeholder=\"Say something\"\n                    maxLength={120}\n                    onChange={(e) => setChat((c) => ({ ...c, text: e.target.value }))}\n                    onBlur={() => setChat((c) => (c.open ? { open: false, text: \"\", sent: \"\" } : c))}\n                    onKeyDown={(e) => {\n                      if (e.key === \"Escape\") setChat({ open: false, text: \"\", sent: \"\" });\n                      if (e.key !== \"Enter\") return;\n                      const text = chat.text.trim();\n                      setChat({ open: false, text: \"\", sent: text });\n                      if (text) onMessage(text);\n                    }}\n                    className=\"pointer-events-auto bg-transparent text-[12.5px] text-foreground outline-none placeholder:text-muted-foreground\"\n                    style={{ width: `${Math.min(28, Math.max(12, chat.text.length + 1))}ch` }}\n                  />\n                ) : (\n                  <p className=\"max-w-[220px] text-[12.5px] leading-snug text-foreground\">{chat.sent}</p>\n                )}\n              </div>\n            </Shape>\n          </div>\n        </div>\n      )}\n\n      <p className=\"sr-only\" role=\"status\">\n        {announce}\n      </p>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/live-cursors"}