CSS リンク 下線 アニメーション9選 — 一行でコピペOK
css リンク 下線 アニメーションとは、マウスを乗せたりキーボードで focus を移したときだけリンクの下線が動いて現れる効果のことで、今どのリンクを指しているかが一目でわかるようになります。9種類まとめて用意しました。どれも一行コピペでそのまま貼って使えます。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9個は人気順ではなく、リンクに実際に出会う順番で並べています。最も基本の左右下線(01)から始めて同じ仕組みの応用(02)を見せたあと、メニューの文字が実際に折り返す場面(03)を扱い、より強い強調(04)とブランドカラー(05)を重ねます。続いて遊び心のある波線(06)でトーンをやわらげ、削除や完了を示す取り消し線(07)、おすすめリンクを示す蛍光ペン(08)、最後にまだ読んでいないリンクが確定状態に変わる点線→実線(09)で締めくくります。線ではなく方向で目を引きたいならCSS矢印アニメーションがその合図で、リンクがカードに載って裏返る規模ならカードフリップのアニメーションまで行きます。
なぜ :hover だけでなく :focus-visible も一緒に書くのか
css リンク ホバー アニメーションをキーボードでも効かせる
マウスを使わないキーボード利用者は Tab キーでリンクの間を移動します。9個すべてで :hover の隣に :focus-visible を並べて書き、キーボードで focus が移ってきたときもマウスを乗せたときと同じように下線が動くようにしました。
ギャラリーのデモと実ページの違い
ギャラリーでレンダリングされたデモは .is-demo クラスが同じ動きを2秒ごとに強制的に繰り返しているだけで、実際のページに貼り付ければ本物の hover・focus のときだけ動きます。
01左から右へ下線
どう引かれるか
もっとも基本的な形で、リンクの下の細い棒が左側から右へ伸びながら下線が引かれます。

<main class="stage">
<a class="link is-demo" href="#">詳しく見る</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 · 左から右へ下線 — 計測 累積面積 0.272% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
入るときと出るとき
マウスを離すと同じ向きに縮んでいき、入るときと出るときが左右対称です。
.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);
}
}
02中央から広がる下線
基準点だけを変える
01と同じ棒を使いながら基準点だけ中央に変えました。

<main class="stage">
<a class="link is-demo" href="#">カードタイトルリンク</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 · 中央から広がる下線 — 計測 累積面積 0.454% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
左右同時に広がる
マウスを乗せると下線が真ん中から始まって左右同時に広がりながら引かれます。
.link::after {
transform-origin: center;
transition: transform 300ms $ease-pop;
}
.link:hover::after,
.link:focus-visible::after {
transform: scaleX(1);
}
03複数行対応下線
2行に折り返したとき
メニューの文字が長くて2行に折り返すと、擬似要素の下線はふつう最後の行にしか引かれません。

<main class="stage">
<p class="wrap"><a class="link is-demo" href="#">お知らせ一覧をもう一度見る</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 · 複数行対応下線 — 計測 累積面積 0.331% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
行ごとに引く方法
背景グラデーションを下線として使い、行ごとに丸ごと複製する性質を加えて、2行に折り返したリンクでも行ごとに下線が別々に引かれるようにしました。
.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;
}
box-decoration-break: clone は Firefox では接頭辞なしで使えますが、Chrome・Edge・Safari では今も -webkit-box-decoration-break の接頭辞付きでしか効きません。両方書いてはじめて主要ブラウザ全体をカバーできます。詳しくはMDNの box-decoration-break ドキュメントにまとまっています。
04太くなる下線
ネイティブの下線プロパティ
ネイティブの下線プロパティをそのまま使い、太さと下線・文字の間隔だけをアニメーションさせました。

<main class="stage">
<a class="link is-demo" href="#">今読んでいる記事</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 · 太くなる下線 — 計測 累積面積 0.733% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
文字が押し出されない理由
レイアウトを計算し直すプロパティではないので、周りの文字が押し出されることなく下線だけが目立って太くなります。
.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;
}
05色が流れる下線
幅2倍のグラデーション
下線の棒に幅2倍のグラデーションを敷いておくと、ホバー中だけそのグラデーションが横に流れ、色が下線の上を通り過ぎていくように見えます。

<main class="stage">
<a class="link is-demo" href="#">今すぐ登録</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 · 色が流れる下線 — 計測 累積面積 0.318% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
マウスを離すと
マウスを離すと流れが止まり下線も縮みます。
.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; }
}
06波線下線
波を自分で描く
ネイティブの波線下線の代わりに、円形グラデーション2枚を横に繰り返し並べて波の形を自分で描きました。

<main class="stage">
<a class="link is-demo" href="#">本日のおすすめ</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 · 波線下線 — 計測 累積面積 0.875% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
止まらない波にする
ホバーすると波が姿を現すと同時に横へ流れ、止まった波ではなく流れ続ける波線になります。
.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; }
}
07ホバー取り消し線
文字の中央に横線
文字の中央の高さに横線を置き、ホバーしたときだけ引きます。

