{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-link",
  "type": "registry:ui",
  "title": "Animated Link",
  "description": "Inline link whose underline draws in, retracts ahead and rewinds when interrupted.",
  "categories": [
    "navigation"
  ],
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json"
  ],
  "files": [
    {
      "path": "components/ui/animated-link.tsx",
      "content": "\"use client\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowRight, ArrowUpRight } from \"@/lib/icons\";\n\ntype Tone = \"default\" | \"muted\" | \"plain\";\n\nconst isExternalHref = (href?: string) => !!href && /^(https?:)?\\/\\//.test(href);\n\n/**\n * When the pointer leaves before the line has finished drawing, it rolls back\n * the way it came instead of jumping to the far end and retracting from there.\n */\nfunction onLeave(e: React.PointerEvent<HTMLElement>) {\n  const line = e.currentTarget.querySelector<HTMLElement>(\"[data-underline]\");\n  if (!line) return;\n  const size = getComputedStyle(line).backgroundSize.split(\",\")[0]?.trim().split(\" \")[0] ?? \"0%\";\n  const drawn = size.endsWith(\"%\") ? parseFloat(size) / 100 : parseFloat(size) / Math.max(line.offsetWidth, 1);\n  e.currentTarget.toggleAttribute(\"data-rewind\", drawn < 0.92);\n}\n\nexport type AnimatedLinkProps = useRender.ComponentProps<\"a\"> & {\n  /** default: primary text with a faint resting underline. muted: secondary text that brightens. plain: no resting underline, for footers and lists where context says “link”. */\n  tone?: Tone;\n  /** Opens in a new tab with an arrow. Defaults to true for absolute URLs. */\n  external?: boolean;\n  /** \"arrow\" adds a forward arrow that nudges on hover, for “View all” links. External links get the up-right arrow on their own. */\n  icon?: \"arrow\" | \"none\";\n  /** Dim links the person has already visited. Off by default: in apps it is usually noise. */\n  markVisited?: boolean;\n};\n\nexport function AnimatedLink({\n  tone = \"default\",\n  external: externalProp,\n  icon = \"none\",\n  markVisited = false,\n  render,\n  className,\n  children,\n  ...rest\n}: AnimatedLinkProps) {\n  const external = externalProp ?? isExternalHref(rest.href);\n  const Arrow = external ? ArrowUpRight : icon === \"arrow\" ? ArrowRight : null;\n\n  const own: useRender.ElementProps<\"a\"> & { \"data-tone\": Tone } = {\n    \"data-tone\": tone,\n    target: external ? \"_blank\" : undefined,\n    rel: external ? \"noopener noreferrer\" : undefined,\n    onPointerEnter: (e: React.PointerEvent<HTMLElement>) => e.currentTarget.removeAttribute(\"data-rewind\"),\n    onPointerLeave: onLeave,\n    className: cn(\n      \"group/link cursor-pointer rounded-[4px] outline-none [-webkit-tap-highlight-color:transparent]\",\n      \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n      // In running text the ring sits 1px out so it never touches the neighboring words; standalone links get the usual 2px.\n      tone === \"default\" ? \"focus-visible:outline-offset-1\" : \"focus-visible:outline-offset-2\",\n      \"transition-colors duration-150\",\n      // The drawn line reads these; hover and keyboard focus draw it from the left.\n      \"[--u:0%] [--u-at:right] hover:[--u:100%] hover:[--u-at:left] focus-visible:[--u:100%] focus-visible:[--u-at:left] data-rewind:[--u-at:left]\",\n      tone === \"default\" && \"text-fg [--u-rest:var(--fg-4)] active:text-fg-2\",\n      tone === \"muted\" && \"text-fg-2 [--u-rest:var(--line-2)] hover:text-fg active:text-fg-3\",\n      tone === \"plain\" && \"text-fg-2 [--u-rest:transparent] hover:text-fg active:text-fg-3\",\n      markVisited && \"visited:text-fg-3\",\n      className,\n    ),\n    children: (\n      <>\n        <span\n          data-underline=\"\"\n          className={cn(\n            // Two lines in the background: a resting hairline, and the one that draws over it.\n            // Backgrounds follow the text across line breaks, so a wrapped link draws line by line.\n            \"bg-no-repeat pb-[0.12em]\",\n            \"[background-image:linear-gradient(currentColor,currentColor),linear-gradient(var(--u-rest),var(--u-rest))]\",\n            \"[background-size:var(--u)_1px,100%_1px] [background-position:var(--u-at)_100%,0_100%]\",\n            \"transition-[background-size] duration-200 ease-out-quart group-hover/link:duration-[280ms] group-hover/link:ease-out-expo\",\n          )}\n        >\n          {children}\n        </span>\n        {Arrow && (\n          <>\n            {/* A word joiner keeps the arrow on the same line as the last word. */}\n            {\"\\u2060\"}\n            <Arrow\n              size={12}\n              className={cn(\n                \"ml-0.5 inline-block align-[-0.05em] opacity-70 transition-[translate,opacity] duration-200 ease-out-quart group-hover/link:opacity-100 group-focus-visible/link:opacity-100\",\n                external ? \"group-hover/link:translate-x-px group-hover/link:-translate-y-px\" : \"group-hover/link:translate-x-0.5\",\n              )}\n            />\n          </>\n        )}\n        {external && <span className=\"sr-only\"> (opens in a new tab)</span>}\n      </>\n    ),\n  };\n\n  return useRender({ defaultTagName: \"a\", render, props: mergeProps<\"a\">(own, rest) });\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/animated-link"
}
