{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"inverted-cursor","type":"registry:component","title":"Inverted Cursor","description":"A difference-blended cursor disc with honest states and clean teardown.","dependencies":[],"categories":["input","cursor","pointer","blend-mode","states"],"files":[{"path":"components/lab/inverted-cursor/inverted-cursor.tsx","target":"components/lab/inverted-cursor/inverted-cursor.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, type CSSProperties } from \"react\";\nimport \"./inverted-cursor.css\";\n\nexport type InvertedCursorProps = {\n  /** Rest diameter in pixels. */\n  size?: number;\n  /** Scale over links, buttons and marked surfaces. */\n  grow?: number;\n  /** Word carried by the disc on data-cursor=\"view\" targets. */\n  label?: string;\n  /** Any mix-blend-mode value; difference is the honest default. */\n  blend?: string;\n  /** Targets marked data-cursor-magnet drift a clamped few pixels toward the pointer. */\n  magnetic?: boolean;\n  /** Removes easing and magnetism, keeping the position 1:1. */\n  respectReducedMotion?: boolean;\n  className?: string;\n};\n\ntype CursorState = \"rest\" | \"link\" | \"view\" | \"text\";\n\n/**\n * Tag/attribute rules for the implicit states. An explicit data-cursor on\n * the nearest marked ancestor always outranks these, so a row marked\n * \"view\" keeps its label even when the pointer sits on the title inside it.\n */\nconst GENERIC_TARGETS = [\n  \"a\",\n  \"button\",\n  \"[role='button']\",\n  \"label\",\n  \"select\",\n  \"input\",\n  \"textarea\",\n  \"[contenteditable]\",\n  \"p\",\n  \"li\",\n  \"h1\",\n  \"h2\",\n  \"h3\",\n  \"h4\",\n  \"h5\",\n  \"h6\",\n  \"td\",\n].join(\",\");\n\nconst PROSE_TAGS = /^(p|li|h[1-6]|td)$/;\nconst MAGNET_TARGETS = \"[data-cursor-magnet]\";\n\nconst EASE = 0.55; // catch-up per frame\nconst MAGNET_PULL = 0.2; // fraction of the offset a magnet takes\nconst MAGNET_MAX = 8; // hard clamp, in pixels\nconst SETTLE = 0.1; // below this the disc snaps and the loop stops\n\nfunction resolveState(node: EventTarget | null): CursorState {\n  if (!(node instanceof Element)) return \"rest\";\n\n  const marked = node.closest(\"[data-cursor]\");\n  const markedKind = marked?.getAttribute(\"data-cursor\");\n  if (markedKind === \"view\" || markedKind === \"link\" || markedKind === \"text\") {\n    return markedKind;\n  }\n\n  const generic = node.closest(GENERIC_TARGETS);\n  if (!generic) return \"rest\";\n  if (generic.hasAttribute(\"contenteditable\")) return \"text\";\n  const tag = generic.tagName.toLowerCase();\n  if (tag === \"input\" || tag === \"textarea\") return \"text\";\n  if (PROSE_TAGS.test(tag)) return \"text\";\n  return \"link\";\n}\n\n/**\n * INVERTED CURSOR — a difference-blended disc with honest states.\n *\n * One fixed disc follows the pointer with a small per-frame easing. The\n * disc's position travels on the independent `translate` property and its\n * scale on `transform`, so the two never fight. It only engages on fine,\n * hovering pointers and only after the pointer has actually moved; over\n * inputs, editable regions and selectable prose it steps aside and hands\n * the native I-beam back by removing the `data-lab-cursor` attribute the\n * stylesheet keys on. Every exit path — leaving the window, blur, tab\n * hide, media change, unmount — cancels the frame loop, restores the\n * native cursor and resets every magnet transform.\n */\nexport function InvertedCursor({\n  size = 22,\n  grow = 2.6,\n  label = \"VIEW\",\n  blend = \"difference\",\n  magnetic = false,\n  respectReducedMotion = true,\n  className,\n}: InvertedCursorProps) {\n  const discRef = useRef<HTMLDivElement>(null);\n  /* Live props are read through a ref so inline values never rebind the\n     pointer engine mid-frame. */\n  const propsRef = useRef({ grow, magnetic, respectReducedMotion });\n  /* The element currently pulled toward the disc, if any. */\n  const magnetRef = useRef<HTMLElement | null>(null);\n\n  useEffect(() => {\n    propsRef.current = { grow, magnetic, respectReducedMotion };\n  });\n\n  /* Turning magnetism off must never strand a drifted target. */\n  useEffect(() => {\n    if (magnetic) return;\n    const el = magnetRef.current;\n    if (el) {\n      el.style.transform = \"\";\n      magnetRef.current = null;\n    }\n  }, [magnetic]);\n\n  useEffect(() => {\n    const disc = discRef.current;\n    if (!disc) return;\n\n    const fineQuery = window.matchMedia(\"(pointer: fine)\");\n    const hoverQuery = window.matchMedia(\"(hover: hover)\");\n    const motionQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const root = document.documentElement;\n\n    let active = false; // disc owns the cursor\n    let pressed = false;\n    let state: CursorState = \"rest\";\n    let posX = 0;\n    let posY = 0;\n    let targetX = 0;\n    let targetY = 0;\n    let hasTarget = false;\n    let raf = 0;\n\n    const isReduced = (): boolean =>\n      propsRef.current.respectReducedMotion && motionQuery.matches;\n\n    const releaseMagnet = (): void => {\n      const el = magnetRef.current;\n      if (!el) return;\n      el.style.transform = \"\";\n      magnetRef.current = null;\n    };\n\n    const writePosition = (x: number, y: number): void => {\n      /* NaN must never reach the DOM as \"NaNpx\". */\n      if (!Number.isFinite(x) || !Number.isFinite(y)) return;\n      disc.style.setProperty(\n        \"translate\",\n        `calc(${x}px - 50%) calc(${y}px - 50%)`,\n      );\n    };\n\n    const setState = (next: CursorState): void => {\n      if (next === state) return;\n      state = next;\n      disc.dataset.state = next;\n      if (next === \"text\") {\n        // Hand the native I-beam back while over a text surface.\n        delete root.dataset.labCursor;\n      } else if (active) {\n        root.dataset.labCursor = \"custom\";\n      }\n    };\n\n    const activate = (): void => {\n      if (active) return;\n      active = true;\n      disc.dataset.engaged = \"true\";\n      if (state !== \"text\") root.dataset.labCursor = \"custom\";\n    };\n\n    const deactivate = (): void => {\n      if (raf) {\n        window.cancelAnimationFrame(raf);\n        raf = 0;\n      }\n      hasTarget = false;\n      pressed = false;\n      disc.dataset.pressed = \"false\";\n      disc.dataset.engaged = \"false\";\n      delete root.dataset.labCursor;\n      releaseMagnet();\n      active = false;\n    };\n\n    const tick = (): void => {\n      raf = 0;\n      /* Hard guarantee: an orphaned frame can never re-show the disc. */\n      if (!active) return;\n      if (!hasTarget) return;\n      if (!Number.isFinite(targetX) || !Number.isFinite(targetY)) return;\n\n      const dx = targetX - posX;\n      const dy = targetY - posY;\n      if (Math.abs(dx) < SETTLE && Math.abs(dy) < SETTLE) {\n        posX = targetX;\n        posY = targetY;\n        writePosition(posX, posY);\n        return;\n      }\n      posX += dx * EASE;\n      posY += dy * EASE;\n      writePosition(posX, posY);\n      raf = window.requestAnimationFrame(tick);\n    };\n\n    const updateMagnet = (node: EventTarget | null): void => {\n      if (!propsRef.current.magnetic || isReduced()) {\n        releaseMagnet();\n        return;\n      }\n      const el =\n        node instanceof Element\n          ? node.closest<HTMLElement>(MAGNET_TARGETS)\n          : null;\n      if (el !== magnetRef.current) releaseMagnet();\n      if (!el) return;\n      magnetRef.current = el;\n\n      const rect = el.getBoundingClientRect();\n      if (rect.width === 0 || rect.height === 0) return;\n      const dx = posX - (rect.left + rect.width / 2);\n      const dy = posY - (rect.top + rect.height / 2);\n      const clamp = (value: number): number =>\n        Math.max(-MAGNET_MAX, Math.min(MAGNET_MAX, value * MAGNET_PULL));\n      el.style.transform = `translate(${clamp(dx).toFixed(2)}px, ${clamp(dy).toFixed(2)}px)`;\n    };\n\n    const onMove = (event: PointerEvent): void => {\n      if (!(fineQuery.matches && hoverQuery.matches)) return;\n      const x = event.clientX;\n      const y = event.clientY;\n      if (!Number.isFinite(x) || !Number.isFinite(y)) return;\n\n      const next = resolveState(event.target);\n      if (next === \"text\") {\n        if (active) {\n          // Keep the position knowledge so leaving text feels continuous,\n          // but hide the disc and let the I-beam through.\n          posX = x;\n          posY = y;\n          deactivate();\n        }\n        return;\n      }\n\n      setState(next);\n      if (!active) {\n        posX = x;\n        posY = y;\n        targetX = x;\n        targetY = y;\n        hasTarget = true;\n        activate();\n        writePosition(posX, posY);\n        updateMagnet(event.target);\n        return;\n      }\n\n      targetX = x;\n      targetY = y;\n      hasTarget = true;\n\n      if (isReduced()) {\n        posX = x;\n        posY = y;\n        writePosition(posX, posY);\n        updateMagnet(event.target);\n        return;\n      }\n      if (!raf) raf = window.requestAnimationFrame(tick);\n      updateMagnet(event.target);\n    };\n\n    const onDown = (event: PointerEvent): void => {\n      if (!active || event.button !== 0) return;\n      pressed = true;\n      disc.dataset.pressed = \"true\";\n    };\n\n    const onUp = (): void => {\n      if (!pressed) return;\n      pressed = false;\n      disc.dataset.pressed = \"false\";\n    };\n\n    const onExit = (): void => deactivate();\n\n    const onVisibility = (): void => {\n      if (document.hidden) deactivate();\n    };\n\n    const onMediaChange = (): void => {\n      if (!(fineQuery.matches && hoverQuery.matches)) deactivate();\n    };\n\n    const onMotionChange = (): void => {\n      if (!isReduced()) return;\n      releaseMagnet();\n      if (raf) {\n        window.cancelAnimationFrame(raf);\n        raf = 0;\n      }\n      if (active && hasTarget) {\n        posX = targetX;\n        posY = targetY;\n        writePosition(posX, posY);\n      }\n    };\n\n    window.addEventListener(\"pointermove\", onMove, { passive: true });\n    window.addEventListener(\"pointerdown\", onDown);\n    window.addEventListener(\"pointerup\", onUp);\n    window.addEventListener(\"pointercancel\", onUp);\n    window.addEventListener(\"blur\", onExit);\n    document.addEventListener(\"mouseleave\", onExit);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    root.addEventListener(\"pointerleave\", onExit);\n    fineQuery.addEventListener(\"change\", onMediaChange);\n    hoverQuery.addEventListener(\"change\", onMediaChange);\n    motionQuery.addEventListener(\"change\", onMotionChange);\n\n    return () => {\n      if (raf) window.cancelAnimationFrame(raf);\n      raf = 0;\n      window.removeEventListener(\"pointermove\", onMove);\n      window.removeEventListener(\"pointerdown\", onDown);\n      window.removeEventListener(\"pointerup\", onUp);\n      window.removeEventListener(\"pointercancel\", onUp);\n      window.removeEventListener(\"blur\", onExit);\n      document.removeEventListener(\"mouseleave\", onExit);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      root.removeEventListener(\"pointerleave\", onExit);\n      fineQuery.removeEventListener(\"change\", onMediaChange);\n      hoverQuery.removeEventListener(\"change\", onMediaChange);\n      motionQuery.removeEventListener(\"change\", onMotionChange);\n      delete root.dataset.labCursor;\n      releaseMagnet();\n    };\n  }, []);\n\n  return (\n    <div\n      ref={discRef}\n      className={`cur-disc${className ? ` ${className}` : \"\"}`}\n      aria-hidden=\"true\"\n      role=\"presentation\"\n      data-state=\"rest\"\n      data-pressed=\"false\"\n      data-engaged=\"false\"\n      style={\n        {\n          \"--cur-size\": `${size}px`,\n          \"--cur-grow\": String(grow),\n          \"--cursor-blend\": blend,\n        } as CSSProperties\n      }\n    >\n      {label ? <span className=\"cur-disc__label\">{label}</span> : null}\n    </div>\n  );\n}\n"},{"path":"components/lab/inverted-cursor/inverted-cursor.css","target":"components/lab/inverted-cursor/inverted-cursor.css","type":"registry:file","content":"/* ============================================================\n   INVERTED CURSOR — self-contained styles.\n   Every token is scoped with a fallback, so the component works in\n   any codebase without the Lab's stylesheet.\n   ============================================================ */\n\n/*\n * The native cursor disappears only while the component has engaged —\n * a fine, hovering pointer has actually moved. Over text surfaces the\n * component removes the attribute again, so the I-beam returns.\n */\nhtml[data-lab-cursor=\"custom\"],\nhtml[data-lab-cursor=\"custom\"] * {\n  cursor: none !important;\n}\n\n.cur-disc {\n  --cur-size: 22px;\n  --cur-grow: 2.6;\n  --cur-zoom: 1;\n  --cur-press: 1;\n  position: fixed;\n  left: 0;\n  top: 0;\n  z-index: 9999;\n  display: grid;\n  place-items: center;\n  width: var(--cur-size);\n  height: var(--cur-size);\n  border-radius: 50%;\n  background: #fff;\n  mix-blend-mode: var(--cursor-blend, difference);\n  pointer-events: none;\n  opacity: 0;\n  /* Position travels on `translate` (set from JS), scale on `transform`. */\n  transform: scale(calc(var(--cur-zoom) * var(--cur-press)));\n  transform-origin: center;\n  transition:\n    transform 200ms cubic-bezier(0.22, 1, 0.36, 1),\n    opacity 140ms ease;\n  will-change: translate, transform;\n}\n\n.cur-disc[data-engaged=\"true\"] {\n  opacity: 1;\n}\n\n.cur-disc[data-state=\"text\"] {\n  opacity: 0;\n}\n\n.cur-disc[data-state=\"link\"],\n.cur-disc[data-state=\"view\"] {\n  --cur-zoom: var(--cur-grow);\n}\n\n.cur-disc[data-pressed=\"true\"] {\n  --cur-press: 0.84;\n}\n\n/*\n * The label is white and difference-blends a second time against the\n * disc's own white surface, so it reads as a cut-out: the word shows\n * the page content un-inverted while the disc around it inverts. That\n * keeps the word on the same surface — nothing can outlive teardown.\n */\n.cur-disc__label {\n  font-family:\n    ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, \"Liberation Mono\",\n    Menlo, monospace;\n  font-size: calc(var(--cur-size) * 0.3);\n  font-weight: 600;\n  letter-spacing: 0.05em;\n  line-height: 1;\n  white-space: nowrap;\n  color: #fff;\n  mix-blend-mode: difference;\n  opacity: 0;\n  transition: opacity 120ms ease;\n}\n\n.cur-disc[data-state=\"view\"] .cur-disc__label {\n  opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .cur-disc,\n  .cur-disc__label {\n    transition: none;\n  }\n}\n"}]}