{
  "name": "marker-text",
  "type": "registry:component",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Marker Text\n * @version: 1.0.0\n * @date: 2026-10-21\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { RotateCcw } from \"lucide-react\";\nimport {\n  createContext,\n  type ElementType,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type MarkType = \"highlight\" | \"underline\" | \"circle\" | \"box\" | \"strike\";\n\nexport interface MarkProps {\n  children: ReactNode;\n  /** How the phrase is marked. */\n  type?: MarkType;\n  /** Any CSS color. Each type has its own default. */\n  color?: string;\n  /** Pen width in px. Defaults to a width that suits the font size. */\n  strokeWidth?: number;\n  className?: string;\n}\n\nexport interface MarkerTextProps {\n  children: ReactNode;\n  /**\n   * Controlled. `true` draws the marks, `false` hides them. Leave it out and\n   * they draw once, the first time the text is mostly on screen.\n   */\n  show?: boolean;\n  /** Milliseconds between one mark finishing and the next one starting. */\n  gap?: number;\n  /** Called after the last mark lands. */\n  onDrawn?: () => void;\n  as?: ElementType;\n  className?: string;\n}\n\ntype Pt = [number, number];\n\ninterface Rect {\n  left: number;\n  top: number;\n  right: number;\n  bottom: number;\n}\n\ninterface Stroke {\n  key: string;\n  /** The ink, a filled outline. */\n  shape: string;\n  /** The pen's path, used as a mask that uncovers the ink as it travels. */\n  pen: string;\n  penWidth: number;\n  length: number;\n  color: string;\n  /** Behind the text (highlights) or over it (everything else). */\n  under: boolean;\n  /** Highlighter ink is see-through; pens are solid. */\n  alpha: number;\n  delay: number;\n  duration: number;\n}\n\ninterface Entry {\n  el: HTMLElement;\n  type: MarkType;\n  color?: string;\n  strokeWidth?: number;\n}\n\nconst DEFAULT_COLOR: Record<MarkType, string> = {\n  highlight: \"#facc15\",\n  underline: \"#f97316\",\n  circle: \"#2563eb\",\n  box: \"#16a34a\",\n  strike: \"#dc2626\",\n};\n\n// How fast the pen travels, in px per ms. A hand slows down on long strokes,\n// so durations are clamped rather than strictly proportional.\nconst PEN_SPEED = 0.9;\nconst HIGHLIGHT_SPEED = 1.15;\nconst MIN_MS = 260;\nconst MAX_MS = 820;\n// Lifting the pen between two lines of the same mark.\nconst LINE_GAP = 60;\n// A hand accelerates into a stroke and eases out of it.\nconst PEN_EASE = \"cubic-bezier(0.45, 0.05, 0.25, 1)\";\n// The layers are 1px boxes that overflow, so masks need an explicit region.\nconst MASK_SPAN = 20_000;\n\n/** Deterministic noise, so a resize redraws the same wobble. */\nfunction random(seed: number) {\n  let n = seed;\n  return () => {\n    n += 1;\n    const x = Math.sin(n * 12.9898 + seed * 78.233) * 43_758.5453;\n    return x - Math.floor(x);\n  };\n}\n\nfunction hash(text: string) {\n  let h = 7;\n  for (const char of text) {\n    h = (h * 31 + (char.codePointAt(0) ?? 0)) % 1_000_003;\n  }\n  return h;\n}\n\nconst r1 = (n: number) => Math.round(n * 10) / 10;\n\nfunction polyline(points: Pt[]) {\n  return points\n    .map(([x, y], i) => `${i === 0 ? \"M\" : \"L\"}${r1(x)} ${r1(y)}`)\n    .join(\"\");\n}\n\nfunction lengthOf(points: Pt[]) {\n  let total = 0;\n  for (let i = 1; i < points.length; i++) {\n    total += Math.hypot(\n      points[i][0] - points[i - 1][0],\n      points[i][1] - points[i - 1][1]\n    );\n  }\n  return total;\n}\n\n/**\n * A pen stroke as a filled shape: the centre line offset to both sides by\n * half the width at each point, with round ends. Unlike an SVG stroke, the\n * width can change along the way, which is what makes it read as ink.\n */\nfunction outline(points: Pt[], widths: number[]) {\n  const left: Pt[] = [];\n  const right: Pt[] = [];\n  const last = points.length - 1;\n  for (let i = 0; i <= last; i++) {\n    const prev = points[Math.max(0, i - 1)];\n    const next = points[Math.min(last, i + 1)];\n    const dx = next[0] - prev[0];\n    const dy = next[1] - prev[1];\n    const len = Math.hypot(dx, dy) || 1;\n    const half = widths[i] / 2;\n    const nx = (-dy / len) * half;\n    const ny = (dx / len) * half;\n    left.push([points[i][0] + nx, points[i][1] + ny]);\n    right.push([points[i][0] - nx, points[i][1] - ny]);\n  }\n  const endCap = r1(widths[last] / 2);\n  const startCap = r1(widths[0] / 2);\n  const back = right.reverse();\n  return `${polyline(left)}A${endCap} ${endCap} 0 0 0 ${r1(back[0][0])} ${r1(\n    back[0][1]\n  )}${polyline(back).replace(\"M\", \"L\")}A${startCap} ${startCap} 0 0 0 ${r1(\n    left[0][0]\n  )} ${r1(left[0][1])}Z`;\n}\n\n/** Pressure along a stroke: the pen lands, presses, then lifts off. */\nfunction pressure(t: number, base: number) {\n  const land = Math.min(1, t / 0.1);\n  const lift = Math.min(1, (1 - t) / 0.18);\n  return (\n    base * (0.45 + 0.55 * Math.sin((land * Math.PI) / 2)) * (0.55 + 0.45 * lift)\n  );\n}\n\n/** A slow, uneven wave for edges that should look hand drawn. */\nfunction wobble(rand: () => number, amp: number) {\n  const p1 = rand() * Math.PI * 2;\n  const p2 = rand() * Math.PI * 2;\n  const f1 = 1 + rand() * 1.5;\n  const f2 = 3 + rand() * 3;\n  return (t: number) =>\n    amp *\n    (0.7 * Math.sin(t * f1 * Math.PI + p1) +\n      0.3 * Math.sin(t * f2 * Math.PI + p2));\n}\n\nfunction sample(count: number, at: (t: number) => Pt) {\n  const points: Pt[] = [];\n  for (let i = 0; i <= count; i++) {\n    points.push(at(i / count));\n  }\n  return points;\n}\n\nfunction penStroke(\n  points: Pt[],\n  width: number,\n  key: string,\n  color: string\n): Omit<Stroke, \"delay\" | \"duration\"> {\n  const widths = points.map((_, i) => pressure(i / (points.length - 1), width));\n  return {\n    key,\n    shape: outline(points, widths),\n    pen: polyline(points),\n    penWidth: width * 4,\n    length: lengthOf(points),\n    color,\n    under: false,\n    alpha: 1,\n  };\n}\n\nfunction strokesFor(\n  entry: Entry,\n  lines: Rect[],\n  seed: number,\n  index: number\n): Omit<Stroke, \"delay\" | \"duration\">[] {\n  const rand = random(seed);\n  const color = entry.color ?? DEFAULT_COLOR[entry.type];\n  const lineHeight = lines[0].bottom - lines[0].top;\n  const width =\n    entry.strokeWidth ?? Math.min(4.5, Math.max(1.6, lineHeight * 0.085));\n\n  if (entry.type === \"highlight\") {\n    return lines.map((line, i) => {\n      const h = line.bottom - line.top;\n      const top = wobble(rand, 0.9);\n      const bottom = wobble(rand, 0.9);\n      // The chisel tip leaves slanted ends, and the stroke drifts a little\n      // off level across the line.\n      const chisel = h * 0.2;\n      const tilt = (rand() - 0.5) * Math.min(3, h * 0.08);\n      const x0 = line.left - 3 - rand() * 2;\n      const x1 = line.right + 3 + rand() * 3;\n      const y0 = line.top + h * 0.08;\n      const y1 = line.bottom - h * 0.02;\n      const steps = Math.max(6, Math.round((x1 - x0) / 14));\n      const edge = (y: number, wave: (t: number) => number, dx: number) =>\n        sample(steps, (t) => [\n          x0 + dx + (x1 - x0) * t,\n          y + wave(t) + tilt * (t - 0.5),\n        ]);\n      const upper = edge(y0, top, chisel * 0.5);\n      const lower = edge(y1, bottom, -chisel * 0.5).reverse();\n      const mid = (y0 + y1) / 2;\n      const pen: Pt[] = [\n        [x0 - chisel, mid],\n        [x1 + chisel, mid + tilt * 0.5],\n      ];\n      return {\n        key: `${index}-${i}`,\n        shape: `${polyline(upper)}${polyline(lower).replace(\"M\", \"L\")}Z`,\n        pen: polyline(pen),\n        penWidth: h * 1.8,\n        length: lengthOf(pen),\n        color,\n        under: true,\n        alpha: 0.42,\n      };\n    });\n  }\n\n  if (entry.type === \"underline\" || entry.type === \"strike\") {\n    return lines.map((line, i) => {\n      const h = line.bottom - line.top;\n      const bow = (rand() - 0.5) * 3;\n      const slope = (rand() - 0.5) * (entry.type === \"strike\" ? 4 : 2.4);\n      const wave = wobble(rand, 0.5);\n      const x0 = line.left - 2 - rand() * 3;\n      const x1 = line.right + 2 + rand() * 4;\n      const y =\n        entry.type === \"strike\"\n          ? line.top + h * 0.56\n          : line.bottom + Math.max(1, h * 0.04);\n      const steps = Math.max(8, Math.round((x1 - x0) / 6));\n      const points = sample(steps, (t) => [\n        x0 + (x1 - x0) * t,\n        y + Math.sin(t * Math.PI) * bow + slope * (t - 0.5) + wave(t),\n      ]);\n      return penStroke(points, width, `${index}-${i}`, color);\n    });\n  }\n\n  // Circles and boxes don't wrap (see `Mark`), but if the phrase is longer\n  // than a line anyway, each line gets its own.\n  return lines.map((line, i) =>\n    entry.type === \"circle\"\n      ? circleStroke(line, rand, width, `${index}-${i}`, color)\n      : boxStroke(line, rand, width, `${index}-${i}`, color)\n  );\n}\n\nfunction circleStroke(\n  box: Rect,\n  rand: () => number,\n  width: number,\n  key: string,\n  color: string\n) {\n  const cx = (box.left + box.right) / 2;\n  const cy = (box.top + box.bottom) / 2;\n  const rx = (box.right - box.left) / 2 + 8;\n  const ry = (box.bottom - box.top) / 2 + 4;\n  const tilt = ((rand() - 0.5) * 6 * Math.PI) / 180;\n  const start = -Math.PI * (0.62 + rand() * 0.12);\n  // A hand never closes a loop exactly: it runs past the start and drifts\n  // outward, so the two ends cross instead of meeting.\n  const sweep = Math.PI * 2 + 0.45 + rand() * 0.2;\n  const drift = 0.05 + rand() * 0.04;\n  const wave = wobble(rand, 0.025);\n  const steps = Math.max(48, Math.round((rx + ry) / 3));\n  const points = sample(steps, (t) => {\n    const a = start + sweep * t;\n    const grow = 1 - drift / 2 + drift * t + wave(t);\n    const x = Math.cos(a) * rx * grow;\n    const y = Math.sin(a) * ry * grow;\n    return [\n      cx + x * Math.cos(tilt) - y * Math.sin(tilt),\n      cy + x * Math.sin(tilt) + y * Math.cos(tilt),\n    ];\n  });\n  return penStroke(points, width, key, color);\n}\n\n/**\n * One continuous stroke round four slightly bowed sides, overshooting the\n * first corner at the end.\n */\nfunction boxStroke(\n  box: Rect,\n  rand: () => number,\n  width: number,\n  key: string,\n  color: string\n) {\n  const padX = 5;\n  const padY = 3;\n  const corners: Pt[] = [\n    [box.left - padX - rand() * 2, box.top - padY + (rand() - 0.5) * 2],\n    [box.right + padX + rand() * 2, box.top - padY + (rand() - 0.5) * 2],\n    [box.right + padX + (rand() - 0.5) * 2, box.bottom + padY + rand() * 2],\n    [box.left - padX + (rand() - 0.5) * 2, box.bottom + padY + rand() * 2],\n  ];\n  const points: Pt[] = [];\n  for (let side = 0; side < 4; side++) {\n    const a = corners[side];\n    const b = corners[(side + 1) % 4];\n    const bow = (rand() - 0.5) * 3;\n    const len = Math.hypot(b[0] - a[0], b[1] - a[1]);\n    const nx = -(b[1] - a[1]) / len;\n    const ny = (b[0] - a[0]) / len;\n    const steps = Math.max(6, Math.round(len / 8));\n    for (let s = side === 0 ? 0 : 1; s <= steps; s++) {\n      const t = s / steps;\n      const lift = Math.sin(t * Math.PI) * bow;\n      points.push([\n        a[0] + (b[0] - a[0]) * t + nx * lift,\n        a[1] + (b[1] - a[1]) * t + ny * lift,\n      ]);\n    }\n  }\n  const first = corners[0];\n  const second = corners[1];\n  const over = 14 / Math.hypot(second[0] - first[0], second[1] - first[1]);\n  points.push([\n    first[0] + (second[0] - first[0]) * over,\n    first[1] + (second[1] - first[1]) * over + 1.5,\n  ]);\n  return penStroke(points, width, key, color);\n}\n\ninterface Registry {\n  add: (id: string, entry: Entry) => () => void;\n}\n\nconst MarkerContext = createContext<Registry | null>(null);\n\n/**\n * A phrase to mark. Use it inside `MarkerText`, which draws every mark in\n * reading order. Highlights render as `<mark>` and strikes as `<s>`, so the\n * meaning survives without the drawing.\n */\nexport function Mark({\n  children,\n  type = \"highlight\",\n  color,\n  strokeWidth,\n  className,\n}: MarkProps) {\n  const registry = useContext(MarkerContext);\n  const ref = useRef<HTMLElement>(null);\n  const id = useId();\n\n  useLayoutEffect(() => {\n    if (!(registry && ref.current)) {\n      return;\n    }\n    return registry.add(id, { el: ref.current, type, color, strokeWidth });\n  }, [registry, id, type, color, strokeWidth]);\n\n  const Tag = (() => {\n    if (type === \"highlight\") {\n      return \"mark\";\n    }\n    if (type === \"strike\") {\n      return \"s\";\n    }\n    return \"span\";\n  })();\n\n  return (\n    <Tag\n      className={cn(\n        \"bg-transparent text-inherit no-underline\",\n        (type === \"circle\" || type === \"box\") && \"whitespace-nowrap\",\n        className\n      )}\n      data-mark={type}\n      ref={ref}\n    >\n      {children}\n    </Tag>\n  );\n}\n\nexport function MarkerText({\n  children,\n  show,\n  gap = 140,\n  onDrawn,\n  as: Tag = \"div\",\n  className,\n}: MarkerTextProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const entries = useRef(new Map<string, Entry>());\n  const [strokes, setStrokes] = useState<Stroke[]>([]);\n  const [seen, setSeen] = useState(false);\n  // `armed` turns the transitions on. Hiding is instant, drawing is not.\n  const [armed, setArmed] = useState(false);\n  const [reduced, setReduced] = useState(false);\n  const measureRef = useRef<() => void>(() => undefined);\n  const onDrawnRef = useRef(onDrawn);\n  onDrawnRef.current = onDrawn;\n  const maskId = useId().replace(/:/g, \"\");\n\n  const visible = show ?? seen;\n\n  const measure = useCallback(() => {\n    const root = rootRef.current;\n    if (!root) {\n      return;\n    }\n    const origin = root.getBoundingClientRect();\n    // Reading order is document order, whatever order the marks mounted in.\n    const order = Array.from(root.querySelectorAll(\"[data-mark]\"));\n    const ordered = [...entries.current.values()].sort(\n      (a, b) => order.indexOf(a.el) - order.indexOf(b.el)\n    );\n    const next: Stroke[] = [];\n    let clock = 0;\n    ordered.forEach((entry, index) => {\n      const lines = Array.from(entry.el.getClientRects())\n        .filter((r) => r.width > 1)\n        .map((r) => ({\n          left: r.left - origin.left,\n          top: r.top - origin.top,\n          right: r.right - origin.left,\n          bottom: r.bottom - origin.top,\n        }));\n      if (lines.length === 0) {\n        return;\n      }\n      const seed = hash(`${entry.el.textContent ?? \"\"}${index}`);\n      const speed = entry.type === \"highlight\" ? HIGHLIGHT_SPEED : PEN_SPEED;\n      const made = strokesFor(entry, lines, seed, index);\n      made.forEach((stroke, i) => {\n        const duration = Math.min(\n          MAX_MS,\n          Math.max(MIN_MS, stroke.length / speed)\n        );\n        next.push({ ...stroke, delay: clock, duration });\n        clock += duration + (i < made.length - 1 ? LINE_GAP : 0);\n      });\n      clock += gap;\n    });\n    setStrokes(next);\n  }, [gap]);\n  measureRef.current = measure;\n\n  const registry = useRef<Registry>({\n    add: (id, entry) => {\n      entries.current.set(id, entry);\n      measureRef.current();\n      return () => {\n        entries.current.delete(id);\n        measureRef.current();\n      };\n    },\n  });\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) {\n      return;\n    }\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(query.matches);\n    const resize = new ResizeObserver(() => measureRef.current());\n    resize.observe(root);\n    // Web fonts can swap in after the first measure and move every line.\n    document.fonts?.ready.then(() => measureRef.current());\n    const view = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setSeen(true);\n          view.disconnect();\n        }\n      },\n      { threshold: 0.6 }\n    );\n    view.observe(root);\n    return () => {\n      resize.disconnect();\n      view.disconnect();\n    };\n  }, []);\n\n  // Draw on the frame after the hidden state has painted, so going from\n  // hidden to shown always runs the transition, even on a replay.\n  useEffect(() => {\n    if (!visible) {\n      setArmed(false);\n      return;\n    }\n    let second = 0;\n    const first = requestAnimationFrame(() => {\n      second = requestAnimationFrame(() => setArmed(true));\n    });\n    return () => {\n      cancelAnimationFrame(first);\n      cancelAnimationFrame(second);\n    };\n  }, [visible]);\n\n  const totalRef = useRef(0);\n  totalRef.current = strokes.reduce(\n    (end, s) => Math.max(end, s.delay + s.duration),\n    0\n  );\n  const hasStrokes = strokes.length > 0;\n\n  // Reports once per draw. Re-measuring mid-draw (a resize) doesn't restart\n  // the clock.\n  useEffect(() => {\n    if (!(armed && hasStrokes)) {\n      return;\n    }\n    const timer = window.setTimeout(\n      () => onDrawnRef.current?.(),\n      reduced ? 0 : totalRef.current\n    );\n    return () => window.clearTimeout(timer);\n  }, [armed, hasStrokes, reduced]);\n\n  const layer = (under: boolean) => (\n    <svg\n      aria-hidden=\"true\"\n      className={cn(\n        \"pointer-events-none absolute top-0 left-0 overflow-visible\",\n        under ? \"-z-10\" : \"z-10\"\n      )}\n      height=\"1\"\n      width=\"1\"\n    >\n      {strokes\n        .filter((s) => s.under === under)\n        .map((s) => {\n          const id = `${maskId}-${s.key}`;\n          return (\n            <g key={s.key}>\n              <mask\n                height={MASK_SPAN}\n                id={id}\n                maskUnits=\"userSpaceOnUse\"\n                width={MASK_SPAN}\n                x={-MASK_SPAN / 2}\n                y={-MASK_SPAN / 2}\n              >\n                <path\n                  d={s.pen}\n                  fill=\"none\"\n                  stroke=\"white\"\n                  strokeDasharray={s.length + 1}\n                  strokeDashoffset={armed ? 0 : s.length + 1}\n                  strokeLinecap={under ? \"butt\" : \"round\"}\n                  strokeLinejoin=\"round\"\n                  strokeWidth={s.penWidth}\n                  style={{\n                    transition:\n                      armed && !reduced\n                        ? `stroke-dashoffset ${s.duration}ms ${PEN_EASE} ${s.delay}ms`\n                        : \"none\",\n                  }}\n                />\n              </mask>\n              <path\n                d={s.shape}\n                fill={s.color}\n                fillOpacity={s.alpha}\n                mask={`url(#${id})`}\n              />\n            </g>\n          );\n        })}\n    </svg>\n  );\n\n  return (\n    <MarkerContext.Provider value={registry.current}>\n      <Tag className={cn(\"relative isolate\", className)} ref={rootRef}>\n        {layer(true)}\n        {children}\n        {layer(false)}\n      </Tag>\n    </MarkerContext.Provider>\n  );\n}\n\nexport default function MarkerTextDemo({ className }: { className?: string }) {\n  const [round, setRound] = useState(0);\n  const [done, setDone] = useState(false);\n\n  const replay = () => {\n    setDone(false);\n    setRound((r) => r + 1);\n  };\n\n  return (\n    <div className={cn(\"w-full max-w-xl px-2 py-6\", className)}>\n      <MarkerText\n        className=\"text-zinc-900 dark:text-zinc-100\"\n        key={round}\n        onDrawn={() => setDone(true)}\n        show={round === 0 ? undefined : true}\n      >\n        <h2 className=\"text-balance font-semibold text-3xl leading-[1.15] tracking-tight sm:text-[2.6rem]\">\n          Notes that keep up with <Mark>how fast you think</Mark>.\n        </h2>\n        <p className=\"mt-6 text-[17px] text-zinc-600 leading-8 dark:text-zinc-400\">\n          Every edit syncs{\" \"}\n          <Mark type=\"underline\">the moment you stop typing</Mark>, search opens\n          in <Mark type=\"circle\">under a second</Mark>, and your notes live in a{\" \"}\n          <Mark type=\"box\">plain folder</Mark> you own. No accounts, no{\" \"}\n          <Mark type=\"strike\">monthly fee</Mark> lock-in.\n        </p>\n      </MarkerText>\n      <button\n        className={cn(\n          \"mt-8 inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-medium text-[13px] text-zinc-500 transition-[color,opacity] duration-200 hover:text-zinc-900 dark:hover:text-zinc-100\",\n          done ? \"opacity-100\" : \"pointer-events-none opacity-0\"\n        )}\n        disabled={!done}\n        onClick={replay}\n        type=\"button\"\n      >\n        <RotateCcw className=\"size-3.5\" />\n        Replay\n      </button>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/marker-text.tsx",
      "target": "components/kokonutui/marker-text.tsx"
    }
  ]
}