{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"line-hover-link","type":"registry:ui","title":"LineHoverLink","description":"Link component with 12 animated underline hover effects — pure CSS, no JS on hover","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["navigation"],"registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/line-hover-link/LineHoverLink.tsx","content":"\"use client\";\n\nimport type { AnchorHTMLAttributes, MouseEvent, ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./line-hover-link.css\";\n\n/**\n * Line Hover Link Variants\n *\n * slide    - Line slides in from right to left\n * double   - Two lines animate with different timings\n * grow     - Line grows thicker on hover\n * strike   - Strikethrough effect with text scale\n * fade     - Lines fade up with stagger delay\n * pulse    - Line pulses up and down\n * swap     - Two lines go opposite directions\n * sweep    - Full background cover sweep\n * bounce   - Bouncy squish animation\n * arc      - SVG arc stroke draws in\n * scribble - SVG scribble stroke draws in\n * ink      - Constant underline; whole link snaps (-1px,-1px) on hover\n */\nexport type LineHoverVariant =\n\t| \"slide\"\n\t| \"double\"\n\t| \"grow\"\n\t| \"strike\"\n\t| \"fade\"\n\t| \"pulse\"\n\t| \"swap\"\n\t| \"sweep\"\n\t| \"bounce\"\n\t| \"arc\"\n\t| \"scribble\"\n\t| \"ink\";\n\nexport interface LineHoverLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"className\"> {\n\t/** The animation variant */\n\tvariant?: LineHoverVariant;\n\t/** Link href */\n\thref?: string;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\tchildren?: ReactNode;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off by\n\t * default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\nexport function LineHoverLink({\n\tvariant = \"slide\",\n\thref = \"#\",\n\ttarget,\n\trel,\n\tclassName = \"\",\n\tchildren,\n\tsound = false,\n\tonClick,\n\t...restProps\n}: LineHoverLinkProps) {\n\tconst needsSpan = [\"strike\", \"bounce\", \"arc\", \"scribble\"].includes(variant);\n\tconst relValue = target === \"_blank\" ? (rel ?? \"noopener noreferrer\") : rel;\n\tconst playCue = useSoundCue(sound);\n\n\tfunction handleClick(event: MouseEvent<HTMLAnchorElement>) {\n\t\tplayCue(\"press\");\n\t\tonClick?.(event);\n\t}\n\n\treturn (\n\t\t<a\n\t\t\t{...restProps}\n\t\t\thref={href}\n\t\t\ttarget={target}\n\t\t\trel={relValue}\n\t\t\tclassName={cn(\"link-hover\", `link-hover--${variant}`, className)}\n\t\t\tonClick={handleClick}\n\t\t>\n\t\t\t{needsSpan ? <span>{children}</span> : children}\n\n\t\t\t{variant === \"arc\" && (\n\t\t\t\t<svg\n\t\t\t\t\tclassName=\"link-hover__graphic link-hover__graphic--stroke link-hover__graphic--arc\"\n\t\t\t\t\twidth=\"100%\"\n\t\t\t\t\theight=\"18\"\n\t\t\t\t\tviewBox=\"0 0 59 18\"\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t>\n\t\t\t\t\t<path d=\"M.945.149C12.3 16.142 43.573 22.572 58.785 10.842\" pathLength={1} />\n\t\t\t\t</svg>\n\t\t\t)}\n\t\t\t{variant === \"scribble\" && (\n\t\t\t\t<svg\n\t\t\t\t\tclassName=\"link-hover__graphic link-hover__graphic--stroke link-hover__graphic--scribble\"\n\t\t\t\t\twidth=\"100%\"\n\t\t\t\t\theight=\"9\"\n\t\t\t\t\tviewBox=\"0 0 101 9\"\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t>\n\t\t\t\t\t<path\n\t\t\t\t\t\td=\"M.426 1.973C4.144 1.567 17.77-.514 21.443 1.48 24.296 3.026 24.844 4.627 27.5 7c3.075 2.748 6.642-4.141 10.066-4.688 7.517-1.2 13.237 5.425 17.59 2.745C58.5 3 60.464-1.786 66 2c1.996 1.365 3.174 3.737 5.286 4.41 5.423 1.727 25.34-7.981 29.14-1.294\"\n\t\t\t\t\t\tpathLength={1}\n\t\t\t\t\t/>\n\t\t\t\t</svg>\n\t\t\t)}\n\t\t</a>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/line-hover-link/LineHoverLink.tsx"},{"path":"react/src/components/line-hover-link/index.ts","content":"\"use client\";\n\nexport { LineHoverLink } from \"./LineHoverLink\";\nexport type { LineHoverLinkProps, LineHoverVariant } from \"./LineHoverLink\";\n","type":"registry:ui","target":"@ui/fancy/components/line-hover-link/index.ts"},{"path":"react/src/components/line-hover-link/line-hover-link.css","content":"/*\n * One rule governs this whole sheet: every `transition` and `animation`\n * declaration lives inside `@media (prefers-reduced-motion: no-preference)`,\n * and nothing else does. The resting and hover states themselves stay\n * outside, so a visitor who asked for less motion still gets the full\n * underline — it simply appears rather than travelling. Each variant keeps\n * its own guard block next to its own rules rather than pooling them at the\n * end of the file, so the shape is obvious to whoever adds variant thirteen.\n *\n * The eleven bespoke `cubic-bezier` curves and the `0.2s`/`0.3s`/`0.4s`/\n * `0.6s` durations below are deliberately NOT retokenised: they are this\n * component's designed choreography, and flattening them onto the four\n * house curves would make eleven distinct variants read as one.\n */\n.link-hover {\n\tcursor: pointer;\n\tposition: relative;\n\twhite-space: nowrap;\n\tcolor: currentColor;\n\ttext-decoration: none;\n}\n\n.link-hover::before,\n.link-hover::after {\n\tposition: absolute;\n\twidth: 100%;\n\theight: 1px;\n\tbackground: currentColor;\n\ttop: 100%;\n\tleft: 0;\n\tpointer-events: none;\n}\n\n.link-hover::before {\n\tcontent: \"\";\n}\n\n/* Slide */\n.link-hover--slide::before {\n\ttransform-origin: 100% 50%;\n\ttransform: scale3d(0, 1, 1);\n}\n\n.link-hover--slide:is(:hover, :focus-visible)::before {\n\ttransform-origin: 0% 50%;\n\ttransform: scale3d(1, 1, 1);\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover--slide::before {\n\t\ttransition: transform 0.3s;\n\t}\n}\n\n/* Double */\n.link-hover--double::before {\n\ttransform-origin: 100% 50%;\n\ttransform: scale3d(0, 1, 1);\n}\n\n.link-hover--double:is(:hover, :focus-visible)::before {\n\ttransform-origin: 0% 50%;\n\ttransform: scale3d(1, 1, 1);\n}\n\n.link-hover--double::after {\n\tcontent: \"\";\n\ttop: calc(100% + 4px);\n\ttransform-origin: 0% 50%;\n\ttransform: scale3d(0, 1, 1);\n}\n\n.link-hover--double:is(:hover, :focus-visible)::after {\n\ttransform-origin: 100% 50%;\n\ttransform: scale3d(1, 1, 1);\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover--double::before,\n\t.link-hover--double::after {\n\t\ttransition: transform 0.3s cubic-bezier(0.7, 0, 0.2, 1);\n\t}\n\n\t.link-hover--double:is(:hover, :focus-visible)::before,\n\t.link-hover--double:is(:hover, :focus-visible)::after {\n\t\ttransition-timing-function: cubic-bezier(0.4, 1, 0.8, 1);\n\t}\n}\n\n/* Grow */\n.link-hover--grow::before {\n\ttransform-origin: 100% 50%;\n\ttransform: scale3d(0, 1, 1);\n}\n\n.link-hover--grow:is(:hover, :focus-visible)::before {\n\ttransform-origin: 0% 50%;\n\ttransform: scale3d(1, 2, 1);\n}\n\n.link-hover--grow::after {\n\tcontent: \"\";\n\ttop: calc(100% + 4px);\n\ttransform-origin: 100% 50%;\n\ttransform: scale3d(0, 1, 1);\n}\n\n.link-hover--grow:is(:hover, :focus-visible)::after {\n\ttransform-origin: 0% 50%;\n\ttransform: scale3d(1, 1, 1);\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover--grow::before {\n\t\ttransition: transform 0.3s cubic-bezier(0.2, 1, 0.8, 1);\n\t}\n\n\t.link-hover--grow::after {\n\t\ttransition: transform 0.4s 0.1s cubic-bezier(0.2, 1, 0.8, 1);\n\t}\n\n\t.link-hover--grow:is(:hover, :focus-visible)::before,\n\t.link-hover--grow:is(:hover, :focus-visible)::after {\n\t\ttransition-timing-function: cubic-bezier(0.7, 0, 0.2, 1);\n\t}\n}\n\n/* Strike */\n.link-hover--strike {\n\tpadding: 0 10px;\n}\n\n.link-hover--strike::before {\n\ttop: 50%;\n\theight: 2px;\n\ttransform-origin: 100% 50%;\n\ttransform: scale3d(0, 1, 1);\n}\n\n.link-hover--strike:is(:hover, :focus-visible)::before {\n\ttransform-origin: 0% 50%;\n\ttransform: scale3d(1, 1, 1);\n}\n\n.link-hover--strike span {\n\tdisplay: inline-block;\n}\n\n.link-hover--strike:is(:hover, :focus-visible) span {\n\ttransform: scale3d(1.1, 1.1, 1.1);\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover--strike::before,\n\t.link-hover--strike span {\n\t\ttransition: transform 0.3s cubic-bezier(0.4, 1, 0.8, 1);\n\t}\n}\n\n/* Fade */\n.link-hover--fade::before,\n.link-hover--fade::after {\n\topacity: 0;\n\ttransform-origin: 50% 0%;\n\ttransform: translate3d(0, 3px, 0);\n}\n\n.link-hover--fade:is(:hover, :focus-visible)::before,\n.link-hover--fade:is(:hover, :focus-visible)::after {\n\topacity: 1;\n\ttransform: translate3d(0, 0, 0);\n}\n\n.link-hover--fade::after {\n\tcontent: \"\";\n\ttop: calc(100% + 4px);\n\twidth: 70%;\n\tleft: 15%;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover--fade::before,\n\t.link-hover--fade::after {\n\t\ttransition-property: transform, opacity;\n\t\ttransition-duration: 0.3s;\n\t\ttransition-timing-function: cubic-bezier(0.2, 1, 0.8, 1);\n\t}\n\n\t.link-hover--fade:is(:hover, :focus-visible)::before,\n\t.link-hover--fade:is(:hover, :focus-visible)::after {\n\t\ttransition-timing-function: cubic-bezier(0.2, 0, 0.3, 1);\n\t}\n\n\t/* The stagger: the two lines swap which of them leads, so the pair reads\n\t   as a gesture rather than as one line drawn twice. */\n\t.link-hover--fade::before,\n\t.link-hover--fade:is(:hover, :focus-visible)::after {\n\t\ttransition-delay: 0.1s;\n\t}\n\n\t.link-hover--fade:is(:hover, :focus-visible)::before {\n\t\ttransition-delay: 0s;\n\t}\n}\n\n/* Pulse */\n.link-hover--pulse::before {\n\theight: 10px;\n\ttop: 100%;\n\topacity: 0;\n}\n\n/*\n * `opacity: 1` stays outside the guard and the `animation` goes inside, on\n * purpose: this variant reaches its visible state THROUGH the keyframes'\n * `forwards` fill. The base rule is a full-width, full-opacity 10px bar\n * with no transform of its own, so dropping the animation leaves a\n * correctly-sized bar that simply appears. Nothing goes invisible.\n */\n.link-hover--pulse:is(:hover, :focus-visible)::before {\n\topacity: 1;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover--pulse:is(:hover, :focus-visible)::before {\n\t\tanimation: lineUp 0.3s ease forwards;\n\t}\n}\n\n@keyframes lineUp {\n\t0% {\n\t\ttransform-origin: 50% 100%;\n\t\ttransform: scale3d(1, 0.045, 1);\n\t}\n\t50% {\n\t\ttransform-origin: 50% 100%;\n\t\ttransform: scale3d(1, 1, 1);\n\t}\n\t51% {\n\t\ttransform-origin: 50% 0%;\n\t\ttransform: scale3d(1, 1, 1);\n\t}\n\t100% {\n\t\ttransform-origin: 50% 0%;\n\t\ttransform: scale3d(1, 0.045, 1);\n\t}\n}\n\n.link-hover--pulse::after {\n\tcontent: \"\";\n\topacity: 0;\n}\n\n.link-hover--pulse:is(:hover, :focus-visible)::after {\n\topacity: 1;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover--pulse::after {\n\t\ttransition: opacity 0.3s;\n\t\ttransition-delay: 0s;\n\t}\n\n\t.link-hover--pulse:is(:hover, :focus-visible)::after {\n\t\ttransition-delay: 0.3s;\n\t}\n}\n\n/* Swap */\n.link-hover--swap::before {\n\ttransform-origin: 0% 50%;\n\ttransform: scale3d(0, 1, 1);\n}\n\n.link-hover--swap:is(:hover, :focus-visible)::before {\n\ttransform: scale3d(1, 1, 1);\n}\n\n.link-hover--swap::after {\n\tcontent: \"\";\n\ttop: calc(100% + 4px);\n\ttransform-origin: 100% 50%;\n}\n\n.link-hover--swap:is(:hover, :focus-visible)::after {\n\ttransform: scale3d(0, 1, 1);\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover--swap::before,\n\t.link-hover--swap::after {\n\t\ttransition: transform 0.3s;\n\t}\n}\n\n/* Sweep */\n.link-hover--sweep::before {\n\theight: 100%;\n\ttop: 0;\n\topacity: 0;\n}\n\n/*\n * Same shape as `--pulse` above, and for the same reason: the visible state\n * is reached through the keyframes' `forwards` fill, so `opacity: 1` has to\n * stay outside the guard. The base rule is a full-height, full-width cover\n * with no transform, so without the animation it simply appears.\n */\n.link-hover--sweep:is(:hover, :focus-visible)::before {\n\topacity: 1;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover--sweep:is(:hover, :focus-visible)::before {\n\t\tanimation: coverUp 0.3s ease forwards;\n\t}\n}\n\n@keyframes coverUp {\n\t0% {\n\t\ttransform-origin: 50% 100%;\n\t\ttransform: scale3d(1, 0.045, 1);\n\t}\n\t50% {\n\t\ttransform-origin: 50% 100%;\n\t\ttransform: scale3d(1, 1, 1);\n\t}\n\t51% {\n\t\ttransform-origin: 50% 0%;\n\t\ttransform: scale3d(1, 1, 1);\n\t}\n\t100% {\n\t\ttransform-origin: 50% 0%;\n\t\ttransform: scale3d(1, 0.045, 1);\n\t}\n}\n\n.link-hover--sweep::after {\n\tcontent: \"\";\n}\n\n.link-hover--sweep:is(:hover, :focus-visible)::after {\n\topacity: 0;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover--sweep::after {\n\t\ttransition: opacity 0.3s;\n\t}\n}\n\n/* Bounce */\n.link-hover--bounce::before {\n\theight: 7px;\n\tborder-radius: 20px;\n\ttransform: scale3d(1, 1, 1);\n}\n\n.link-hover--bounce:is(:hover, :focus-visible)::before {\n\topacity: 1;\n\ttransform: scale3d(1.2, 0.1, 1);\n}\n\n.link-hover--bounce span {\n\ttransform: translate3d(0, -4px, 0);\n\tdisplay: inline-block;\n}\n\n.link-hover--bounce:is(:hover, :focus-visible) span {\n\ttransform: translate3d(0, 0, 0);\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover--bounce::before {\n\t\ttransition:\n\t\t\ttransform 0.2s,\n\t\t\topacity 0.2s;\n\t\ttransition-timing-function: cubic-bezier(0.2, 0.57, 0.67, 1.53);\n\t}\n\n\t.link-hover--bounce span {\n\t\ttransition: transform 0.2s 0.05s cubic-bezier(0.2, 0.57, 0.67, 1.53);\n\t}\n\n\t.link-hover--bounce:is(:hover, :focus-visible)::before,\n\t.link-hover--bounce:is(:hover, :focus-visible) span {\n\t\ttransition-timing-function: cubic-bezier(0.8, 0, 0.1, 1);\n\t\ttransition-duration: 0.4s;\n\t}\n\n\t.link-hover--bounce:is(:hover, :focus-visible) span {\n\t\ttransition-delay: 0s;\n\t}\n}\n\n/* SVG Graphics Base */\n.link-hover__graphic {\n\tposition: absolute;\n\ttop: 0;\n\tleft: 0;\n\tpointer-events: none;\n\tfill: none;\n\tstroke: currentColor;\n\tstroke-width: 1px;\n}\n\n.link-hover__graphic--stroke path {\n\tstroke-dasharray: 1;\n\tstroke-dashoffset: 1;\n}\n\n.link-hover:is(:hover, :focus-visible) .link-hover__graphic--stroke path {\n\tstroke-dashoffset: 0;\n}\n\n/* Arc */\n.link-hover--arc::before {\n\tdisplay: none;\n}\n\n.link-hover__graphic--arc {\n\ttop: 73%;\n\tleft: -23%;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover__graphic--arc path {\n\t\ttransition: stroke-dashoffset 0.4s cubic-bezier(0.7, 0, 0.3, 1);\n\t}\n\n\t.link-hover:is(:hover, :focus-visible) .link-hover__graphic--arc path {\n\t\ttransition-timing-function: cubic-bezier(0.8, 1, 0.7, 1);\n\t\ttransition-duration: 0.3s;\n\t}\n}\n\n/* Scribble */\n.link-hover--scribble::before {\n\tdisplay: none;\n}\n\n.link-hover__graphic--scribble {\n\ttop: 100%;\n}\n\n/* The stroke-draw pair (`--arc` and `--scribble`) animates\n   `stroke-dashoffset`, which is neither opacity nor transform — a named,\n   long-standing exception for SVG line drawing. Under reduced motion the\n   guard drops the transition and the resting `stroke-dashoffset: 1` snaps\n   straight to `0`, so the stroke is fully drawn, just not drawn *for* you. */\n@media (prefers-reduced-motion: no-preference) {\n\t.link-hover__graphic--scribble path {\n\t\ttransition: stroke-dashoffset 0.6s cubic-bezier(0.7, 0, 0.3, 1);\n\t}\n\n\t.link-hover:is(:hover, :focus-visible) .link-hover__graphic--scribble path {\n\t\ttransition-timing-function: cubic-bezier(0.8, 1, 0.7, 1);\n\t\ttransition-duration: 0.3s;\n\t}\n}\n\n/* Ink */\n.link-hover--ink::before {\n\theight: 2px;\n}\n\n.link-hover--ink:focus-visible {\n\ttransform: translate3d(-1px, -1px, 0);\n}\n\n@media (hover: hover) {\n\t.link-hover--ink:hover {\n\t\ttransform: translate3d(-1px, -1px, 0);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/line-hover-link/line-hover-link.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/line-hover-link?framework=react"}}