{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "live-cursors",
  "type": "registry:ui",
  "title": "Live Cursors",
  "description": "Teammates' pointers glide between network updates; idle names tuck into initials.",
  "categories": [
    "collaboration"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/live-cursors.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\nexport type LiveCursor = {\n  /** Stable id from your presence channel. */\n  id: string;\n  name: string;\n  /** Horizontal position as a fraction of the layer, 0 at the left edge and 1 at the right. */\n  x: number;\n  /** Vertical position as a fraction of the layer, 0 at the top and 1 at the bottom. */\n  y: number;\n  /** Any CSS color for the arrow and label. Defaults to the foreground. */\n  color?: string;\n};\n\n// Tuned for updates arriving 10–30 times a second: fast enough to keep up with a hand,\n// soft enough that the gaps between network frames read as one continuous movement.\nconst glide = { stiffness: 320, damping: 32, mass: 0.6 };\n\nexport type LiveCursorsProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Everyone else's cursor. Never include the viewer's own. */\n  cursors: LiveCursor[];\n  /** Milliseconds without movement before a name tucks away to an initial. */\n  idleAfter?: number;\n};\n\n/**\n * Other people's pointers over a shared surface. Positions are fractions of the layer, so a\n * cursor lands on the same spot however wide each person's window is. Place it inside the\n * element it covers; that element needs `position: relative`.\n */\nexport function LiveCursors({ cursors, idleAfter = 2500, className, ...rest }: LiveCursorsProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const [size, setSize] = useState<{ w: number; h: number } | null>(null);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setSize({ w: entry.contentRect.width, h: entry.contentRect.height }));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  return (\n    <div ref={ref} aria-hidden className={cn(\"pointer-events-none absolute inset-0 overflow-hidden\", className)} {...rest}>\n      {size && (\n        <AnimatePresence>\n          {cursors.map((c) => (\n            <Cursor key={c.id} cursor={c} width={size.w} height={size.h} idleAfter={idleAfter} />\n          ))}\n        </AnimatePresence>\n      )}\n    </div>\n  );\n}\n\nfunction Cursor({ cursor: c, width, height, idleAfter }: { cursor: LiveCursor; width: number; height: number; idleAfter: number }) {\n  const reduce = useReducedMotion();\n  const x = useSpring(c.x * width, glide);\n  const y = useSpring(c.y * height, glide);\n  const label = useMotionValue(1);\n  const badge = useMotionValue(0);\n  const size = useRef({ width, height });\n\n  // New positions glide; a resized layer jumps, because the person didn't move, the window did.\n  useEffect(() => {\n    const resized = size.current.width !== width || size.current.height !== height;\n    size.current = { width, height };\n    const tx = c.x * width;\n    const ty = c.y * height;\n    if (reduce || resized) {\n      x.jump(tx);\n      y.jump(ty);\n    } else {\n      x.set(tx);\n      y.set(ty);\n    }\n  }, [c.x, c.y, width, height, reduce, x, y]);\n\n  // Moving shows the name; stillness tucks it away to an initial so idle cursors stop shouting.\n  useEffect(() => {\n    const t = reduce ? { duration: 0.12 } : { duration: 0.18, ease: ease.out };\n    animate(label, 1, t);\n    animate(badge, 0, t);\n    const timer = window.setTimeout(() => {\n      animate(label, 0, reduce ? { duration: 0.2 } : { duration: 0.32, ease: ease.out });\n      animate(badge, 1, reduce ? { duration: 0.2 } : { duration: 0.32, ease: ease.out, delay: 0.08 });\n    }, idleAfter);\n    return () => window.clearTimeout(timer);\n  }, [c.x, c.y, idleAfter, reduce, label, badge]);\n\n  const color = c.color ?? \"var(--fg)\";\n  // Near the right edge the label swings to the left of the arrow instead of being clipped.\n  const flip = width - c.x * width < 150;\n  const initial = (Array.from(c.name.trim())[0] ?? \"\").toUpperCase();\n\n  return (\n    <motion.div className=\"absolute left-0 top-0 will-change-transform\" style={{ x, y }}>\n      <motion.div\n        className=\"origin-top-left\"\n        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n        animate={{ opacity: 1, scale: 1 }}\n        exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.6, transition: { duration: 0.16, ease: ease.out } }}\n        transition={reduce ? { duration: 0.15 } : { type: \"spring\", stiffness: 500, damping: 30, mass: 0.6 }}\n      >\n        {/* The tip sits exactly on the point. A frame-colored outline keeps it legible on any fill. */}\n        <svg width=\"16\" height=\"18\" viewBox=\"0 0 16 18\" fill=\"none\" className=\"-translate-x-[1.5px] -translate-y-[1.5px]\">\n          <path d=\"M1.5 1.5 14 7.6 8.4 9.2 5.9 14.9z\" fill={color} stroke=\"var(--frame)\" strokeWidth=\"1.5\" strokeLinejoin=\"round\" />\n        </svg>\n        <span className={cn(\"absolute left-[10px] top-[15px] grid w-max transition-[translate] duration-240 ease-in-out-quart motion-reduce:transition-none\", flip && \"-translate-x-[calc(100%+14px)]\")}>\n          <motion.span\n            style={{ opacity: label, scale: label, backgroundColor: color, transformOrigin: flip ? \"top right\" : \"top left\" }}\n            className={cn(\n              \"col-start-1 row-start-1 flex h-5 max-w-40 items-center truncate whitespace-nowrap rounded-md px-1.5 text-[11.5px] font-medium leading-none text-frame shadow-pop\",\n              flip ? \"rounded-tr-[3px]\" : \"rounded-tl-[3px]\",\n            )}\n          >\n            {c.name}\n          </motion.span>\n          <motion.span\n            style={{ opacity: badge, scale: badge, backgroundColor: color, transformOrigin: flip ? \"top right\" : \"top left\" }}\n            className={cn(\"col-start-1 row-start-1 grid size-4 place-items-center rounded-full text-[9.5px] font-semibold leading-none text-frame\", flip && \"justify-self-end\")}\n          >\n            {initial}\n          </motion.span>\n        </span>\n      </motion.div>\n    </motion.div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/live-cursors"
}
