{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "annotated-text",
  "title": "Annotated text",
  "author": "Tori Bryan",
  "description": "Hand-drawn marks on a word or phrase, from underlines and circles to a highlighter, plus sticker chips that can cycle through words.",
  "dependencies": [
    "motion",
    "class-variance-authority"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/annotated-text.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { cva } from \"class-variance-authority\"\n\nimport { mergeRefs } from \"@/lib/merge-refs\"\nimport { cn } from \"@/lib/utils\"\n\n/** The mark drawn on the text. */\ntype AnnotatedTextVariant =\n  | \"underline\"\n  | \"wavy\"\n  | \"double\"\n  | \"highlight\"\n  | \"circle\"\n  | \"box\"\n  | \"bracket\"\n  | \"strike\"\n  | \"cross\"\n  | \"arrow\"\n\n/** What the mark means, if anything. Color only carries meaning in fibo. */\ntype AnnotatedTextTone =\n  \"default\" | \"muted\" | \"info\" | \"success\" | \"warning\" | \"destructive\"\n\ntype Size = { w: number; h: number; y: number }\n\nconst markVariants = cva(\"fill-none\", {\n  variants: {\n    tone: {\n      default: \"stroke-foreground\",\n      muted: \"stroke-muted-foreground\",\n      info: \"stroke-info\",\n      success: \"stroke-success\",\n      warning: \"stroke-warning\",\n      destructive: \"stroke-destructive\",\n    },\n  },\n  defaultVariants: { tone: \"default\" },\n})\n\n// A highlight sits behind the text, so it takes the quiet fill of each tone.\nconst highlightVariants = cva(\"fill-none\", {\n  variants: {\n    tone: {\n      default: \"stroke-highlight\",\n      muted: \"stroke-muted\",\n      info: \"stroke-info-subtle\",\n      success: \"stroke-success-subtle\",\n      warning: \"stroke-warning-subtle\",\n      destructive: \"stroke-destructive-subtle\",\n    },\n  },\n  defaultVariants: { tone: \"default\" },\n})\n\n/*\n * A small seeded generator, so a phrase gets the same wobble on every render,\n * device and server, and two different phrases don't wobble alike.\n */\nfunction seeded(seed: string) {\n  let hash = 2166136261\n  for (const char of seed) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619)\n  let state = hash >>> 0\n  return () => {\n    state = (Math.imul(state, 1664525) + 1013904223) >>> 0\n    return (state / 2 ** 32) * 2 - 1\n  }\n}\n\nconst fmt = (n: number) => Math.round(n * 10) / 10\n\nfunction line(points: [number, number][]) {\n  return points\n    .map(([x, y], i) => `${i ? \"L\" : \"M\"}${fmt(x)} ${fmt(y)}`)\n    .join(\" \")\n}\n\n/*\n * One gently bowed stroke from start to end, the way a pen drifts: the\n * control point sits off the straight line by up to `bow` pixels.\n */\nfunction bowed(\n  [x1, y1]: [number, number],\n  [x2, y2]: [number, number],\n  bow: number,\n  rand: () => number\n) {\n  const cx = (x1 + x2) / 2 + rand() * bow\n  const cy = (y1 + y2) / 2 + rand() * bow\n  return `M${fmt(x1)} ${fmt(y1)} Q${fmt(cx)} ${fmt(cy)} ${fmt(x2)} ${fmt(y2)}`\n}\n\n/*\n * Paths in the text's own pixels, origin at its top left. Text height drives\n * every offset, so a mark keeps its proportions from a caption to a display\n * heading. Each entry is drawn in order when the mark animates in.\n */\nfunction markPaths(\n  variant: AnnotatedTextVariant,\n  { w, h }: Size,\n  rand: () => number\n): string[] {\n  const j = h * 0.06\n  const base = h * 0.98\n  switch (variant) {\n    case \"underline\":\n      return [\n        bowed([-j, base + rand() * j], [w + j, base + rand() * j], j, rand),\n      ]\n    case \"double\":\n      return [\n        bowed([-j, base], [w + j, base + rand() * j], j, rand),\n        bowed(\n          [j, base + h * 0.16],\n          [w - j, base + h * 0.16 + rand() * j],\n          j,\n          rand\n        ),\n      ]\n    case \"wavy\": {\n      const wave = h * 0.32\n      const amp = h * 0.06\n      const count = Math.max(2, Math.round(w / wave))\n      const step = w / count\n      let d = `M0 ${fmt(base)}`\n      for (let i = 0; i < count; i++) {\n        const x = i * step\n        const up = i % 2 ? amp : -amp\n        d += ` Q${fmt(x + step / 2)} ${fmt(base + up * 2)} ${fmt(x + step)} ${fmt(base)}`\n      }\n      return [d]\n    }\n    case \"highlight\":\n      return [\n        bowed(\n          [-h * 0.12, h * 0.58 + rand() * j],\n          [w + h * 0.12, h * 0.54 + rand() * j],\n          j * 0.5,\n          rand\n        ),\n      ]\n    case \"strike\":\n      return [bowed([-j, h * 0.56 + rand() * j], [w + j, h * 0.5], j, rand)]\n    case \"cross\":\n      return [\n        bowed([-j, h * 0.15], [w + j, h * 0.9], j * 2, rand),\n        bowed([w + j, h * 0.1], [-j, h * 0.88], j * 2, rand),\n      ]\n    case \"circle\": {\n      // A loop that starts above the left of the text and runs a little past\n      // where it began, so the ends cross the way a hand-drawn ring does.\n      const cx = w / 2\n      const cy = h / 2\n      const rx = w / 2 + h * 0.4\n      const ry = h / 2 + h * 0.3\n      const tilt = rand() * 0.04\n      const start = -Math.PI * 0.62\n      const points: [number, number][] = []\n      const steps = 48\n      for (let i = 0; i <= steps; i++) {\n        const t = start + (i / steps) * Math.PI * 2.08\n        const drift = 0.96 + (i / steps) * 0.12 + Math.sin(t * 3) * 0.015\n        const x = cx + Math.cos(t) * rx * drift\n        const y = cy + Math.sin(t) * ry * drift\n        points.push([x, y + (x - cx) * tilt])\n      }\n      return [line(points)]\n    }\n    case \"box\": {\n      const p = h * 0.22\n      const l = -p\n      const r = w + p\n      const t = -p * 0.6\n      const b = h + p * 0.6\n      // Each side overshoots the corner a little, as boxes drawn by hand do.\n      const o = p * 0.5\n      return [\n        line([\n          [l + rand() * j, t - o * 0.3],\n          [r + o, t + rand() * j],\n          [r + rand() * j, b + o * 0.6],\n          [l - o * 0.4, b + rand() * j],\n          [l + rand() * j, t - o],\n        ]),\n      ]\n    }\n    case \"bracket\": {\n      const p = h * 0.24\n      const arm = h * 0.22\n      return [\n        line([\n          [-p + arm, -h * 0.08],\n          [-p, -h * 0.06 + rand() * j],\n          [-p + rand() * j, h * 1.08],\n          [-p + arm, h * 1.1],\n        ]),\n        line([\n          [w + p - arm, -h * 0.08],\n          [w + p, -h * 0.06 + rand() * j],\n          [w + p + rand() * j, h * 1.08],\n          [w + p - arm, h * 1.1],\n        ]),\n      ]\n    }\n    case \"arrow\": {\n      const end: [number, number] = [w + h * 0.5, base - h * 0.08]\n      const head = h * 0.28\n      return [\n        bowed([-j, base + h * 0.06], end, j * 2, rand),\n        line([\n          [end[0] - head, end[1] - head * 0.7],\n          end,\n          [end[0] - head * 0.9, end[1] + head * 0.6],\n        ]),\n      ]\n    }\n  }\n}\n\nfunction strokeWidth(variant: AnnotatedTextVariant, h: number) {\n  if (variant === \"highlight\") return h * 0.78\n  return Math.max(1.5, h * 0.075)\n}\n\n// Marks drawn round the text reach past it, so they keep clear of the\n// neighboring words.\nconst ENCLOSING: AnnotatedTextVariant[] = [\"circle\", \"box\", \"bracket\"]\n\n// Ascender to descender for most UI faces, in em.\nconst GLYPH_HEIGHT = 1.2\n\n// How long a mark takes to draw in, and the pause between its strokes.\nconst DRAW_MS = 520\nconst STAGGER_MS = 160\n\ntype AnnotatedTextProps = React.ComponentProps<\"span\"> & {\n  /** The mark drawn on the text. */\n  variant?: AnnotatedTextVariant\n  /** What the mark means. Color only carries meaning, so leave it at default otherwise. */\n  tone?: AnnotatedTextTone\n  /** Draws the mark in the first time it scrolls into view, rather than showing it at once. */\n  animate?: boolean\n  /** Milliseconds to wait before drawing in, for staging several marks. */\n  delay?: number\n}\n\nfunction AnnotatedText({\n  variant = \"underline\",\n  tone = \"default\",\n  animate = false,\n  delay = 0,\n  className,\n  children,\n  ref,\n  ...props\n}: AnnotatedTextProps) {\n  const root = React.useRef<HTMLSpanElement>(null)\n  const reduceMotion = useReducedMotion()\n  const [size, setSize] = React.useState<Size | null>(null)\n  const [text, setText] = React.useState(\"\")\n  const [seen, setSeen] = React.useState(false)\n  const drawn = !animate || seen\n\n  // The mark is drawn in the text's own pixels, so it follows font loading,\n  // resizing and type changes rather than stretching a fixed drawing.\n  React.useLayoutEffect(() => {\n    const el = root.current\n    if (!el) return\n    const measure = () => {\n      // The glyphs, not the line box: loose leading would push an underline\n      // halfway to the next line.\n      const h = Math.min(\n        el.offsetHeight,\n        parseFloat(getComputedStyle(el).fontSize) * GLYPH_HEIGHT\n      )\n      setSize({ w: el.offsetWidth, h, y: (el.offsetHeight - h) / 2 })\n      setText(el.textContent ?? \"\")\n    }\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [])\n\n  React.useEffect(() => {\n    const el = root.current\n    if (!animate || !el) return\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry?.isIntersecting) return\n        setSeen(true)\n        observer.disconnect()\n      },\n      { threshold: 0.6 }\n    )\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [animate])\n\n  const paths = React.useMemo(\n    () =>\n      size && size.w > 0\n        ? markPaths(variant, size, seeded(`${variant}:${text}`))\n        : [],\n    [variant, size, text]\n  )\n  const behind = variant === \"highlight\"\n  const still = reduceMotion || !animate\n\n  return (\n    <span\n      ref={mergeRefs(root, ref)}\n      data-slot=\"annotated-text\"\n      data-variant={variant}\n      data-tone={tone}\n      data-drawn={drawn ? \"\" : undefined}\n      className={cn(\n        \"relative isolate inline-block whitespace-nowrap\",\n        ENCLOSING.includes(variant) && \"mx-[0.45em]\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      {size && (\n        <svg\n          data-slot=\"annotated-text-mark\"\n          aria-hidden=\"true\"\n          width={size.w}\n          height={size.h}\n          style={{ top: size.y }}\n          className={cn(\n            \"pointer-events-none absolute left-0 overflow-visible\",\n            behind && \"-z-10\"\n          )}\n        >\n          {paths.map((d, i) => (\n            <path\n              key={i}\n              d={d}\n              pathLength={1}\n              strokeWidth={strokeWidth(variant, size.h)}\n              strokeLinecap={behind ? \"butt\" : \"round\"}\n              strokeLinejoin=\"round\"\n              className={(behind ? highlightVariants : markVariants)({ tone })}\n              style={\n                still\n                  ? undefined\n                  : {\n                      strokeDasharray: \"1 2\",\n                      strokeDashoffset: drawn ? 0 : 1,\n                      transition: `stroke-dashoffset ${DRAW_MS}ms cubic-bezier(0.65, 0, 0.35, 1) ${delay + i * STAGGER_MS}ms`,\n                    }\n              }\n            />\n          ))}\n        </svg>\n      )}\n    </span>\n  )\n}\n\n/*\n * Two ways to make a sticker. An emoji or image already has a solid\n * silhouette, so it gets a die-cut paper edge: stacked drop shadows in em\n * that follow its outline. A line icon has holes the edge would trace stroke\n * by stroke, so it is printed in ink on a small paper tile instead.\n */\nconst CUTOUT =\n  \"[filter:drop-shadow(0.05em_0_0_var(--sticker-edge))_drop-shadow(-0.05em_0_0_var(--sticker-edge))_drop-shadow(0_0.05em_0_var(--sticker-edge))_drop-shadow(0_-0.05em_0_var(--sticker-edge))_drop-shadow(0_0.04em_0.06em_var(--sticker-shadow))]\"\nconst TILE =\n  \"rounded-[0.24em] bg-sticker-edge p-[0.17em] text-sticker-ink shadow-[0_0.03em_0.08em_var(--sticker-shadow)] [&>svg]:stroke-[2.5]\"\n\nfunction isCutout(icon: React.ReactNode) {\n  return (\n    typeof icon === \"string\" ||\n    (React.isValidElement(icon) && icon.type === \"img\")\n  )\n}\n\nconst PILL = \"rounded-full bg-chip py-[0.04em] leading-[1.1] whitespace-nowrap\"\n\ntype AnnotatedTextChipProps = React.ComponentProps<\"span\"> & {\n  /** A sticker at the chip's start. An emoji or `<img>` is die-cut; an icon is printed on a paper tile. Decorative. */\n  icon?: React.ReactNode\n  /** Tilts the sticker, in degrees. Defaults to a small tilt from the text. */\n  tilt?: number\n}\n\nfunction chipTilt(children: React.ReactNode) {\n  return typeof children === \"string\" ? seeded(children)() * 9 : -6\n}\n\nfunction AnnotatedTextChip({\n  icon,\n  tilt,\n  className,\n  children,\n  ...props\n}: AnnotatedTextChipProps) {\n  return (\n    <span\n      data-slot=\"annotated-text-chip\"\n      className={cn(\n        \"inline-block ps-[0.12em] pe-[0.32em]\",\n        PILL,\n        !icon && \"ps-[0.32em]\",\n        className\n      )}\n      {...props}\n    >\n      {icon && (\n        <ChipSticker tilt={tilt ?? chipTilt(children)}>{icon}</ChipSticker>\n      )}\n      {children}\n    </span>\n  )\n}\n\nfunction ChipSticker({\n  tilt,\n  children,\n}: {\n  tilt: number\n  children: React.ReactNode\n}) {\n  return (\n    <span\n      data-slot=\"annotated-text-chip-icon\"\n      aria-hidden=\"true\"\n      style={{ rotate: `${tilt}deg` }}\n      className={cn(\n        \"me-[0.18em] inline-flex size-[1.1em] items-center justify-center align-[-0.2em] leading-none [&>img]:size-full [&>img]:object-contain [&>svg]:size-full\",\n        isCutout(children) ? CUTOUT : TILE\n      )}\n    >\n      {children}\n    </span>\n  )\n}\n\ntype AnnotatedTextCycleItem = {\n  /** The word or phrase. */\n  label: string\n  /** Its sticker. */\n  icon?: React.ReactNode\n}\n\ntype AnnotatedTextCycleProps = Omit<\n  React.ComponentProps<\"span\">,\n  \"children\"\n> & {\n  /** What the chip cycles through, in order. Needs at least one. */\n  items: AnnotatedTextCycleItem[]\n  /** Milliseconds each item holds. */\n  interval?: number\n  /** Keeps cycling. Off, it runs through once and settles on the last item. */\n  loop?: boolean\n  /** Stops on the current item, for a pause button or a hidden tab. */\n  paused?: boolean\n  /** What screen readers hear in place of the changing word. Defaults to the first item. */\n  label?: string\n}\n\nfunction AnnotatedTextCycle({\n  items,\n  interval = 2000,\n  loop = false,\n  paused = false,\n  label,\n  className,\n  onPointerEnter,\n  onPointerLeave,\n  ...props\n}: AnnotatedTextCycleProps) {\n  const reduceMotion = useReducedMotion()\n  const [index, setIndex] = React.useState(0)\n  const [hovered, setHovered] = React.useState(false)\n  const [width, setWidth] = React.useState<number | null>(null)\n  const content = React.useRef<HTMLSpanElement>(null)\n\n  const last = items.length - 1\n  const done = !loop && index >= last\n  const still = reduceMotion || paused || hovered || done || items.length < 2\n\n  React.useEffect(() => {\n    if (still) return\n    const timer = window.setTimeout(\n      () => setIndex((i) => (i + 1) % items.length),\n      interval\n    )\n    return () => window.clearTimeout(timer)\n  }, [still, index, interval, items.length])\n\n  React.useLayoutEffect(() => {\n    const el = content.current\n    if (!el) return\n    const measure = () => setWidth(el.offsetWidth)\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [])\n\n  const item = items[Math.min(index, last)]\n  if (!item) return null\n\n  return (\n    <span\n      data-slot=\"annotated-text-cycle\"\n      data-index={index}\n      className={cn(\"inline-block\", className)}\n      onPointerEnter={(event) => {\n        setHovered(true)\n        onPointerEnter?.(event)\n      }}\n      onPointerLeave={(event) => {\n        setHovered(false)\n        onPointerLeave?.(event)\n      }}\n      {...props}\n    >\n      <span className=\"sr-only\">{label ?? items[0]?.label}</span>\n      <motion.span\n        aria-hidden=\"true\"\n        className={cn(\n          \"inline-flex [overflow-x:clip] [overflow-clip-margin:0.25em]\",\n          PILL\n        )}\n        initial={false}\n        animate={width === null ? undefined : { width }}\n        transition={\n          reduceMotion\n            ? { duration: 0 }\n            : { type: \"spring\", bounce: 0.2, duration: 0.5 }\n        }\n      >\n        <span\n          ref={content}\n          className=\"relative inline-block ps-[0.12em] pe-[0.32em]\"\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={item.label}\n              className=\"inline-block\"\n              initial={{ opacity: 0, y: \"0.5em\", filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, y: \"-0.5em\", filter: \"blur(4px)\" }}\n              transition={{ duration: reduceMotion ? 0 : 0.32 }}\n            >\n              {item.icon && (\n                <ChipSticker tilt={chipTilt(item.label)}>\n                  {item.icon}\n                </ChipSticker>\n              )}\n              {item.label}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </motion.span>\n    </span>\n  )\n}\n\nexport { AnnotatedText, AnnotatedTextChip, AnnotatedTextCycle }\nexport type {\n  AnnotatedTextProps,\n  AnnotatedTextVariant,\n  AnnotatedTextTone,\n  AnnotatedTextChipProps,\n  AnnotatedTextCycleProps,\n  AnnotatedTextCycleItem,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/lib/merge-refs.ts",
      "content": "import * as React from \"react\"\n\n/**\n * One callback ref that sets every ref given, so a part can keep its own\n * ref on an element and still hand the element to a caller's `ref`. Undo\n * runs through React 19's ref cleanup, which calls a function ref with\n * null and empties an object ref.\n */\nexport function mergeRefs<T>(\n  ...refs: (React.Ref<T> | undefined)[]\n): React.RefCallback<T> {\n  return (node) => {\n    const cleanups = refs.map((ref) => {\n      if (typeof ref === \"function\") {\n        const cleanup = ref(node)\n        return typeof cleanup === \"function\" ? cleanup : () => ref(null)\n      }\n      if (ref) {\n        ref.current = node\n        return () => {\n          ref.current = null\n        }\n      }\n      return undefined\n    })\n    return () => {\n      for (const cleanup of cleanups) cleanup?.()\n    }\n  }\n}\n",
      "type": "registry:lib"
    }
  ],
  "cssVars": {
    "theme": {
      "color-highlight": "var(--highlight)",
      "color-chip": "var(--chip)",
      "color-destructive-subtle": "var(--destructive-subtle)",
      "color-success": "var(--success)",
      "color-success-subtle": "var(--success-subtle)",
      "color-warning": "var(--warning)",
      "color-warning-subtle": "var(--warning-subtle)",
      "color-info": "var(--info)",
      "color-info-subtle": "var(--info-subtle)",
      "color-sticker-edge": "var(--sticker-edge)",
      "color-sticker-ink": "var(--sticker-ink)",
      "color-sticker-shadow": "var(--sticker-shadow)",
      "stroke-destructive": "var(--destructive-icon)",
      "stroke-success": "var(--success-icon)",
      "stroke-warning": "var(--warning-icon)",
      "stroke-info": "var(--info-icon)"
    },
    "light": {
      "highlight": "var(--color-neutral-200)",
      "chip": "var(--color-neutral-200)",
      "destructive-subtle": "color-mix(in oklch, var(--color-red-700) 8%, transparent)",
      "success": "var(--color-green-700)",
      "success-subtle": "color-mix(in oklch, var(--color-green-700) 6%, transparent)",
      "warning": "var(--color-amber-700)",
      "warning-subtle": "color-mix(in oklch, var(--color-amber-700) 6%, transparent)",
      "info": "var(--color-blue-700)",
      "info-subtle": "color-mix(in oklch, var(--color-blue-700) 6%, transparent)",
      "sticker-edge": "var(--color-white)",
      "sticker-ink": "var(--color-neutral-900)",
      "sticker-shadow": "color-mix(in oklch, var(--color-black) 30%, transparent)",
      "destructive-icon": "var(--destructive)",
      "success-icon": "var(--success)",
      "warning-icon": "var(--warning)",
      "info-icon": "var(--info)"
    },
    "dark": {
      "highlight": "var(--color-neutral-700)",
      "chip": "var(--color-neutral-800)",
      "destructive-subtle": "color-mix(in oklch, var(--color-red-400) 20%, transparent)",
      "success": "var(--color-green-400)",
      "success-subtle": "color-mix(in oklch, var(--color-green-400) 20%, transparent)",
      "warning": "var(--color-amber-400)",
      "warning-subtle": "color-mix(in oklch, var(--color-amber-400) 20%, transparent)",
      "info": "var(--color-blue-400)",
      "info-subtle": "color-mix(in oklch, var(--color-blue-400) 20%, transparent)",
      "sticker-edge": "var(--color-white)",
      "sticker-ink": "var(--color-neutral-900)",
      "sticker-shadow": "color-mix(in oklch, var(--color-black) 30%, transparent)",
      "destructive-icon": "var(--destructive)",
      "success-icon": "var(--success)",
      "warning-icon": "var(--warning)",
      "info-icon": "var(--info)"
    }
  },
  "meta": {
    "tier": "special-components",
    "group": "Display",
    "docs": "https://fibo.toribryan.com/?path=/docs/special-components-annotated-text--docs"
  },
  "docs": "Docs and live examples: https://fibo.toribryan.com/?path=/docs/special-components-annotated-text--docs",
  "categories": [
    "special-components",
    "display"
  ],
  "type": "registry:ui"
}