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.
01 link-underline-hover-underline-left-right
02 link-underline-hover-underline-center-out
03 link-underline-hover-underline-multiline
04 link-underline-hover-underline-thick-grow
05 link-underline-hover-underline-color-slide
06 link-underline-hover-underline-wavy
07 link-underline-hover-strike-through-hover
08 link-underline-hover-highlight-marker-hover
09 link-underline-hover-underline-dotted-morph
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 unlike a css link underline only on hover, a visitor tabbing through links with a keyboard sees the same motion as someone pointing a mouse. When direction rather than a line should pull the eye, the CSS arrow animations are that signal, and a link riding on a card that flips is the scale of the card flip animations.
01Left-to-right underline
How the bar grows
Hover the link and a thin bar grows from its left edge across to the right.

<main class="stage">
<a class="link is-demo" href="#">Learn more</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.link {
position: relative;
display: inline-block;
padding: $sp-2 $sp-1;
color: $color;
text-decoration: none;
font-weight: 700; font-size: clamp(18px, 5vw, 26px); line-height: 1.2;
&::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: 0;
height: 3px;
background: currentColor;
transform: scaleX(0);
transform-origin: left center; // 고정 — 들어올 때도 나갈 때도 항상 왼쪽 기준
transition: transform $duration $easing;
}
&:hover::after, &:focus-visible::after {
transform: scaleX(1);
}
&.is-demo::after {
animation: underline-lr $dur-loop $easing infinite;
}
}
@keyframes underline-lr {
0%, 100% { transform: scaleX(0); }
35%, 60% { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.link::after, .link.is-demo::after { transition: none; animation: none; }
.link:hover::after, .link:focus-visible::after { transform: scaleX(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.link {
position: relative;
display: inline-block;
padding: 8px 4px;
color: #fff7e6;
text-decoration: none;
font-weight: 700;
font-size: clamp(18px, 5vw, 26px);
line-height: 1.2;
}
.link::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: currentColor;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.link:hover::after, .link:focus-visible::after {
transform: scaleX(1);
}
.link.is-demo::after {
animation: underline-lr 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes underline-lr {
0%, 100% {
transform: scaleX(0);
}
35%, 60% {
transform: scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.link::after, .link.is-demo::after {
transition: none;
animation: none;
}
.link:hover::after, .link:focus-visible::after {
transform: scaleX(1);
}
}document.querySelectorAll('.link').forEach(function (b) {
b.addEventListener('pointerenter', function () { b.classList.remove('is-demo'); });
b.addEventListener('click', function (e) { e.preventDefault(); });
});import type { CSSProperties, ReactNode } from "react";
import styles from "./LeftRightUnderline.module.scss";
export type LeftRightUnderlineProps = { children: ReactNode; href: string; duration?: number; color?: string; thickness?: number };
export default function LeftRightUnderline({ children, href, duration = 220, color = "currentColor", thickness = 3 }: LeftRightUnderlineProps) {
const vars = { "--duration": `${duration}ms`, "--color": color, "--thickness": `${thickness}px` } as CSSProperties;
return (
<a className={styles.link} href={href} style={vars}>
{children}
</a>
);
}01 · Left-to-right underline — Measured area 0.272% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Coming and going
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 one value that changes
The same bar as item 01 keeps every line of code except one: transform-origin moves from left to center.

<main class="stage">
<a class="link is-demo" href="#">Card title link</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.link {
position: relative;
display: inline-block;
padding: $sp-2 $sp-1;
color: $color;
text-decoration: none;
font-weight: 800; font-size: clamp(18px, 5vw, 26px); line-height: 1.2;
&::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: 0;
height: 3px;
background: currentColor;
transform: scaleX(0);
transform-origin: center; // 가운데서 양옆으로
transition: transform $duration $easing;
}
&:hover::after, &:focus-visible::after {
transform: scaleX(1);
}
&.is-demo::after {
animation: underline-center $dur-loop $easing infinite;
}
}
@keyframes underline-center {
0%, 100% { transform: scaleX(0); }
35%, 60% { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.link::after, .link.is-demo::after { transition: none; animation: none; }
.link:hover::after, .link:focus-visible::after { transform: scaleX(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.link {
position: relative;
display: inline-block;
padding: 8px 4px;
color: #17141a;
text-decoration: none;
font-weight: 800;
font-size: clamp(18px, 5vw, 26px);
line-height: 1.2;
}
.link::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: currentColor;
transform: scaleX(0);
transform-origin: center;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.link:hover::after, .link:focus-visible::after {
transform: scaleX(1);
}
.link.is-demo::after {
animation: underline-center 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes underline-center {
0%, 100% {
transform: scaleX(0);
}
35%, 60% {
transform: scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.link::after, .link.is-demo::after {
transition: none;
animation: none;
}
.link:hover::after, .link:focus-visible::after {
transform: scaleX(1);
}
}document.querySelectorAll('.link').forEach(function (b) {
b.addEventListener('pointerenter', function () { b.classList.remove('is-demo'); });
b.addEventListener('click', function (e) { e.preventDefault(); });
});import type { CSSProperties, ReactNode } from "react";
import styles from "./CenterOutUnderline.module.scss";
export type CenterOutUnderlineProps = { children: ReactNode; href: string; duration?: number; color?: string; thickness?: number };
export default function CenterOutUnderline({ children, href, duration = 300, color = "currentColor", thickness = 3 }: CenterOutUnderlineProps) {
const vars = { "--duration": `${duration}ms`, "--color": color, "--thickness": `${thickness}px` } as CSSProperties;
return (
<a className={styles.link} href={href} style={vars}>
{children}
</a>
);
}02 · Center-out underline — Measured area 0.454% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Growing from the middle
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
Why a wrapped link loses its underline
A nav label long enough to wrap onto two lines usually gets a pseudo-element underline only under its last line.

<main class="stage">
<p class="wrap"><a class="link is-demo" href="#">See all notices again</a></p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
// box-decoration-break: clone 이 인라인 흐름에서만 줄마다 적용되므로 부모 폭을 좁혀 두 줄로 감싼다
.wrap {
margin: 0;
width: 160px; // max-width 는 그리드 place-items:center 아래서 내용 폭에 clamp 되지 않고 그대로 넓어졌다(실측) — 고정 width 로 강제로 줄바꿈시킨다
text-align: center;
}
.link {
color: $color;
text-decoration: none;
font-weight: 700; font-size: clamp(16px, 4.4vw, 20px); line-height: 1.6;
// 밑줄을 border/의사요소가 아니라 background-image 로 그린다 — box-decoration-break: clone 이
// 줄마다(fragment마다) 이 배경을 통째로 복제해 각 줄의 밑줄을 독립적으로 그린다.
// repeat-x 를 쓰면 크기가 작아도 타일이 반복돼 처음부터 꽉 차 보인다(실측 — 렌더 측정에서 움직인 프레임 1/23) → no-repeat
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 $duration $easing;
}
.link:hover, .link:focus-visible {
background-size: 100% 2px;
}
.link.is-demo {
animation: underline-multiline $dur-loop $easing infinite;
}
@keyframes underline-multiline {
0%, 100% { background-size: 0% 2px; }
35%, 60% { background-size: 100% 2px; }
}
@media (prefers-reduced-motion: reduce) {
.link, .link.is-demo { transition: none; animation: none; }
.link:hover, .link:focus-visible { background-size: 100% 2px; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
margin: 0;
width: 160px;
text-align: center;
}
.link {
color: #fff7e6;
text-decoration: none;
font-weight: 700;
font-size: clamp(16px, 4.4vw, 20px);
line-height: 1.6;
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;
}
.link.is-demo {
animation: underline-multiline 2s ease-out infinite;
}
@keyframes underline-multiline {
0%, 100% {
background-size: 0% 2px;
}
35%, 60% {
background-size: 100% 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.link, .link.is-demo {
transition: none;
animation: none;
}
.link:hover, .link:focus-visible {
background-size: 100% 2px;
}
}document.querySelectorAll('.link').forEach(function (b) {
b.addEventListener('pointerenter', function () { b.classList.remove('is-demo'); });
b.addEventListener('click', function (e) { e.preventDefault(); });
});import type { CSSProperties, ReactNode } from "react";
import styles from "./MultilineUnderline.module.scss";
export type MultilineUnderlineProps = { children: ReactNode; href: string; duration?: number; color?: string; maxWidth?: number };
export default function MultilineUnderline({ children, href, duration = 300, color = "currentColor", maxWidth = 160 }: MultilineUnderlineProps) {
const vars = { "--duration": `${duration}ms`, "--color": color, "--max-width": `${maxWidth}px` } as CSSProperties;
return (
<p className={styles.wrap} style={vars}>
<a className={styles.link} href={href}>
{children}
</a>
</p>
);
}03 · Multi-line underline — Measured area 0.331% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Cloning the background per 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
Native underline, untouched
The native text-decoration-line: underline stays exactly as it is.

<main class="stage">
<a class="link is-demo" href="#">The article you're reading</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.link {
color: $subject-ink;
text-decoration-line: underline;
text-decoration-style: solid; // wavy 가 아니라 solid — 046-04 는 물결, 이 항목은 굵기만 바뀐다
text-decoration-color: $color;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
font-weight: 700; font-size: clamp(18px, 5vw, 26px); line-height: 1.4;
transition: text-decoration-thickness $duration $easing, text-underline-offset $duration $easing;
}
.link:hover, .link:focus-visible {
text-decoration-thickness: 4px;
text-underline-offset: 5px;
}
.link.is-demo {
animation: underline-thick $dur-loop $easing infinite;
}
@keyframes underline-thick {
0%, 100% { text-decoration-thickness: 1px; text-underline-offset: 3px; }
35%, 60% { text-decoration-thickness: 4px; text-underline-offset: 5px; }
}
@media (prefers-reduced-motion: reduce) {
.link, .link.is-demo { transition: none; animation: none; }
.link:hover, .link:focus-visible { text-decoration-thickness: 4px; text-underline-offset: 5px; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.link {
color: #17141a;
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-color: #2f6df6;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
font-weight: 700;
font-size: clamp(18px, 5vw, 26px);
line-height: 1.4;
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;
}
.link.is-demo {
animation: underline-thick 2s ease-out infinite;
}
@keyframes underline-thick {
0%, 100% {
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
35%, 60% {
text-decoration-thickness: 4px;
text-underline-offset: 5px;
}
}
@media (prefers-reduced-motion: reduce) {
.link, .link.is-demo {
transition: none;
animation: none;
}
.link:hover, .link:focus-visible {
text-decoration-thickness: 4px;
text-underline-offset: 5px;
}
}document.querySelectorAll('.link').forEach(function (b) {
b.addEventListener('pointerenter', function () { b.classList.remove('is-demo'); });
b.addEventListener('click', function (e) { e.preventDefault(); });
});import type { CSSProperties, ReactNode } from "react";
import styles from "./ThickGrowUnderline.module.scss";
export type ThickGrowUnderlineProps = { children: ReactNode; href: string; duration?: number; color?: string; maxThickness?: number };
export default function ThickGrowUnderline({ children, href, duration = 300, color = "#2f6df6", maxThickness = 4 }: ThickGrowUnderlineProps) {
const vars = { "--duration": `${duration}ms`, "--color": color, "--max-thickness": `${maxThickness}px` } as CSSProperties;
return (
<a className={styles.link} href={href} style={vars}>
{children}
</a>
);
}04 · Thickening underline — Measured area 0.733% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
What actually animates
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 twice as wide
A gradient bar twice as wide as the underline itself sits under the text.

<main class="stage">
<a class="link is-demo" href="#">Sign up now</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.link {
position: relative;
display: inline-block;
padding: $sp-2 $sp-1;
color: $subject-cream;
text-decoration: none;
font-weight: 700; font-size: clamp(18px, 5vw, 26px); line-height: 1.2;
&::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: 0;
height: 3px;
background: linear-gradient(90deg, $color, $stage-yellow, $color);
background-size: 200% 100%;
background-position: 0% 0;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 언더라인 좌측 채움
transition: transform $duration $easing;
}
// 실제 브라우저 — scaleX 는 트랜지션, 색 흐름은 호버 중에만 도는 애니메이션
&:hover::after, &:focus-visible::after {
transform: scaleX(1);
animation: color-flow 1.4s linear infinite;
}
// 렌더러는 :hover 를 못 흉내 내므로 하나의 키프레임에 그리기·흐름·지우기를 전부 담는다
&.is-demo::after {
animation: color-slide-loop $dur-loop $easing infinite;
}
}
@keyframes color-flow {
0% { background-position: 0% 0; }
100% { background-position: 100% 0; }
}
@keyframes color-slide-loop {
0% { transform: scaleX(0); background-position: 0% 0; }
15% { transform: scaleX(1); background-position: 0% 0; }
85% { transform: scaleX(1); background-position: 100% 0; }
100% { transform: scaleX(0); background-position: 100% 0; }
}
@media (prefers-reduced-motion: reduce) {
.link::after, .link.is-demo::after { transition: none; animation: none; }
.link:hover::after, .link:focus-visible::after { transform: scaleX(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.link {
position: relative;
display: inline-block;
padding: 8px 4px;
color: #fff7e6;
text-decoration: none;
font-weight: 700;
font-size: clamp(18px, 5vw, 26px);
line-height: 1.2;
}
.link::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: linear-gradient(90deg, #2f6df6, #ffd23f, #2f6df6);
background-size: 200% 100%;
background-position: 0% 0;
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;
}
.link.is-demo::after {
animation: color-slide-loop 2s ease-out infinite;
}
@keyframes color-flow {
0% {
background-position: 0% 0;
}
100% {
background-position: 100% 0;
}
}
@keyframes color-slide-loop {
0% {
transform: scaleX(0);
background-position: 0% 0;
}
15% {
transform: scaleX(1);
background-position: 0% 0;
}
85% {
transform: scaleX(1);
background-position: 100% 0;
}
100% {
transform: scaleX(0);
background-position: 100% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.link::after, .link.is-demo::after {
transition: none;
animation: none;
}
.link:hover::after, .link:focus-visible::after {
transform: scaleX(1);
}
}document.querySelectorAll('.link').forEach(function (b) {
b.addEventListener('pointerenter', function () { b.classList.remove('is-demo'); });
b.addEventListener('click', function (e) { e.preventDefault(); });
});import type { CSSProperties, ReactNode } from "react";
import styles from "./ColorSlideUnderline.module.scss";
export type ColorSlideUnderlineProps = { children: ReactNode; href: string; duration?: number; color?: string; accent?: string };
export default function ColorSlideUnderline({ children, href, duration = 220, color = "#2f6df6", accent = "#ffd23f" }: ColorSlideUnderlineProps) {
const vars = { "--duration": `${duration}ms`, "--color": color, "--accent": accent } as CSSProperties;
return (
<a className={styles.link} href={href} style={vars}>
{children}
</a>
);
}05 · Color-slide underline — Measured area 0.318% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
While the cursor stays
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
Drawing the bumps by hand
Instead of the native text-decoration-style: wavy, two layered radial gradients are tiled sideways to draw the bumps by hand.

<main class="stage">
<a class="link is-demo" href="#">Today's pick</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-quick;
$easing: $ease-out;
$color: $stage-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.link {
position: relative;
display: inline-block;
padding: $sp-2 $sp-1 $sp-3;
color: $color;
text-decoration: none;
font-weight: 700; font-size: clamp(18px, 5vw, 26px); line-height: 1.2;
// 물결은 네이티브 wavy 가 아니라 두 겹 radial-gradient 로 그린다 (repeat-x, currentColor 상속)
&::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: 0;
height: 10px;
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;
background-position: 0 0, 0 0;
opacity: 0;
transition: opacity $duration $easing;
}
// 실제 브라우저 — 등장은 opacity 트랜지션, 흐름은 호버 중에만 도는 별도 애니메이션
&:hover::after, &:focus-visible::after {
opacity: 1;
animation: wave-flow 1.2s linear infinite;
}
&.is-demo::after {
animation: wave-loop $dur-loop $easing infinite;
}
}
@keyframes wave-flow {
0% { background-position: 0 0, 0 0; }
100% { background-position: -40px 0, -40px 0; }
}
@keyframes wave-loop {
0% { opacity: 0; background-position: 0 0, 0 0; }
15% { opacity: 1; background-position: 0 0, 0 0; }
85% { opacity: 1; background-position: -40px 0, -40px 0; }
100% { opacity: 0; background-position: -40px 0, -40px 0; }
}
@media (prefers-reduced-motion: reduce) {
.link::after, .link.is-demo::after { transition: none; animation: none; }
.link:hover::after, .link:focus-visible::after { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.link {
position: relative;
display: inline-block;
padding: 8px 4px 12px;
color: #17141a;
text-decoration: none;
font-weight: 700;
font-size: clamp(18px, 5vw, 26px);
line-height: 1.2;
}
.link::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 10px;
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;
background-position: 0 0, 0 0;
opacity: 0;
transition: opacity 220ms ease-out;
}
.link:hover::after, .link:focus-visible::after {
opacity: 1;
animation: wave-flow 1.2s linear infinite;
}
.link.is-demo::after {
animation: wave-loop 2s ease-out infinite;
}
@keyframes wave-flow {
0% {
background-position: 0 0, 0 0;
}
100% {
background-position: -40px 0, -40px 0;
}
}
@keyframes wave-loop {
0% {
opacity: 0;
background-position: 0 0, 0 0;
}
15% {
opacity: 1;
background-position: 0 0, 0 0;
}
85% {
opacity: 1;
background-position: -40px 0, -40px 0;
}
100% {
opacity: 0;
background-position: -40px 0, -40px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.link::after, .link.is-demo::after {
transition: none;
animation: none;
}
.link:hover::after, .link:focus-visible::after {
opacity: 1;
}
}document.querySelectorAll('.link').forEach(function (b) {
b.addEventListener('pointerenter', function () { b.classList.remove('is-demo'); });
b.addEventListener('click', function (e) { e.preventDefault(); });
});import type { CSSProperties, ReactNode } from "react";
import styles from "./WavyUnderline.module.scss";
export type WavyUnderlineProps = { children: ReactNode; href: string; duration?: number; color?: string };
export default function WavyUnderline({ children, href, duration = 220, color = "currentColor" }: WavyUnderlineProps) {
const vars = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<a className={styles.link} href={href} style={vars}>
{children}
</a>
);
}06 · Wavy underline — Measured area 0.875% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Keeping the wave moving
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 at mid-height
A line sits at the text's mid-height and only draws itself, left to right, while the cursor is over the link.

<main class="stage">
<a class="link is-demo" href="#">Mark task as done</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-instant;
$easing: $ease-pop;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.link {
position: relative;
display: inline-block;
padding: $sp-2 $sp-1;
color: $color;
text-decoration: none;
font-weight: 700; font-size: clamp(18px, 5vw, 26px); line-height: 1.2;
&::after {
content: "";
position: absolute;
left: 0; right: 0; top: 50%; // 글자 중간 높이 — 취소선 자리
height: 2px;
background: currentColor;
transform: translateY(-50%) scaleX(0);
transform-origin: left center; // 규칙8: 취소선 왼쪽에서 긋기
transition: transform $duration $easing;
}
&:hover::after, &:focus-visible::after {
transform: translateY(-50%) scaleX(1);
}
&.is-demo::after {
animation: strike-hover $dur-loop $easing infinite;
}
}
@keyframes strike-hover {
0%, 100% { transform: translateY(-50%) scaleX(0); }
35%, 60% { transform: translateY(-50%) scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.link::after, .link.is-demo::after { transition: none; animation: none; }
.link:hover::after, .link:focus-visible::after { transform: translateY(-50%) scaleX(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.link {
position: relative;
display: inline-block;
padding: 8px 4px;
color: #fff7e6;
text-decoration: none;
font-weight: 700;
font-size: clamp(18px, 5vw, 26px);
line-height: 1.2;
}
.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 cubic-bezier(0.2, 1.4, 0.4, 1);
}
.link:hover::after, .link:focus-visible::after {
transform: translateY(-50%) scaleX(1);
}
.link.is-demo::after {
animation: strike-hover 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes strike-hover {
0%, 100% {
transform: translateY(-50%) scaleX(0);
}
35%, 60% {
transform: translateY(-50%) scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.link::after, .link.is-demo::after {
transition: none;
animation: none;
}
.link:hover::after, .link:focus-visible::after {
transform: translateY(-50%) scaleX(1);
}
}document.querySelectorAll('.link').forEach(function (b) {
b.addEventListener('pointerenter', function () { b.classList.remove('is-demo'); });
b.addEventListener('click', function (e) { e.preventDefault(); });
});import type { CSSProperties, ReactNode } from "react";
import styles from "./StrikeThroughHover.module.scss";
export type StrikeThroughHoverProps = { children: ReactNode; href: string; duration?: number; color?: string };
export default function StrikeThroughHover({ children, href, duration = 140, color = "currentColor" }: StrikeThroughHoverProps) {
const vars = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<a className={styles.link} href={href} style={vars}>
{children}
</a>
);
}07 · Hover strikethrough — Measured area 0.231% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it reads as an action
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 block behind the text
A tilted, rounded block sits behind the text and blends into it with mix-blend-mode: multiply.

<main class="stage">
<a class="link is-demo" href="#">You might also like this</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-base;
$easing: $ease-pop;
$color: $stage-yellow;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.link {
position: relative;
isolation: isolate; // z-index:-1 마커가 무대 배경보다 먼저 그려져 안 보이는 걸 막는다 (046 01·02 실측)
display: inline-block;
padding: $sp-1 $sp-2;
color: $subject-ink;
text-decoration: none;
font-weight: 700; font-size: clamp(18px, 5vw, 26px); line-height: 1.3;
&::before {
content: "";
position: absolute;
inset: 10% -4% 6%;
background: $color;
border-radius: $r-xs;
transform: skewX(-6deg) scaleX(0);
transform-origin: left center; // 규칙8: 형광펜 마커 왼쪽 채움
mix-blend-mode: multiply;
z-index: -1;
transition: transform $duration $easing;
}
&:hover::before, &:focus-visible::before {
transform: skewX(-6deg) scaleX(1);
}
&.is-demo::before {
animation: marker-hover $dur-loop $easing infinite;
}
}
@keyframes marker-hover {
0%, 100% { transform: skewX(-6deg) scaleX(0); }
35%, 60% { transform: skewX(-6deg) scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.link::before, .link.is-demo::before { transition: none; animation: none; }
.link:hover::before, .link:focus-visible::before { transform: skewX(-6deg) scaleX(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.link {
position: relative;
isolation: isolate;
display: inline-block;
padding: 4px 8px;
color: #17141a;
text-decoration: none;
font-weight: 700;
font-size: clamp(18px, 5vw, 26px);
line-height: 1.3;
}
.link::before {
content: "";
position: absolute;
inset: 10% -4% 6%;
background: #ffd23f;
border-radius: 4px;
transform: skewX(-6deg) scaleX(0);
transform-origin: left center;
mix-blend-mode: multiply;
z-index: -1;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.link:hover::before, .link:focus-visible::before {
transform: skewX(-6deg) scaleX(1);
}
.link.is-demo::before {
animation: marker-hover 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes marker-hover {
0%, 100% {
transform: skewX(-6deg) scaleX(0);
}
35%, 60% {
transform: skewX(-6deg) scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.link::before, .link.is-demo::before {
transition: none;
animation: none;
}
.link:hover::before, .link:focus-visible::before {
transform: skewX(-6deg) scaleX(1);
}
}document.querySelectorAll('.link').forEach(function (b) {
b.addEventListener('pointerenter', function () { b.classList.remove('is-demo'); });
b.addEventListener('click', function (e) { e.preventDefault(); });
});import type { CSSProperties, ReactNode } from "react";
import styles from "./HighlightMarkerHover.module.scss";
export type HighlightMarkerHoverProps = { children: ReactNode; href: string; duration?: number; color?: string; skew?: number };
export default function HighlightMarkerHover({ children, href, duration = 300, color = "#ffd23f", skew = -6 }: HighlightMarkerHoverProps) {
const vars = { "--duration": `${duration}ms`, "--color": color, "--skew": `${skew}deg` } as CSSProperties;
return (
<a className={styles.link} href={href} style={vars}>
{children}
</a>
);
}08 · Hover highlight marker — Measured area 5.158% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why multiply matters
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
Two bars in the same spot
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.

<main class="stage">
<a class="link is-demo" href="#">Read the next post</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
// text-decoration-style 은 애니메이션이 안 되는 이산값이라, 점선 막대·실선 막대 두 개를 같은 자리에 겹쳐 opacity 로 교차한다
.link {
position: relative;
display: inline-block;
padding: $sp-2 $sp-1;
color: $color;
text-decoration: none;
font-weight: 700; font-size: clamp(18px, 5vw, 26px); line-height: 1.2;
&::before, &::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
transition: opacity $duration $easing;
}
&::before {
background-image: repeating-linear-gradient(90deg, currentColor 0 3px, transparent 3px 7px);
opacity: 1;
}
&::after {
background: currentColor;
opacity: 0;
}
&:hover::before, &:focus-visible::before { opacity: 0; }
&:hover::after, &:focus-visible::after { opacity: 1; }
&.is-demo::before { animation: dotted-fade $dur-loop $easing infinite; }
&.is-demo::after { animation: solid-fade $dur-loop $easing infinite; }
}
@keyframes dotted-fade {
0%, 100% { opacity: 1; }
35%, 60% { opacity: 0; }
}
@keyframes solid-fade {
0%, 100% { opacity: 0; }
35%, 60% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.link::before, .link::after, .link.is-demo::before, .link.is-demo::after { transition: none; animation: none; }
.link:hover::before, .link:focus-visible::before { opacity: 0; }
.link:hover::after, .link:focus-visible::after { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.link {
position: relative;
display: inline-block;
padding: 8px 4px;
color: #fff7e6;
text-decoration: none;
font-weight: 700;
font-size: clamp(18px, 5vw, 26px);
line-height: 1.2;
}
.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;
}
.link.is-demo::before {
animation: dotted-fade 2s ease-out infinite;
}
.link.is-demo::after {
animation: solid-fade 2s ease-out infinite;
}
@keyframes dotted-fade {
0%, 100% {
opacity: 1;
}
35%, 60% {
opacity: 0;
}
}
@keyframes solid-fade {
0%, 100% {
opacity: 0;
}
35%, 60% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.link::before, .link::after, .link.is-demo::before, .link.is-demo::after {
transition: none;
animation: none;
}
.link:hover::before, .link:focus-visible::before {
opacity: 0;
}
.link:hover::after, .link:focus-visible::after {
opacity: 1;
}
}document.querySelectorAll('.link').forEach(function (b) {
b.addEventListener('pointerenter', function () { b.classList.remove('is-demo'); });
b.addEventListener('click', function (e) { e.preventDefault(); });
});import type { CSSProperties, ReactNode } from "react";
import styles from "./DottedMorphUnderline.module.scss";
export type DottedMorphUnderlineProps = { children: ReactNode; href: string; duration?: number; color?: string };
export default function DottedMorphUnderline({ children, href, duration = 300, color = "currentColor" }: DottedMorphUnderlineProps) {
const vars = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<a className={styles.link} href={href} style={vars}>
{children}
</a>
);
}09 · Dotted-to-solid morph — Measured area 0.183% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
What the swap suggests
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?
Why a css hover underline effect can look finished from frame one
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 max-width that never clamped
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. A yellow underline dying on a sky-blue field is precisely the accident the citrus landing kit heads off by pairing saturations in advance.
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
What reduced motion still shows
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.
Where to go next
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 css text underline hover effects work best inside a paragraph, 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. To make a whole sentence stand out rather than a line under it, the blockquote CSS designs do it as a quote box.