<main class="stage">
<a class="link is-demo" href="#">タスクを完了にする</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 · ホバー取り消し線 — 計測 累積面積 0.231% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どんな場面に向くか
自動で繰り返すことはなく、マウスを乗せたときにだけ現れるので、削除や完了処理のようにユーザー自身が起こす動作を示すのに向いています。
.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);
}
08ホバーマーカー
文字の背後に隠すブロック
文字の背後に少し傾けた丸い端のブロックを隠しておき、ホバーすると蛍光ペンでちょうど塗ったように塗り広がります。

<main class="stage">
<a class="link is-demo" href="#">こちらの記事もどうぞ</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 · ホバーマーカー — 計測 累積面積 5.158% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
マーカーらしさの出どころ
色を乗算で重ねることで、実物のマーカー特有の半透明な質感を出しています。
.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 を親の .link に付けるのを忘れると、z-index: -1 のマーカーが積み重ね文脈の外に出てしまい、舞台の背景より下に描画されて見えなくなります。乗算以外に使える値はMDNの mix-blend-mode ドキュメントにまとまっています。
09点線→実線モーフ
点線と実線を重ねる
下線のスタイルそのものはアニメーションできない値なので、点線の棒と実線の棒を同じ位置に重ねておき、ホバー時に互いの不透明度を入れ替えます。

<main class="stage">
<a class="link is-demo" href="#">次の記事を見る</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 · 点線→実線モーフ — 計測 累積面積 0.183% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どんな意味に読めるか
まだ読んでいないリンクが確定した状態に変わる感覚を出せます。
.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; }
どこで壊れるか — 落とし穴2つ
繰り返す背景が下線を埋めた
css 下線 アニメーション 複数行に当たる03番は、最初に作ったときは下線が伸びる様子がまったく見えませんでした。下線を描く背景画像を横に繰り返す設定にしていたところ、サイズがごく小さい値でもタイルが繰り返し敷き詰められ、最初から下線が満杯になっているように映ってしまったのです。レンダリング計測でも動いたフレームが1枚しか検出されず、しきい値を通りませんでした。繰り返しを止めて一度だけ描く設定に変えると、各行の下線が実際に0%から100%まで育っていくのが計測でも目でも確認できました。
最大幅が効かなかった理由
同じ項目にもう一つ落とし穴が重なっていました。2行に折り返させるつもりで親要素に最大幅を指定したのですが、中央寄せのグリッドレイアウトの中ではその値が実際には効かず、文字が元の幅のまま1行に広がってしまいました。最大幅を固定幅に変えてはじめて、文字が2行に折り返され下線も行ごとに別々に引かれるのが撮影した画像で確かめられました。9個のファイルが入った圧縮ファイルの合言葉はje9m2xxsで、文中にさりげなく書いてあるので、ここからそのままコピーして使ってください。黄色い下線が空色の背景で死ぬ色対比の事故は、シトラスカラーのLPキットが彩度の組み合わせを前もって決めて防ぐまさにその事故です。
アクセシビリティ
reduced motion で残るもの
OSで motion を減らす設定を入れている方には、9項目すべてで prefers-reduced-motion: reduce のなかでトランジションと自動アニメーションを止めるようにしています。01・02・03・04・07・08・09は、ホバーや focus の瞬間にアニメーションなしでそのまま最終形(下線が引かれた・太くなった・マーカーが塗られた状態)に切り替わるので、情報が欠けることはありません。05・06は色が流れる動き・波が横へ流れる動きだけを止め、下線や波そのものが現れることは残しています。どのブラウザから対応しているかはMDNの prefers-reduced-motion ドキュメントで確認できます。
ほかの効果はどこに
リンクの下線以外の文字装飾アニメーションはテキスト強調アニメーション9選、ボタンの動きはCSSボタンアニメーション9選でも探せます。ほかのCSSアニメーションはCSSカテゴリーからまとめて見られます。
FAQ
css ホバー アニメーション 下線を9個とも同じページで使ってもいいですか?
使えますが、同じ画面の中ではナビゲーションには一つ、本文中のインラインリンクには一つのように、場所ごとに一種類だけに絞るのがおすすめです。複数の下線効果が混ざると、今どのリンクを指しているのかがかえって分かりにくくなります。
訪問済みリンクの色も一緒に変えられますか?
はい、:visited セレクターに color を別途指定すれば変えられます。ただし :visited はセキュリティ上、色・不透明度系のプロパティしか許可されないため、この記事の transform・background を使った下線アニメーションは訪問済みかどうかに関係なく常に同じ形で動きます。
下線が改行位置によってずれて見えるのはなぜですか?
03の複数行対応下線のように box-decoration-break: clone を使わない場合、擬似要素の下線はリンク全体の最後の行の下にしか描かれないのがふつうです。行ごとに下線が必要なときは、擬似要素の代わりに背景画像方式へ切り替えて box-decoration-break: clone を加えると解決します。下線ではなく文全体を目立たせたいなら、引用ブロックのCSSデザインが引用箱としてその役を果たします。








