docsLine Hover Link

Line Hover Link

Subtle and elegant underline hover animations for links. Easy-to-remember variant names!

Loading Preview...

Features

  • 11 Animation Variants: Easy-to-remember names like slide, double, grow, bounce
  • Pure CSS Animations: Maximum performance, no JavaScript overhead
  • Dark Mode Ready: Uses currentColor for automatic theming
  • Accessible: Standard anchor element with proper semantics

Installation

npx shadcn@latest add "https://www.vengenceui.com/r/line-hover-link.json"

Install Manually

1

Install dependencies

npm install framer-motion clsx tailwind-merge
2

Add util file

lib/utils.ts

import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
3

Add styles to global CSS

Add the following styles to your app/globals.css file

.link-hover {
cursor: pointer;
position: relative;
white-space: nowrap;
color: currentColor;
text-decoration: none;
}
.link-hover::before,
.link-hover::after {
position: absolute;
width: 100%;
height: 1px;
background: currentColor;
top: 100%;
left: 0;
pointer-events: none;
}
.link-hover::before {
content: "";
}
/* Slide - Simple slide from right to left */
.link-hover--slide::before {
transform-origin: 100% 50%;
transform: scale3d(0, 1, 1);
transition: transform 0.3s;
}
.link-hover--slide:hover::before {
transform-origin: 0% 50%;
transform: scale3d(1, 1, 1);
}
/* Double - Double line slide with different timings */
.link-hover--double::before {
transform-origin: 100% 50%;
transform: scale3d(0, 1, 1);
transition: transform 0.3s cubic-bezier(0.7, 0, 0.2, 1);
}
.link-hover--double:hover::before {
transform-origin: 0% 50%;
transform: scale3d(1, 1, 1);
transition-timing-function: cubic-bezier(0.4, 1, 0.8, 1);
}
.link-hover--double::after {
content: "";
top: calc(100% + 4px);
transform-origin: 0% 50%;
transform: scale3d(0, 1, 1);
transition: transform 0.3s cubic-bezier(0.7, 0, 0.2, 1);
}
.link-hover--double:hover::after {
transform-origin: 100% 50%;
transform: scale3d(1, 1, 1);
transition-timing-function: cubic-bezier(0.4, 1, 0.8, 1);
}
/* Grow - Line with thickness change + second line */
.link-hover--grow::before {
transform-origin: 100% 50%;
transform: scale3d(0, 1, 1);
transition: transform 0.3s cubic-bezier(0.2, 1, 0.8, 1);
}
.link-hover--grow:hover::before {
transform-origin: 0% 50%;
transform: scale3d(1, 2, 1);
transition-timing-function: cubic-bezier(0.7, 0, 0.2, 1);
}
.link-hover--grow::after {
content: "";
top: calc(100% + 4px);
transform-origin: 100% 50%;
transform: scale3d(0, 1, 1);
transition: transform 0.4s 0.1s cubic-bezier(0.2, 1, 0.8, 1);
}
.link-hover--grow:hover::after {
transform-origin: 0% 50%;
transform: scale3d(1, 1, 1);
transition-timing-function: cubic-bezier(0.7, 0, 0.2, 1);
}
/* Strike - Strikethrough with text scale */
.link-hover--strike {
padding: 0 10px;
}
.link-hover--strike::before {
top: 50%;
height: 2px;
transform-origin: 100% 50%;
transform: scale3d(0, 1, 1);
transition: transform 0.3s cubic-bezier(0.4, 1, 0.8, 1);
}
.link-hover--strike:hover::before {
transform-origin: 0% 50%;
transform: scale3d(1, 1, 1);
}
.link-hover--strike span {
display: inline-block;
transition: transform 0.3s cubic-bezier(0.4, 1, 0.8, 1);
}
.link-hover--strike:hover span {
transform: scale3d(1.1, 1.1, 1.1);
}
/* Fade - Double lines with staggered fade-up */
.link-hover--fade::before,
.link-hover--fade::after {
opacity: 0;
transform-origin: 50% 0%;
transform: translate3d(0, 3px, 0);
transition-property: transform, opacity;
transition-duration: 0.3s;
transition-timing-function: cubic-bezier(0.2, 1, 0.8, 1);
}
.link-hover--fade:hover::before,
.link-hover--fade:hover::after {
opacity: 1;
transform: translate3d(0, 0, 0);
transition-timing-function: cubic-bezier(0.2, 0, 0.3, 1);
}
.link-hover--fade::after {
content: "";
top: calc(100% + 4px);
width: 70%;
left: 15%;
}
.link-hover--fade::before,
.link-hover--fade:hover::after {
transition-delay: 0.1s;
}
.link-hover--fade:hover::before {
transition-delay: 0s;
}
/* Pulse - Animated line that expands and contracts */
.link-hover--pulse::before {
height: 10px;
top: 100%;
opacity: 0;
}
.link-hover--pulse:hover::before {
opacity: 1;
animation: lineUp 0.3s ease forwards;
}
@keyframes lineUp {
0% {
transform-origin: 50% 100%;
transform: scale3d(1, 0.045, 1);
}
50% {
transform-origin: 50% 100%;
transform: scale3d(1, 1, 1);
}
51% {
transform-origin: 50% 0%;
transform: scale3d(1, 1, 1);
}
100% {
transform-origin: 50% 0%;
transform: scale3d(1, 0.045, 1);
}
}
.link-hover--pulse::after {
content: "";
transition: opacity 0.3s;
opacity: 0;
transition-delay: 0s;
}
.link-hover--pulse:hover::after {
opacity: 1;
transition-delay: 0.3s;
}
/* Swap - Two lines going opposite directions */
.link-hover--swap::before {
transform-origin: 0% 50%;
transform: scale3d(0, 1, 1);
transition: transform 0.3s;
}
.link-hover--swap:hover::before {
transform: scale3d(1, 1, 1);
}
.link-hover--swap::after {
content: "";
top: calc(100% + 4px);
transition: transform 0.3s;
transform-origin: 100% 50%;
}
.link-hover--swap:hover::after {
transform: scale3d(0, 1, 1);
}
/* Sweep - Cover sweep animation */
.link-hover--sweep::before {
height: 100%;
top: 0;
opacity: 0;
}
.link-hover--sweep:hover::before {
opacity: 1;
animation: coverUp 0.3s ease forwards;
}
@keyframes coverUp {
0% {
transform-origin: 50% 100%;
transform: scale3d(1, 0.045, 1);
}
50% {
transform-origin: 50% 100%;
transform: scale3d(1, 1, 1);
}
51% {
transform-origin: 50% 0%;
transform: scale3d(1, 1, 1);
}
100% {
transform-origin: 50% 0%;
transform: scale3d(1, 0.045, 1);
}
}
.link-hover--sweep::after {
content: "";
transition: opacity 0.3s;
}
.link-hover--sweep:hover::after {
opacity: 0;
}
/* Bounce - Bouncy squish effect */
.link-hover--bounce::before {
height: 7px;
border-radius: 20px;
transform: scale3d(1, 1, 1);
transition: transform 0.2s, opacity 0.2s;
transition-timing-function: cubic-bezier(0.2, 0.57, 0.67, 1.53);
}
.link-hover--bounce:hover::before {
transition-timing-function: cubic-bezier(0.8, 0, 0.1, 1);
transition-duration: 0.4s;
opacity: 1;
transform: scale3d(1.2, 0.1, 1);
}
.link-hover--bounce span {
transform: translate3d(0, -4px, 0);
display: inline-block;
transition: transform 0.2s 0.05s cubic-bezier(0.2, 0.57, 0.67, 1.53);
}
.link-hover--bounce:hover span {
transform: translate3d(0, 0, 0);
transition-timing-function: cubic-bezier(0.8, 0, 0.1, 1);
transition-duration: 0.4s;
transition-delay: 0s;
}
/* SVG Graphics Base */
.link-hover__graphic {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
fill: none;
stroke: currentColor;
stroke-width: 1px;
}
.link-hover__graphic--stroke path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
.link-hover:hover .link-hover__graphic--stroke path {
stroke-dashoffset: 0;
}
/* Arc - SVG arc stroke draw */
.link-hover--arc::before {
display: none;
}
.link-hover__graphic--arc {
top: 73%;
left: -23%;
}
.link-hover__graphic--arc path {
transition: stroke-dashoffset 0.4s cubic-bezier(0.7, 0, 0.3, 1);
}
.link-hover:hover .link-hover__graphic--arc path {
transition-timing-function: cubic-bezier(0.8, 1, 0.7, 1);
transition-duration: 0.3s;
}
/* Scribble - SVG scribble stroke draw */
.link-hover--scribble::before {
display: none;
}
.link-hover__graphic--scribble {
top: 100%;
}
.link-hover__graphic--scribble path {
transition: stroke-dashoffset 0.6s cubic-bezier(0.7, 0, 0.3, 1);
}
.link-hover:hover .link-hover__graphic--scribble path {
transition-timing-function: cubic-bezier(0.8, 1, 0.7, 1);
transition-duration: 0.3s;
}
4

