{ "$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). 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(\"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 \n );\n}\n\nfunction IconBase({\n className,\n children,\n}: {\n className?: string;\n children: React.ReactNode;\n}) {\n return (\n \n {children}\n \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 \n \n \n \n );\n}\n\n/* sparkles: the big star draws, then the two glints flick in */\nexport function SparklesIcon({ className }: AnimatedIconProps) {\n return (\n \n \n \n \n \n \n \n );\n}\n\n/* mouse-pointer-click: cursor draws, then the click ticks paint out */\nexport function MousePointerClickIcon({ className }: AnimatedIconProps) {\n return (\n \n \n \n \n \n \n \n );\n}\n\n/* rocket: body draws, fins follow, exhaust flame paints last */\nexport function RocketIcon({ className }: AnimatedIconProps) {\n return (\n \n \n \n \n \n \n );\n}\n\n/* ------------------------------------------------------------------ */\n/* Highlight */\n/* ------------------------------------------------------------------ */\nexport interface HighlightProps\n extends Omit, \"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(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 \n {icon}\n \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 {\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 \n {iconPosition === \"before\" ? renderedIcon : null}\n {children}\n {iconPosition === \"after\" ? renderedIcon : null}\n \n\n {/* image tooltip — anchored above the word, tracking the cursor's x */}\n {image ? (\n \n {active ? (\n \n \n \n \n \n \n \n \n ) : null}\n \n ) : null}\n \n );\n}\n\n/* ------------------------------------------------------------------ */\n/* AnimatedHighlightText */\n/* ------------------------------------------------------------------ */\nexport interface AnimatedHighlightTextProps\n extends React.HTMLAttributes {\n /** The paragraph — mix plain copy with 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 \n {children}\n \n );\n}\n", "type": "registry:ui", "target": "components/ruixen/animated-highlight-text.tsx" } ] }