{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-link",
  "type": "registry:ui",
  "title": "Animated Link",
  "description": "Inline links with refined hover and focus underlines — slide, grow, double, highlight, arrow and wave — that follow line wraps.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/animated-link.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type AnimatedLinkVariant = \"slide\" | \"grow\" | \"double\" | \"highlight\" | \"arrow\" | \"wave\";\n\nexport interface AnimatedLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {\n  /** Underline animation. */\n  variant?: AnimatedLinkVariant;\n  /** Colour of the animated line / highlight (hex). */\n  color?: string;\n  /** Thickness of the animated line in px. */\n  thickness?: number;\n  /** Duration of the draw in ms. */\n  duration?: number;\n  /** Gap between the text baseline area and the line, in px. */\n  offset?: number;\n  /** Keep a faint resting underline so the link is identifiable without hover or colour. */\n  restingLine?: boolean;\n  /** Open in a new tab: adds target/rel, an ↗ indicator and screen-reader text. */\n  external?: boolean;\n  /** Screen-reader text appended for external links. */\n  externalLabel?: string;\n}\n\n/* --------------------------------- helpers --------------------------------- */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst WORD_JOINER = \"\\u2060\";\nconst WAVE_WIDTH = 16;\nconst WAVE_HEIGHT = 6;\n\n/** A single wave period as an SVG data URI; the stroke keeps its width when the tile is squashed. */\nfunction waveImage(color: string, thickness: number) {\n  const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${WAVE_WIDTH}\" height=\"${WAVE_HEIGHT}\" viewBox=\"0 0 16 6\" preserveAspectRatio=\"none\"><path d=\"M0 3 C4 0 4 0 8 3 S12 6 16 3\" fill=\"none\" stroke=\"${color}\" stroke-width=\"${thickness}\" vector-effect=\"non-scaling-stroke\" stroke-linecap=\"square\"/></svg>`;\n  return `url(\"data:image/svg+xml,${encodeURIComponent(svg)}\")`;\n}\n\nconst line = (c: string) => `linear-gradient(${c},${c})`;\n\n/**\n * Underlines are background images on the inline <a> itself, so they follow every wrapped line.\n * Slices (the default box-decoration-break) treat the wrapped fragments as one long strip, so the\n * line draws across the first line and continues on the next.\n */\nfunction underlineStyle(variant: AnimatedLinkVariant, on: boolean, color: string, t: number, resting: boolean): React.CSSProperties {\n  const restColor = \"color-mix(in oklab, currentColor 28%, transparent)\";\n  const rest = { image: line(restColor), size: `100% ${resting ? 1 : 0}px`, position: \"0 100%\" };\n  const drawn = on ? \"100%\" : \"0%\";\n  // Draw from the left, leave towards the right: the anchor flips sides while the size is 0.\n  const slideAnchor = on ? \"0%\" : \"100%\";\n\n  type Layer = { image: string; size: string; position: string; repeat?: string };\n  let layers: Layer[];\n  switch (variant) {\n    case \"grow\":\n      layers = [{ image: line(color), size: `${drawn} ${t}px`, position: \"50% 100%\" }];\n      break;\n    case \"double\":\n      layers = [\n        { image: line(color), size: `${drawn} ${t}px`, position: \"0% 100%\" },\n        { image: line(color), size: `${drawn} ${t}px`, position: `100% calc(100% - ${t + 2}px)` },\n      ];\n      break;\n    case \"highlight\":\n      layers = [\n        {\n          image: line(`color-mix(in oklab, ${color} 34%, transparent)`),\n          size: `${drawn} 100%`,\n          position: `${slideAnchor} 100%`,\n        },\n      ];\n      break;\n    case \"wave\":\n      // The wave grows out of a flat line: the tile height animates from 0 to its full amplitude.\n      layers = [{ image: waveImage(color, t), size: `${WAVE_WIDTH}px ${on ? WAVE_HEIGHT : 0}px`, position: \"0 100%\", repeat: \"repeat-x\" }];\n      break;\n    case \"slide\":\n    case \"arrow\":\n    default:\n      layers = [{ image: line(color), size: `${drawn} ${t}px`, position: `${slideAnchor} 100%` }];\n  }\n  // The resting line hides while the wave replaces it.\n  const all: Layer[] = [...layers, variant === \"wave\" && on ? { ...rest, size: \"100% 0px\" } : rest];\n  return {\n    backgroundImage: all.map((l) => l.image).join(\", \"),\n    backgroundSize: all.map((l) => l.size).join(\", \"),\n    backgroundPosition: all.map((l) => l.position).join(\", \"),\n    backgroundRepeat: all.map((l) => l.repeat ?? \"no-repeat\").join(\", \"),\n  };\n}\n\n/**\n * An inline link with refined hover / focus underline animations. Lines are painted as background\n * images on the <a>, so they work across line wraps, and a faint resting underline keeps links\n * recognisable at rest.\n */\nexport const AnimatedLink = React.forwardRef<HTMLAnchorElement, AnimatedLinkProps>(function AnimatedLink(\n  {\n    variant = \"slide\",\n    color = \"#FF7448\",\n    thickness = 2,\n    duration = 450,\n    offset = 2,\n    restingLine = true,\n    external = false,\n    externalLabel = \"(opens in new tab)\",\n    className = \"\",\n    style,\n    children,\n    target,\n    rel,\n    onPointerEnter,\n    onPointerLeave,\n    onFocus,\n    onBlur,\n    ...props\n  },\n  forwardedRef,\n) {\n  const reduceMotion = usePrefersReducedMotion();\n  const [hovered, setHovered] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  const on = hovered || focused;\n\n  const ref = React.useRef<HTMLAnchorElement>(null);\n  React.useImperativeHandle(forwardedRef, () => ref.current as HTMLAnchorElement, []);\n\n  // The wave flows sideways while engaged (Web Animations; it only runs during hover / focus).\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el || variant !== \"wave\" || !on || reduceMotion || typeof el.animate !== \"function\") return;\n    const restPosition = \"0 100%\";\n    const animation = el.animate(\n      [\n        { backgroundPosition: `0px 100%, ${restPosition}` },\n        { backgroundPosition: `${-WAVE_WIDTH}px 100%, ${restPosition}` },\n      ],\n      { duration: 700, iterations: Infinity, easing: \"linear\" },\n    );\n    return () => animation.cancel();\n  }, [variant, on, reduceMotion]);\n\n  const transition = reduceMotion\n    ? \"none\"\n    : `background-size ${duration}ms cubic-bezier(0.22, 1, 0.36, 1), color 200ms ease`;\n  const wavePad = variant === \"wave\" ? WAVE_HEIGHT - 2 : variant === \"double\" ? thickness + 2 : 0;\n\n  return (\n    <a\n      ref={ref}\n      target={external ? \"_blank\" : target}\n      rel={external ? (rel ? `${rel} noopener noreferrer` : \"noopener noreferrer\") : rel}\n      data-variant={variant}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        // Touch taps fire pointerenter without a hover; let focus drive those instead.\n        if (event.pointerType === \"mouse\") setHovered(true);\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        setHovered(false);\n      }}\n      onFocus={(event) => {\n        onFocus?.(event);\n        setFocused(event.currentTarget.matches(\":focus-visible\"));\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        setFocused(false);\n      }}\n      className={`rounded-[2px] text-inherit no-underline outline-hidden focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-solid focus-visible:outline-[var(--al-color)] ${className}`}\n      style={{\n        [\"--al-color\" as string]: color,\n        paddingBottom: offset + wavePad,\n        transition,\n        ...underlineStyle(variant, on, color, thickness, restingLine),\n        ...style,\n      }}\n      {...props}\n    >\n      {children}\n      {(variant === \"arrow\" || external) && (\n        // A text glyph (not an inline-block), so the word joiner keeps it on the last word's line.\n        // Relative offsets work on inline boxes, where transforms do not.\n        <span\n          aria-hidden=\"true\"\n          className=\"relative ml-[0.12em] font-normal\"\n          style={{\n            left: on ? (external ? 2 : 3) : 0,\n            top: on && external ? -2 : 0,\n            transition: reduceMotion ? \"none\" : \"left 300ms cubic-bezier(0.22, 1, 0.36, 1), top 300ms cubic-bezier(0.22, 1, 0.36, 1)\",\n          }}\n        >\n          {WORD_JOINER}\n          {external ? \"↗\" : \"→\"}\n        </span>\n      )}\n      {external && <span className=\"sr-only\"> {externalLabel}</span>}\n    </a>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/animated-link"
}
