{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "highlight",
  "type": "registry:ui",
  "title": "Highlight",
  "description": "A marker stroke drawn in reading order, carrying on across line breaks.",
  "categories": [
    "content"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json"
  ],
  "files": [
    {
      "path": "components/ui/highlight.tsx",
      "content": "\"use client\";\nimport { createContext, use, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nconst tones = {\n  neutral: \"[--hl:color-mix(in_oklab,var(--fg)_15%,transparent)]\",\n  warning: \"[--hl:color-mix(in_oklab,var(--warning)_26%,transparent)]\",\n  success: \"[--hl:color-mix(in_oklab,var(--success)_22%,transparent)]\",\n  danger: \"[--hl:color-mix(in_oklab,var(--danger)_22%,transparent)]\",\n  info: \"[--hl:color-mix(in_oklab,var(--info)_22%,transparent)]\",\n} as const;\n\n/** Pen speed in px per second, and the shortest and longest a stroke may take, in seconds. */\nconst PACE = { speed: 520, min: 0.4, max: 1.2 };\n\n/** Seconds a stroke takes: from its real length across every line it covers, like a pen at a steady pace. */\nfunction strokeTime(el: HTMLElement) {\n  const fixed = Number(el.dataset.duration);\n  if (fixed > 0) return fixed;\n  const length = Array.from(el.getClientRects()).reduce((sum, r) => sum + r.width, 0);\n  return Math.min(PACE.max, Math.max(PACE.min, length / PACE.speed));\n}\n\nfunction pace(el: HTMLElement, seconds: number, delay?: number) {\n  el.style.setProperty(\"--hl-in\", `${seconds}s`);\n  el.style.setProperty(\"--hl-out\", `${Math.max(0.2, seconds * 0.6)}s`);\n  if (delay !== undefined) el.style.setProperty(\"--hl-delay\", `${delay}s`);\n}\n\nconst reducedMotion = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n/** Once when it scrolls into view. Scrolled past unseen counts as seen. */\nfunction useSeen(ref: React.RefObject<HTMLElement | null>, enabled: boolean) {\n  const [seen, setSeen] = useState(false);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        const above = entry.boundingClientRect.bottom < (entry.rootBounds?.top ?? 0);\n        if (!entry.isIntersecting && !above) return;\n        io.disconnect();\n        setSeen(true);\n      },\n      { rootMargin: \"0px 0px -15% 0px\" },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, enabled]);\n  return seen;\n}\n\nconst Group = createContext<{ on: boolean } | null>(null);\n\ntype Trigger = {\n  /** Draw once when it scrolls into view, or only when `active` says so. */\n  trigger?: \"view\" | \"manual\";\n  /** Controls it from outside. true draws, false wipes back out. Wins over `trigger`. */\n  active?: boolean;\n};\n\nexport type HighlightProps = Omit<React.HTMLAttributes<HTMLElement>, \"children\"> &\n  Trigger & {\n    children: React.ReactNode;\n    /** A full-height marker behind the text, or a low stroke under it. */\n    variant?: \"marker\" | \"underline\";\n    /** Neutral emphasis, or a meaning: attention, added, removed, a note. */\n    tone?: keyof typeof tones;\n    /** Seconds before drawing. Ignored inside a HighlightGroup, which sequences for you. */\n    delay?: number;\n    /** Seconds to draw. By default it follows the length of the stroke. */\n    duration?: number;\n    /** Called when it has finished drawing in. */\n    onDrawn?: () => void;\n    ref?: React.Ref<HTMLElement>;\n  };\n\n/**\n * A marker stroke drawn behind text, left to right in reading order. It is a\n * background on the inline element, not a box behind it, so when the phrase\n * wraps the stroke finishes the first line and carries on along the next.\n */\nexport function Highlight({\n  children,\n  trigger = \"view\",\n  active,\n  variant = \"marker\",\n  tone = \"neutral\",\n  delay = 0,\n  duration,\n  onDrawn,\n  className,\n  style,\n  ref,\n  ...rest\n}: HighlightProps) {\n  const group = use(Group);\n  const self = useRef<HTMLElement>(null);\n  const seen = useSeen(self, !group && active === undefined && trigger === \"view\");\n  const on = group ? group.on : (active ?? (trigger === \"view\" && seen));\n  const drawn = useRef(onDrawn);\n  useEffect(() => {\n    drawn.current = onDrawn;\n  });\n\n  // Paced while it's off, ready for the next draw: measuring after it turns on\n  // would flush styles and start the transition before its timing is set. The\n  // stroke's length doesn't depend on its state. In a group, the group paces.\n  useIsoLayoutEffect(() => {\n    const el = self.current;\n    if (!el) return;\n    if (!on && !group) pace(el, strokeTime(el));\n    // With reduced motion nothing transitions, so there is no transitionend to wait for.\n    if (on && reducedMotion()) drawn.current?.();\n  }, [on, duration, group]);\n\n  return (\n    <>\n      <mark\n        ref={(node: HTMLElement | null) => {\n          self.current = node;\n          if (typeof ref === \"function\") return ref(node);\n          if (ref) (ref as React.RefObject<HTMLElement | null>).current = node;\n        }}\n        data-highlight=\"\"\n        data-state={on ? \"on\" : \"off\"}\n        data-variant={variant}\n        data-tone={tone}\n        data-duration={duration}\n        onTransitionEnd={(e) => {\n          if (e.target === e.currentTarget && e.propertyName === \"background-size\" && on) drawn.current?.();\n        }}\n        className={cn(\n          // Zero-specificity reset of the browser's black-on-yellow, so any colour class you pass wins.\n          \"bg-transparent [:where(&)]:text-inherit\",\n          tones[tone],\n          variant === \"underline\" && tone === \"neutral\" && \"[--hl:color-mix(in_oklab,var(--fg)_30%,transparent)]\",\n          // Slice (the default) lays the background out along the whole phrase as if it were\n          // one line, which is what makes a growing width draw line after line in order.\n          \"bg-[linear-gradient(var(--hl),var(--hl))] bg-no-repeat [box-decoration-break:slice]\",\n          variant === \"marker\"\n            ? \"-mx-[0.12em] rounded-[0.2em] px-[0.12em] [background-position:0_50%] data-[state=off]:[background-size:0%_100%] data-[state=on]:[background-size:100%_100%]\"\n            : \"[background-position:0_90%] data-[state=off]:[background-size:0%_0.36em] data-[state=on]:[background-size:100%_0.36em]\",\n          // Draws in on the soft ease-out after its delay; wipes back out faster, all at once.\n          // Colour rides along so a data-[state=on]: text colour eases with the stroke.\n          \"transition-[background-size,color] ease-out-quart duration-(--hl-out,0.3s) data-[state=on]:delay-(--hl-delay,0s) data-[state=on]:duration-(--hl-in,0.6s)\",\n          \"motion-reduce:transition-none\",\n          className,\n        )}\n        style={group ? style : ({ \"--hl-delay\": `${delay}s`, ...style } as React.CSSProperties)}\n        {...rest}\n      >\n        {children}\n      </mark>\n      {!group && trigger === \"view\" && active === undefined && (\n        <noscript>\n          <style>{\"[data-highlight][data-state=off]{background-size:100% 100%!important}\"}</style>\n        </noscript>\n      )}\n    </>\n  );\n}\n\nexport type HighlightGroupProps = React.HTMLAttributes<HTMLElement> &\n  Trigger & {\n    as?: \"div\" | \"p\" | \"section\" | \"article\" | \"span\";\n    /** Seconds before the first stroke. */\n    delay?: number;\n    /** Seconds between one stroke finishing and the next starting. */\n    gap?: number;\n    ref?: React.Ref<HTMLElement>;\n  };\n\n/**\n * Draws every Highlight inside it one after another, in reading order: each\n * stroke starts as the one before it finishes, whatever their lengths. Wiping\n * out happens all at once.\n */\nexport function HighlightGroup({ as: Tag = \"div\", trigger = \"view\", active, delay = 0, gap = 0.06, className, children, ref, ...rest }: HighlightGroupProps) {\n  const self = useRef<HTMLElement>(null);\n  const seen = useSeen(self, active === undefined && trigger === \"view\");\n  const on = active ?? (trigger === \"view\" && seen);\n\n  // Pace and chain the marks while they're off, ready for the next draw (see Highlight).\n  useIsoLayoutEffect(() => {\n    const el = self.current;\n    if (!el || on) return;\n    let at = delay;\n    for (const mark of el.querySelectorAll<HTMLElement>(\"[data-highlight]\")) {\n      const seconds = strokeTime(mark);\n      pace(mark, seconds, at);\n      at += seconds + gap;\n    }\n  }, [on, delay, gap]);\n\n  return (\n    <Group value={{ on }}>\n      <Tag\n        ref={(node: HTMLElement | null) => {\n          self.current = node;\n          if (typeof ref === \"function\") return ref(node);\n          if (ref) (ref as React.RefObject<HTMLElement | null>).current = node;\n        }}\n        data-highlight-group=\"\"\n        data-state={on ? \"on\" : \"off\"}\n        className={className}\n        {...rest}\n      >\n        {children}\n      </Tag>\n      {trigger === \"view\" && active === undefined && (\n        <noscript>\n          <style>{\"[data-highlight-group] [data-highlight]{background-size:100% 100%!important}\"}</style>\n        </noscript>\n      )}\n    </Group>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/highlight"
}
