{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "highlighter",
  "type": "registry:component",
  "title": "Highlighter",
  "description": "A marker pen that sweeps behind your words one line after another, with a chisel tip, slightly ragged edges and ink that pools where each stroke starts and stops; runs when it scrolls into view or when its row is hovered.",
  "categories": [
    "motion",
    "Text"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/highlighter.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { animate, type MotionValue, motion, useInView, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { type ComponentProps, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type HighlightColor = \"yellow\" | \"pink\" | \"green\" | \"blue\" | \"orange\";\nexport type HighlightTrigger = \"view\" | \"hover\" | \"manual\";\n\nconst inks: Record<HighlightColor, string> = {\n  yellow: \"text-yellow-300 dark:text-amber-300/35\",\n  pink: \"text-pink-300 dark:text-pink-400/40\",\n  green: \"text-lime-300 dark:text-lime-300/35\",\n  blue: \"text-sky-300 dark:text-sky-400/40\",\n  orange: \"text-orange-300 dark:text-orange-400/40\",\n};\n\ntype Stroke = { x: number; y: number; w: number; h: number; start: number; d: string; streak: string };\n\nconst TIP = 3;\n\nfunction seeded(text: string) {\n  let a = 2166136261;\n  for (const c of text) a = Math.imul(a ^ c.charCodeAt(0), 16777619);\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nconst round = (n: number) => Math.round(n * 10) / 10;\n\nfunction edge(from: number, to: number, y: number, rand: () => number) {\n  const steps = Math.max(2, Math.round(Math.abs(to - from) / 14));\n  const points: string[] = [];\n  for (let i = 1; i < steps; i++) points.push(`L${round(from + ((to - from) * i) / steps)} ${round(y + (rand() - 0.5) * 1.3)}`);\n  return points.join(\" \");\n}\n\nfunction shape(w: number, h: number, rand: () => number) {\n  const top = 0.6 + rand() * 0.6;\n  const bottom = h - 0.6 - rand() * 0.6;\n  const d = [\n    `M${TIP} ${round(top)}`,\n    edge(TIP, w, top, rand),\n    `L${round(w)} ${round(top + (rand() - 0.5))}`,\n    `L${round(w - TIP)} ${round(bottom)}`,\n    edge(w - TIP, 0, bottom, rand),\n    `L0 ${round(bottom)}`,\n    \"Z\",\n  ].join(\" \");\n  const band = h * (0.62 + rand() * 0.12);\n  const streak = `M${TIP + 4} ${round(band)} L${round(w - TIP - 6)} ${round(band + (rand() - 0.5) * 1.2)} L${round(w - TIP - 7)} ${round(band + 1.6)} L${TIP + 3} ${round(band + 1.4)} Z`;\n  return { d, streak };\n}\n\nfunction useCalmMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nfunction Bar({ stroke, total, progress, ink, id }: { stroke: Stroke; total: number; progress: MotionValue<number>; ink: string; id: string }) {\n  const clipPath = useTransform(progress, (p) => {\n    const reach = Math.min(1, Math.max(0, (p * total - stroke.start) / stroke.w)) * (stroke.w + TIP);\n    return `polygon(0px 0px, ${round(reach)}px 0px, ${round(reach - TIP)}px 100%, 0px 100%)`;\n  });\n  const fade = Math.min(0.45, 11 / stroke.w);\n\n  return (\n    <motion.svg\n      aria-hidden\n      className={cn(\"pointer-events-none absolute -z-10 overflow-visible\", ink)}\n      height={stroke.h}\n      style={{ left: stroke.x, top: stroke.y, clipPath }}\n      viewBox={`0 0 ${round(stroke.w)} ${round(stroke.h)}`}\n      width={stroke.w}\n    >\n      <defs>\n        <linearGradient gradientUnits=\"userSpaceOnUse\" id={id} x1=\"0\" x2={stroke.w} y1=\"0\" y2=\"0\">\n          <stop offset=\"0\" stopColor=\"currentColor\" stopOpacity=\"0.95\" />\n          <stop offset={fade} stopColor=\"currentColor\" stopOpacity=\"0\" />\n          <stop offset={1 - fade} stopColor=\"currentColor\" stopOpacity=\"0\" />\n          <stop offset=\"1\" stopColor=\"currentColor\" stopOpacity=\"0.95\" />\n        </linearGradient>\n      </defs>\n      <path d={stroke.d} fill=\"currentColor\" fillOpacity={0.78} />\n      <path d={stroke.streak} fill=\"currentColor\" fillOpacity={0.35} />\n      <path d={stroke.d} fill={`url(#${id})`} />\n    </motion.svg>\n  );\n}\n\nexport interface HighlightProps extends ComponentProps<\"span\"> {\n  color?: HighlightColor;\n  trigger?: HighlightTrigger;\n  active?: boolean;\n  delay?: number;\n  speed?: number;\n}\n\nexport function Highlight({\n  color = \"yellow\",\n  trigger = \"view\",\n  active = false,\n  delay = 0,\n  speed = 700,\n  className,\n  children,\n  ...props\n}: HighlightProps) {\n  const id = `ink${useId().replace(/[^\\w-]/g, \"\")}`;\n  const ref = useRef<HTMLSpanElement>(null);\n  const origin = useRef<HTMLSpanElement>(null);\n  const reduce = useCalmMotion();\n  const inView = useInView(ref, { once: true, amount: 0.6 });\n  const [hovered, setHovered] = useState(false);\n  const [touchOnly, setTouchOnly] = useState(false);\n  const [strokes, setStrokes] = useState<Stroke[]>([]);\n  const progress = useMotionValue(0);\n  const total = strokes.reduce((sum, s) => sum + s.w, 0);\n\n  useLayoutEffect(() => {\n    const wrapper = ref.current;\n    const probe = origin.current;\n    if (!wrapper || !probe) return;\n    let frame = 0;\n    const measure = () => {\n      const scale = wrapper.getBoundingClientRect().width / (wrapper.offsetWidth || 1) || 1;\n      const zero = probe.getBoundingClientRect();\n      const rand = seeded(wrapper.textContent ?? \"\");\n      let start = 0;\n      const next = [...wrapper.getClientRects()]\n        .filter((r) => r.width > 1)\n        .map((r) => {\n          const w = r.width / scale + 6;\n          const h = (r.height / scale) * 0.86;\n          const stroke = {\n            x: (r.left - zero.left) / scale - 3,\n            y: (r.top - zero.top) / scale + (r.height / scale) * 0.09,\n            w,\n            h,\n            start,\n            ...shape(w, h, rand),\n          };\n          start += w;\n          return stroke;\n        });\n      setStrokes(next);\n    };\n    const schedule = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(measure);\n    };\n    measure();\n    const observer = new ResizeObserver(schedule);\n    observer.observe(wrapper.parentElement ?? wrapper);\n    document.fonts?.ready.then(schedule);\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n    };\n  }, []);\n\n  useEffect(() => {\n    const wrapper = ref.current;\n    if (trigger !== \"hover\" || !wrapper) return;\n    const media = window.matchMedia(\"(hover: none)\");\n    const sync = () => setTouchOnly(media.matches);\n    sync();\n    media.addEventListener(\"change\", sync);\n    const host = wrapper.closest<HTMLElement>(\"a, button, [tabindex]\") ?? wrapper;\n    const enter = (e: PointerEvent) => e.pointerType !== \"touch\" && setHovered(true);\n    const leave = () => setHovered(false);\n    const focus = () => host.matches(\":focus-visible\") && setHovered(true);\n    host.addEventListener(\"pointerenter\", enter);\n    host.addEventListener(\"pointerleave\", leave);\n    host.addEventListener(\"focus\", focus);\n    host.addEventListener(\"blur\", leave);\n    return () => {\n      media.removeEventListener(\"change\", sync);\n      host.removeEventListener(\"pointerenter\", enter);\n      host.removeEventListener(\"pointerleave\", leave);\n      host.removeEventListener(\"focus\", focus);\n      host.removeEventListener(\"blur\", leave);\n    };\n  }, [trigger]);\n\n  const on = trigger === \"manual\" ? active : trigger === \"hover\" ? hovered || (touchOnly && inView) : inView;\n\n  useEffect(() => {\n    if (!total) return;\n    const target = on ? 1 : 0;\n    if (reduce) {\n      progress.jump(1);\n      return;\n    }\n    const remaining = Math.abs(target - progress.get());\n    if (remaining === 0) return;\n    const full = Math.min(1.2, Math.max(0.25, total / speed));\n    const controls = animate(progress, target, {\n      duration: full * remaining * (on ? 1 : 0.6),\n      delay: on ? delay : 0,\n      ease: on ? [0.45, 0.05, 0.3, 1] : [0.23, 1, 0.32, 1],\n    });\n    return () => controls.stop();\n  }, [on, total, reduce, delay, speed, progress]);\n\n  return (\n    <span className={cn(\"relative isolate\", className)} ref={ref} {...props}>\n      <span aria-hidden className=\"pointer-events-none absolute top-0 left-0 size-0\" ref={origin} />\n      <motion.span\n        animate={{ opacity: reduce && !on ? 0 : 1 }}\n        aria-hidden\n        className=\"pointer-events-none absolute top-0 left-0 -z-10\"\n        initial={false}\n        transition={{ duration: 0.2, delay: reduce && on ? delay * 0.3 : 0 }}\n      >\n        {strokes.map((stroke, i) => (\n          <Bar id={`${id}-${i}`} ink={inks[color]} key={`${i}-${stroke.w}`} progress={progress} stroke={stroke} total={total} />\n        ))}\n      </motion.span>\n      {children}\n    </span>\n  );\n}\n"
    }
  ]
}