9 CSS Link Hover Underline — One Line, Copy-Paste
A CSS link hover underline is the short line that grows under a link only on hover or keyboard focus, showing at a glance which link is about to open.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Left-to-right underline
- 02 Center-out underline
- 03 Multi-line underline
- 04 Thickening underline
- 05 Color-slide underline
- 06 Wavy underline
- 07 Hover strikethrough
- 08 Hover highlight marker
- 09 Dotted-to-solid morph
The order below follows how a reader actually meets a link, not how flashy each one looks. It opens with the plainest underline (01) and its centered cousin (02), then a fix for links that wrap onto two lines (03), a bolder version that leans on native CSS instead of a drawn bar (04), and a brand-colored one that keeps moving while the cursor stays put (05). After a playful wavy underline (06) comes a strikethrough that only appears on hover, hinting at deletion or completion (07), a highlighter block that fills in behind the text (08), and finally, a dotted line that swaps for a solid one, as if a link just got confirmed (09). Every one of the nine responds to both :hover and :focus-visible, so a visitor tabbing through links with a keyboard sees the same motion as someone pointing a mouse.
01Left-to-right underline
Hover the link and a thin bar grows from its left edge across to the right; move away and it shrinks back in the same direction it came from, so entering and leaving always look symmetric.
.link {
position: relative;
&::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: 0;
height: 3px;
background: currentColor;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms $ease-spring;
}
&:hover::after, &:focus-visible::after {
transform: scaleX(1);
}
}
02Center-out underline
The same bar as item 01 keeps every line of code except one: transform-origin moves from left to center, so the underline now grows outward from the middle in both directions instead of from one edge.
.link::after {
transform-origin: center;
transition: transform 300ms $ease-pop;
}
.link:hover::after,
.link:focus-visible::after {
transform: scaleX(1);
}
03Multi-line underline
A nav label long enough to wrap onto two lines usually gets a pseudo-element underline only under its last line. Drawing the underline as a background gradient instead, and cloning that background per fragment, gives each wrapped line its own underline that grows independently.
.wrap { width: 160px; text-align: center; }
.link {
background-image: linear-gradient(currentColor, currentColor);
background-repeat: no-repeat;
background-position: 0 100%;
background-size: 0% 2px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
transition: background-size 300ms $ease-out;
}
.link:hover, .link:focus-visible {
background-size: 100% 2px;
}
Chrome, Edge, and Safari still need the -webkit- prefix for box-decoration-break; only Firefox reads the unprefixed line, so the MDN box-decoration-break entry is worth checking before dropping either line.
04Thickening underline
The native text-decoration-line: underline stays exactly as it is — only text-decoration-thickness (1px to 4px) and text-underline-offset (3px to 5px) animate, so the underline visibly thickens without nudging any surrounding text.
.link {
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
transition: text-decoration-thickness 300ms $ease-out,
text-underline-offset 300ms $ease-out;
}
.link:hover, .link:focus-visible {
text-decoration-thickness: 4px;
text-underline-offset: 5px;
}
05Color-slide underline
A gradient bar twice as wide as the underline itself sits under the text; hovering slides that gradient sideways over 1.4 seconds on a loop, so color appears to travel across the line, and the moment the cursor leaves, the slide stops and the bar retracts.
.link::after {
background: linear-gradient(90deg, $color, $stage-yellow, $color);
background-size: 200% 100%;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms $ease-out;
}
.link:hover::after, .link:focus-visible::after {
transform: scaleX(1);
animation: color-flow 1.4s linear infinite;
}
@keyframes color-flow {
0% { background-position: 0% 0; }
100% { background-position: 100% 0; }
}
06Wavy underline
Instead of the native text-decoration-style: wavy, two layered radial gradients are tiled sideways to draw the bumps by hand; hovering fades the wave in and, at the same time, slides it left on a loop so the bumps keep traveling rather than sitting still.
.link::after {
background-image:
radial-gradient(circle at 10px -5px, transparent 12px, currentColor 13px, currentColor 14px, transparent 15px),
radial-gradient(circle at 30px 5px, transparent 12px, currentColor 13px, currentColor 14px, transparent 15px);
background-repeat: repeat-x;
background-size: 40px 10px;
opacity: 0;
transition: opacity 220ms $ease-out;
}
.link:hover::after, .link:focus-visible::after {
opacity: 1;
animation: wave-flow 1.2s linear infinite;
}
@keyframes wave-flow {
0% { background-position: 0 0, 0 0; }
100% { background-position: -40px 0, -40px 0; }
}
07Hover strikethrough
A line sits at the text's mid-height and only draws itself, left to right, while the cursor is over the link — because it never fires on its own, it reads as an action the visitor caused, fitting for a "remove" or "mark as done" link.
.link::after {
content: "";
position: absolute;
left: 0; right: 0; top: 50%;
height: 2px;
background: currentColor;
transform: translateY(-50%) scaleX(0);
transform-origin: left center;
transition: transform 140ms $ease-pop;
}
.link:hover::after, .link:focus-visible::after {
transform: translateY(-50%) scaleX(1);
}
08Hover highlight marker
A tilted, rounded block sits behind the text and blends into it with mix-blend-mode: multiply; hovering grows that block left to right so it reads like a highlighter stroke someone just made, translucent where it overlaps the letters underneath.
.link {
position: relative;
isolation: isolate;
&::before {
content: "";
position: absolute;
inset: 10% -4% 6%;
background: $stage-yellow;
border-radius: $r-xs;
transform: skewX(-6deg) scaleX(0);
transform-origin: left center;
mix-blend-mode: multiply;
z-index: -1;
transition: transform 300ms $ease-pop;
}
&:hover::before, &:focus-visible::before {
transform: skewX(-6deg) scaleX(1);
}
}
isolation: isolate on .link matters here — without it, the marker's z-index: -1 layer can end up painted behind the page background instead of behind just the text, and disappear entirely.
09Dotted-to-solid morph
text-decoration-style can't be animated because it only takes fixed keyword values, so a dotted bar and a solid bar sit stacked in the same spot and cross-fade their opacity on hover — the dotted line appears to firm up into a solid one, a metaphor that suits an unvisited link turning into a confirmed one.
.link::before, .link::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
transition: opacity 300ms $ease-out;
}
.link::before {
background-image: repeating-linear-gradient(90deg, currentColor 0 3px, transparent 3px 7px);
opacity: 1;
}
.link::after { background: currentColor; opacity: 0; }
.link:hover::before, .link:focus-visible::before { opacity: 0; }
.link:hover::after, .link:focus-visible::after { opacity: 1; }
Where does this break?
Item 03's multi-line underline shipped broken twice before it worked. The first attempt tiled the background underline sideways with background-repeat: repeat-x, and a tiled background fills in at any size — even 0% — so the underline looked fully drawn from the very first frame instead of growing. Measuring the render caught it directly: only 1 of 23 captured frames showed any change, well under this site's 6-frame floor for counting something as real motion. Switching to background-repeat: no-repeat let each line grow on its own from 0% to 100%, and the same measurement jumped to 18 of 23 frames. The second bug sat one line above it: the parent <p> was first given a max-width to force the label onto two lines, but inside a display: grid; place-items: center stage a max-width alone doesn't clamp the content — the text just rendered on one line at its natural width. Only a fixed width on that parent actually forced the wrap, which a poster screenshot confirmed the max-width version never did. The zip holding all nine files needs a password before it opens, written here as an ordinary word rather than boxed or highlighted: je9m2xxs.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Snappier draw | Item 01's transition cut from 220ms to 120ms | The underline snaps into place instead of gliding, closer to a click confirmation |
| Thicker highlight | Item 08's marker block widened with a taller inset |
The highlighter stroke covers more of the line height, reading as a heavier marker |
| Faster wave | Item 06's wave-flow duration cut from 1.2s to 0.6s |
The bumps scroll past twice as fast, closer to a ripple than a gentle wave |
Accessibility — what reduced motion leaves behind
Every item disables its transition and animation under prefers-reduced-motion: reduce, but what a visitor still sees differs by item. Items 01, 02, 03, 04, and 07 snap straight to their finished hover state — full underline, full thickness, full strikethrough — the instant a mouse or keyboard focus arrives, with no growth or fade in between. Item 08's highlighter block does the same, filling in immediately rather than sweeping across. Items 05 and 06 keep their reveal — the bar or wave still appears on hover — but the separate looping animation that made color or bumps travel sideways is switched off entirely, so both stay still once shown. Item 09 still swaps its dotted line for a solid one on hover; only the 300ms cross-fade between them is removed; it happens instantly instead. The MDN prefers-reduced-motion entry lists which browsers respect the setting.
For more hover-triggered CSS, see 9 CSS Text Highlight Animations; for animations that don't need a link at all, see 9 CSS Button Animations.
FAQ
Do I need JavaScript for any of these underlines?
No. All nine run on :hover, :focus-visible, and plain CSS transitions or @keyframes — nothing in this set reads or sets a class with a script.
Why add :focus-visible next to :hover at all?
A visitor moving between links with the Tab key never triggers :hover — their pointer hasn't moved. Pairing :focus-visible with :hover on every rule means the same underline shows up for keyboard navigation, not just a mouse.
Which of these nine works best inside a paragraph of text, not a menu?
Item 01's left-to-right underline and item 04's thickening underline both stay closest to what a plain link already looks like, so they read as an underline first and an animation second — a good fit for a link sitting inside a sentence rather than standing alone in a nav bar.