{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "highlighter",
  "title": "Highlighter",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Hand-drawn marks around inline text: highlight, underline, box, circle, strike-through and brackets, drawn in when the phrase scrolls into view.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/highlighter.tsx",
      "content": "// Ballmac UI: Highlighter. https://ui.ballmac.com/components/highlighter\n\"use client\"\n\nimport * as React from \"react\"\nimport { motion, useInView, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype HighlighterAction = \"highlight\" | \"underline\" | \"box\" | \"circle\" | \"strike\" | \"bracket\"\ntype HighlighterTone = \"chart-1\" | \"chart-2\" | \"chart-3\" | \"chart-4\" | \"chart-5\" | \"destructive\" | \"foreground\"\n\nconst TONE: Record<HighlighterTone, string> = {\n  \"chart-1\": \"text-chart-1\",\n  \"chart-2\": \"text-chart-2\",\n  \"chart-3\": \"text-chart-3\",\n  \"chart-4\": \"text-chart-4\",\n  \"chart-5\": \"text-chart-5\",\n  destructive: \"text-destructive\",\n  foreground: \"text-foreground\",\n}\n\n// Drawn in a 100 x 40 box and stretched to the text, with a little wobble so it reads as hand drawn.\n/** Scales a path made only of absolute coordinate pairs from the 100 x 40 drawing box to real pixels. */\nfunction scalePath(d: string, sx: number, sy: number) {\n  let axis = 0\n  return d.replace(/-?\\d*\\.?\\d+/g, (n) => {\n    const v = parseFloat(n) * (axis % 2 === 0 ? sx : sy)\n    axis++\n    return v.toFixed(2)\n  })\n}\n\nconst PATHS: Record<Exclude<HighlighterAction, \"highlight\">, string[]> = {\n  underline: [\"M1 33 C20 28 38 36 56 31 S88 29 99 33\"],\n  strike: [\"M0 21 C22 17 46 24 70 19 S92 20 100 18\"],\n  box: [\"M3 6 C30 3 70 4 97 5 C98 14 97 27 96 35 C66 37 34 36 4 35 C3 25 2 14 3 6\"],\n  circle: [\"M8 20 C8 6 40 1 62 3 C88 5 99 14 96 24 C93 35 62 39 38 37 C14 35 2 28 6 15 C9 8 22 3 36 2\"],\n  bracket: [\"M10 4 C4 4 3 6 3 10 L3 30 C3 34 4 36 10 36\", \"M90 4 C96 4 97 6 97 10 L97 30 C97 34 96 36 90 36\"],\n}\n\ntype HighlighterProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** The phrase to mark. Short phrases work best because the mark is drawn around one box. */\n  children: React.ReactNode\n  /** The kind of mark. */\n  action?: HighlighterAction\n  /** Mark color. */\n  tone?: HighlighterTone\n  /** Stroke width in pixels (not used by \"highlight\"). */\n  strokeWidth?: number\n  /** Extra space around the text in pixels, so the mark does not touch the letters. */\n  padding?: number\n  /** Seconds the drawing takes. */\n  duration?: number\n  /** Seconds before it starts. */\n  delay?: number\n  /** Wait until the phrase scrolls into view. */\n  inView?: boolean\n}\n\nfunction Highlighter({\n  children,\n  action = \"highlight\",\n  tone = \"chart-3\",\n  strokeWidth = 2.5,\n  padding = 4,\n  duration = 0.7,\n  delay = 0,\n  inView = true,\n  className,\n  ...props\n}: HighlighterProps) {\n  const ref = React.useRef<HTMLSpanElement>(null)\n  const seen = useInView(ref, { once: true, margin: \"0px 0px -10% 0px\" })\n  const reduce = useReducedMotion()\n  const show = reduce || !inView || seen\n  const pad = padding\n  const horizontal = action === \"bracket\" ? padding + 6 : padding\n  const [size, setSize] = React.useState<{ w: number; h: number } | null>(null)\n\n  // Strokes are drawn in real pixels so their width and dash animation stay true at any text size.\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    const measure = () => setSize({ w: el.offsetWidth + horizontal * 2, h: el.offsetHeight + pad * 2 })\n    measure()\n    const ro = new ResizeObserver(measure)\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [horizontal, pad])\n\n  return (\n    <span ref={ref} data-slot=\"highlighter\" className={cn(\"relative isolate inline-block whitespace-nowrap text-foreground\", className)} {...props}>\n      {size && (\n        <svg\n          aria-hidden=\"true\"\n          viewBox={`0 0 ${size.w} ${size.h}`}\n          className={cn(\"pointer-events-none absolute overflow-visible\", TONE[tone], action === \"highlight\" && \"-z-10\")}\n          style={{ left: -horizontal, top: -pad, width: size.w, height: size.h }}\n        >\n          {action === \"highlight\" ? (\n            <motion.path\n              d={scalePath(\"M1 9 C18 5 40 8 62 5 S90 6 99 8 L98 33 C80 36 55 33 34 36 S10 34 2 35 Z\", size.w / 100, size.h / 40)}\n              fill=\"currentColor\"\n              fillOpacity={0.35}\n              initial={false}\n              animate={{ clipPath: show ? \"inset(0 0% 0 0)\" : \"inset(0 100% 0 0)\" }}\n              transition={{ duration: reduce ? 0 : duration, delay: show ? delay : 0, ease: [0.22, 1, 0.36, 1] }}\n            />\n          ) : (\n            PATHS[action].map((d, i) => (\n              <motion.path\n                key={i}\n                d={scalePath(d, size.w / 100, size.h / 40)}\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={strokeWidth}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={false}\n                animate={{ pathLength: show ? 1 : 0, opacity: show ? 1 : 0 }}\n                transition={{ duration: reduce ? 0 : duration, delay: show ? delay + i * 0.15 : 0, ease: [0.65, 0, 0.35, 1] }}\n              />\n            ))\n          )}\n        </svg>\n      )}\n      <span className=\"relative\">{children}</span>\n    </span>\n  )\n}\n\nexport { Highlighter, type HighlighterProps, type HighlighterAction, type HighlighterTone }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/highlighter.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "highlight",
      "underline",
      "annotation",
      "marker",
      "text"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<Highlighter action='highlight|underline|box|circle|strike|bracket' tone padding duration delay>phrase</Highlighter>. Short phrases only: the mark is drawn around one box.",
      "whenToUse": [
        "Drawing attention to a key phrase in copy",
        "Editorial and tutorial text"
      ],
      "whenNotToUse": [
        "Multi-line passages",
        "Search-result highlighting (use a plain mark element)"
      ],
      "composesWith": [
        "text-animate",
        "callout"
      ],
      "a11y": [
        {
          "keys": "Screen readers",
          "action": "The mark is decorative (aria-hidden); the text is unchanged"
        },
        {
          "keys": "Contrast",
          "action": "Text keeps the foreground color on a translucent highlight"
        },
        {
          "keys": "Reduced motion",
          "action": "Marks are drawn instantly"
        }
      ],
      "customization": [
        "action",
        "tone",
        "strokeWidth and padding",
        "duration and delay",
        "inView"
      ]
    },
    "examples": [
      "highlighter-demo",
      "highlighter-actions"
    ],
    "url": "https://ui.ballmac.com/components/highlighter"
  },
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}