{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-highlight-text",
  "type": "registry:ui",
  "title": "Animated Highlight Text",
  "description": "Typographic block that mixes plain copy with inline highlights carrying bespoke animated SVG icons — a heart that beats a real lub-dub, twinkling sparkles, a clicking cursor, a launching rocket — that come alive on hover or focus.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/ruixenui/animated-highlight-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  type Variants,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ */\n/*  draw engine                                                       */\n/* ------------------------------------------------------------------ */\n/*\n * The animation is a stroke DRAW-ON: each path animates its own\n * `pathLength` from 0 → 1 so the line paints itself in, then STOPS\n * (no looping, no shaking, no moving the whole icon). At rest every\n * path sits fully drawn (pathLength 1). <Highlight> tracks hover/focus\n * and pushes the play state down through DrawContext, so hovering the\n * word re-draws its icon once.\n */\n\ntype DrawState = \"rest\" | \"draw\";\nconst DrawContext = React.createContext<DrawState>(\"rest\");\n\nexport interface AnimatedIconProps {\n  className?: string;\n}\n\nconst drawVariants = (delay = 0, duration = 0.55): Variants => ({\n  rest: { pathLength: 1, opacity: 1 },\n  draw: {\n    pathLength: [0, 1],\n    opacity: [0, 1],\n    transition: {\n      pathLength: { duration, ease: \"easeInOut\", delay },\n      opacity: { duration: 0.12, delay },\n    },\n  },\n});\n\n/** A single stroke that paints itself in when the highlight is active. */\nfunction DrawPath({\n  d,\n  delay = 0,\n  duration = 0.55,\n}: {\n  d: string;\n  delay?: number;\n  duration?: number;\n}) {\n  const state = React.useContext(DrawContext);\n  return (\n    <motion.path\n      d={d}\n      variants={drawVariants(delay, duration)}\n      initial=\"rest\"\n      animate={state}\n    />\n  );\n}\n\nfunction IconBase({\n  className,\n  children,\n}: {\n  className?: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      className={cn(\"inline-block size-[1.15em] align-[-0.22em]\", className)}\n    >\n      {children}\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/*  icons — each redraws its own strokes on hover                     */\n/* ------------------------------------------------------------------ */\n\n/* heart: outline draws in, then the fill washes back over it */\nconst HEART_D =\n  \"M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.49 4.04 3 5.5l7 7Z\";\n\nconst FILL_REVEAL: Variants = {\n  rest: { opacity: 1 },\n  draw: {\n    opacity: [0, 0, 1],\n    transition: { duration: 0.75, times: [0, 0.62, 1], ease: \"easeOut\" },\n  },\n};\n\nexport function HeartIcon({ className }: AnimatedIconProps) {\n  const state = React.useContext(DrawContext);\n  return (\n    <IconBase className={className}>\n      <motion.path\n        d={HEART_D}\n        fill=\"currentColor\"\n        stroke=\"none\"\n        variants={FILL_REVEAL}\n        initial=\"rest\"\n        animate={state}\n      />\n      <DrawPath d={HEART_D} duration={0.6} />\n    </IconBase>\n  );\n}\n\n/* sparkles: the big star draws, then the two glints flick in */\nexport function SparklesIcon({ className }: AnimatedIconProps) {\n  return (\n    <IconBase className={className}>\n      <DrawPath\n        d=\"M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0Z\"\n        duration={0.6}\n      />\n      <DrawPath d=\"M20 3v4\" delay={0.45} duration={0.2} />\n      <DrawPath d=\"M22 5h-4\" delay={0.5} duration={0.2} />\n      <DrawPath d=\"M4 17v2\" delay={0.6} duration={0.2} />\n      <DrawPath d=\"M5 18H3\" delay={0.65} duration={0.2} />\n    </IconBase>\n  );\n}\n\n/* mouse-pointer-click: cursor draws, then the click ticks paint out */\nexport function MousePointerClickIcon({ className }: AnimatedIconProps) {\n  return (\n    <IconBase className={className}>\n      <DrawPath\n        d=\"M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074Z\"\n        duration={0.5}\n      />\n      <DrawPath d=\"M14 4.1 12 6\" delay={0.42} duration={0.22} />\n      <DrawPath d=\"m5.1 8-2.9-.8\" delay={0.48} duration={0.22} />\n      <DrawPath d=\"m6 12-1.9 2\" delay={0.54} duration={0.22} />\n      <DrawPath d=\"M7.2 2.2 8 5.1\" delay={0.6} duration={0.22} />\n    </IconBase>\n  );\n}\n\n/* rocket: body draws, fins follow, exhaust flame paints last */\nexport function RocketIcon({ className }: AnimatedIconProps) {\n  return (\n    <IconBase className={className}>\n      <DrawPath\n        d=\"m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z\"\n        duration={0.55}\n      />\n      <DrawPath\n        d=\"M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0\"\n        delay={0.35}\n        duration={0.3}\n      />\n      <DrawPath\n        d=\"M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5\"\n        delay={0.45}\n        duration={0.3}\n      />\n      <DrawPath\n        d=\"M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z\"\n        delay={0.7}\n        duration={0.3}\n      />\n    </IconBase>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/*  Highlight                                                         */\n/* ------------------------------------------------------------------ */\nexport interface HighlightProps\n  extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"color\"> {\n  /** The word(s) that light up. */\n  children: React.ReactNode;\n  /** One of the animated icon components (or any node) shown inline. */\n  icon: React.ReactNode;\n  /** Accent color for the text + icon (any CSS color). */\n  color?: string;\n  /** Icon side, relative to the text. */\n  iconPosition?: \"before\" | \"after\";\n  /** Image URL shown in a tooltip while the highlight is hovered / focused. */\n  image?: string;\n  /** Alt text for the tooltip image. */\n  imageAlt?: string;\n  /** Draw the icon once on mount instead of waiting for hover. */\n  autoPlay?: boolean;\n}\n\nexport function Highlight({\n  children,\n  icon,\n  color,\n  iconPosition = \"before\",\n  image,\n  imageAlt = \"\",\n  autoPlay = false,\n  className,\n  ...props\n}: HighlightProps) {\n  const reduce = useReducedMotion();\n  const [active, setActive] = React.useState(autoPlay);\n  const tipId = React.useId();\n  const ref = React.useRef<HTMLSpanElement>(null);\n\n  /* the tooltip tracks the pointer along the word using LOCAL coordinates\n     (offset from the highlight's own left edge) — no portal, no viewport\n     math, so it can't drift when nested in a scaled / offset container */\n  const mvLeft = useMotionValue(0);\n  const left = useSpring(mvLeft, { stiffness: 700, damping: 45, mass: 0.5 });\n\n  const pointTo = (clientX: number, snap = false) => {\n    const rect = ref.current?.getBoundingClientRect();\n    if (!rect) return;\n    const lx = clientX - rect.left;\n    mvLeft.set(lx);\n    if (snap) left.jump(lx);\n  };\n\n  const focusCenter = (snap = false) => {\n    const rect = ref.current?.getBoundingClientRect();\n    if (rect) pointTo(rect.left + rect.width / 2, snap);\n  };\n\n  const state: DrawState = !reduce && (autoPlay || active) ? \"draw\" : \"rest\";\n\n  /* keep the word as plain inline text so it shares the paragraph\n     baseline; only the icon is inline-block + vertical-align nudged.\n     spacing lives on the icon — no flex wrapper to distort alignment. */\n  const spacing = iconPosition === \"before\" ? \"mr-[0.22em]\" : \"ml-[0.22em]\";\n  const renderedIcon = React.isValidElement(icon)\n    ? React.cloneElement(icon as React.ReactElement<{ className?: string }>, {\n        className: cn(\n          spacing,\n          (icon.props as { className?: string }).className,\n        ),\n      })\n    : icon && (\n        <span className={cn(\"inline-block align-[-0.1em]\", spacing)}>\n          {icon}\n        </span>\n      );\n\n  /* marker-style highlight behind the word: accent tint when a color is\n     set (inline, since the color is dynamic), otherwise a neutral token\n     wash via hover/focus classes that reads in light and dark */\n  const style: React.CSSProperties = {};\n  if (color) {\n    style.color = color;\n    style.backgroundColor = active ? `${color}22` : \"transparent\";\n  }\n\n  return (\n    <span\n      className={cn(\n        // whitespace-nowrap keeps the icon + word as one unbreakable unit, so\n        // the span never splits across lines — that split is what made the\n        // tooltip anchor to the wrong fragment.\n        \"relative -mx-[0.15em] inline-block cursor-pointer whitespace-nowrap rounded-[0.35em] px-[0.15em] align-baseline font-semibold text-foreground transition-colors\",\n        !color && \"hover:bg-foreground/10 focus-visible:bg-foreground/10\",\n        className,\n      )}\n      style={style}\n      tabIndex={0}\n      aria-describedby={image && active ? tipId : undefined}\n      onMouseEnter={(e) => {\n        pointTo(e.clientX, true);\n        setActive(true);\n      }}\n      onMouseMove={(e) => pointTo(e.clientX)}\n      onMouseLeave={() => setActive(false)}\n      onFocus={() => {\n        focusCenter(true);\n        setActive(true);\n      }}\n      onBlur={() => setActive(false)}\n      {...props}\n    >\n      <DrawContext.Provider value={state}>\n        {iconPosition === \"before\" ? renderedIcon : null}\n        {children}\n        {iconPosition === \"after\" ? renderedIcon : null}\n      </DrawContext.Provider>\n\n      {/* image tooltip — anchored above the word, tracking the cursor's x */}\n      {image ? (\n        <AnimatePresence>\n          {active ? (\n            <motion.span\n              key=\"tip\"\n              role=\"tooltip\"\n              id={tipId}\n              className=\"pointer-events-none absolute bottom-full z-50 mb-2 block -translate-x-1/2\"\n              style={{ left }}\n            >\n              <motion.span\n                className=\"block origin-bottom\"\n                initial={{ opacity: 0, scale: reduce ? 1 : 0.9 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: reduce ? 1 : 0.94 }}\n                transition={{ type: \"spring\", stiffness: 460, damping: 30 }}\n              >\n                <span className=\"block w-44 overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-xl\">\n                  <img\n                    src={image}\n                    alt={imageAlt}\n                    loading=\"lazy\"\n                    className=\"block h-24 w-full rounded-lg object-cover\"\n                  />\n                </span>\n                <span className=\"absolute left-1/2 top-full -mt-1 block size-2 -translate-x-1/2 rotate-45 border-b border-r border-border bg-popover\" />\n              </motion.span>\n            </motion.span>\n          ) : null}\n        </AnimatePresence>\n      ) : null}\n    </span>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/*  AnimatedHighlightText                                             */\n/* ------------------------------------------------------------------ */\nexport interface AnimatedHighlightTextProps\n  extends React.HTMLAttributes<HTMLParagraphElement> {\n  /** The paragraph — mix plain copy with <Highlight> nodes. */\n  children: React.ReactNode;\n  /** Render as a different element (e.g. \"h2\", \"div\"). */\n  as?: React.ElementType;\n}\n\n/**\n * A typographic block that mixes plain copy with {@link Highlight} spans.\n * Each highlight carries an icon that redraws its own strokes on hover / focus.\n */\nexport default function AnimatedHighlightText({\n  children,\n  as: Tag = \"p\",\n  className,\n  ...props\n}: AnimatedHighlightTextProps) {\n  return (\n    <Tag\n      className={cn(\n        \"max-w-2xl text-pretty text-lg leading-relaxed text-muted-foreground md:text-xl\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </Tag>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ruixen/animated-highlight-text.tsx"
    }
  ]
}