Copy the source code

Copy the code below and paste it into components/ui/glow-border-card.tsx

"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export type LineHoverVariant =
| "slide"
| "double"
| "grow"
| "strike"
| "fade"
| "pulse"
| "swap"
| "sweep"
| "bounce"
| "arc"
| "scribble";
export interface LineHoverLinkProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
variant?: LineHoverVariant;
children: React.ReactNode;
className?: string;
}
const ArcGraphic = () => (
<svg
className="link-hover__graphic link-hover__graphic--stroke link-hover__graphic--arc"
width="100%"
height="18"
viewBox="0 0 59 18"
>
<path
d="M.945.149C12.3 16.142 43.573 22.572 58.785 10.842"
pathLength="1"
/>
</svg>
);
const ScribbleGraphic = () => (
<svg
className="link-hover__graphic link-hover__graphic--stroke link-hover__graphic--scribble"
width="100%"
height="9"
viewBox="0 0 101 9"
>
<path
d="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"
pathLength="1"
/>
</svg>
);
export const LineHoverLink = React.forwardRef<
HTMLAnchorElement,
LineHoverLinkProps
>(({ variant = "slide", children, className, ...props }, ref) => {
// Variants that need span wrapper for text animation
const needsSpan = ["strike", "bounce", "arc", "scribble"].includes(variant);
// Variants that need SVG graphics
const svgVariant =
variant === "arc" ? "arc" : variant === "scribble" ? "scribble" : null;
return (
<a
ref={ref}
className={cn("link-hover", `link-hover--${variant}`, className)}
{...props}
>
{needsSpan ? <span>{children}</span> : children}
{svgVariant === "arc" && <ArcGraphic />}
{svgVariant === "scribble" && <ScribbleGraphic />}
</a>
);
});
LineHoverLink.displayName = "LineHoverLink";
export default LineHoverLink;

Usage

Basic Usage

Loading Preview...
Loading Preview...

SVG-Based Effects

Loading Preview...

Props

Prop NameTypeDefaultDescription
variantLineHoverVariant'slide'The animation variant. See Variant Guide below.
childrenReact.ReactNode-The link content.
hrefstring-The URL the link points to.
classNamestring-Additional CSS classes to apply.

Variant Guide

VariantEffectBest For
slideLine slides in from rightNavigation links
doubleTwo lines animate togetherBold statements
growLine grows thicker on hoverEmphasis
strikeStrikethrough + text scalesCall to action
fadeLines fade up with staggerElegant effect
pulseLine pulses up and downDynamic feel
swapLines go opposite directionsUnique style
sweepFull background sweepStrong emphasis
bounceBouncy squish animationPlayful effect
arcSVG arc stroke draws inSign-up CTAs
scribbleSVG scribble draws inHandwritten feel