CSS 밑줄 애니메이션 9종 — 한 줄 복붙으로
css 밑줄 애니메이션은 마우스를 올리거나 키보드로 초점을 옮겼을 때만 링크 아래 줄이 움직이며 나타나는 효과로, 지금 가리키고 있는 링크가 어디인지 눈으로 바로 알려줍니다. 아홉 가지를 모았고 전부 한 줄 복붙으로 그대로 붙여 쓸 수 있습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 인기 순이 아니라 링크를 마주치는 순서대로 놓았습니다. 가장 기본이 되는 좌우 밑줄(01)에서 시작해 같은 방식의 변형(02)을 보여주고, 실제로 메뉴 글자가 줄바꿈되는 상황(03)을 짚은 뒤, 더 강한 강조(04)와 브랜드 색(05)을 더합니다. 이어서 장난스러운 물결(06)로 톤을 풀고, 삭제·완료 같은 상태 변화를 암시하는 취소선(07), 추천 링크를 표시하는 형광펜(08), 마지막으로 아직 안 읽은 링크가 확정 상태로 바뀌는 점선→실선(09)까지 이어집니다. 밑줄이 아니라 방향으로 눈을 끌고 싶다면 CSS 화살표 애니메이션이 그 신호이고, 링크가 카드에 얹혀 뒤집히는 규모라면 카드 뒤집기 애니메이션까지 갑니다.
왜 :hover 뿐 아니라 :focus-visible 도 같이 걸어야 하나요
css 밑줄 효과를 키보드에서도 보이게
마우스가 없는 키보드 사용자는 Tab 키로 링크 사이를 옮겨 다닙니다. 아홉 개 전부 :hover 옆에 :focus-visible 을 나란히 걸어, 키보드로 초점이 옮겨 왔을 때도 마우스를 올린 것과 똑같이 밑줄이 움직이게 했습니다.
갤러리 데모와 실제 페이지의 차이
갤러리에 렌더된 데모는 .is-demo 클래스가 같은 모양을 2초마다 강제로 반복해 보여줄 뿐이고, 실제 페이지에 붙이면 진짜 호버·포커스에서만 움직입니다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
들어올 때와 나갈 때
마우스를 떼면 같은 방향으로 다시 줄어들어, 들어올 때와 나갈 때가 대칭을 이룹니다.
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
양옆으로 퍼질 때
마우스를 올리면 밑줄이 중앙에서 시작해 양옆으로 동시에 퍼지며 그어집니다.
.link::after {
transform-origin: center;
transition: transform 300ms $ease-pop;
}
.link:hover::after,
.link:focus-visible::after {
transform: scaleX(1);
}
03여러 줄 대응 밑줄
두 줄로 줄바꿈되면
메뉴 글자가 길어 두 줄로 줄바꿈되면 의사요소 밑줄은 보통 마지막 줄에만 그어집니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
줄마다 따로 긋는 법
배경 그러데이션을 밑줄로 쓰고 줄마다 통째로 복제하는 속성을 더해, 두 줄로 감싸진 링크도 줄마다 따로 밑줄이 그어지게 했습니다.
.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;
}
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
글자가 밀리지 않는 이유
레이아웃을 계산하는 속성이 아니라서 주변 글자가 밀리지 않으면서 밑줄만 눈에 띄게 굵어집니다.
.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;
}
지원 범위는 MDN text-decoration-thickness 문서에 정리돼 있습니다.
05색이 흐르는 밑줄
두 배 폭 그러데이션
밑줄 막대에 두 배 폭의 그러데이션을 깔아 두면, 호버 중에만 그 그러데이션이 옆으로 흘러 색이 지나가는 것처럼 보입니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
손을 떼면
마우스를 떼면 흐름이 멈추고 밑줄도 다시 줄어듭니다.
.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물결 밑줄
물결을 직접 그린다
네이티브 물결 밑줄 대신 두 겹의 원형 그러데이션을 반복시켜 물결 모양을 직접 그렸습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
멈추지 않고 흐르게
호버하면 물결이 드러나면서 동시에 옆으로 흘러가, 멈춰 있는 물결이 아니라 계속 흐르는 물결이 됩니다.
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어떤 자리에 맞나
자동으로 반복되지 않고 오직 마우스를 올렸을 때만 나타나므로, 삭제나 완료 처리처럼 사용자가 직접 일으키는 동작을 암시하기에 알맞습니다.
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
마커 느낌은 어디서 오나
css 형광펜 밑줄 효과는 겹치는 색을 곱하는 방식으로 섞어야 실제 마커 특유의 반투명한 느낌이 납니다.
.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);
}
}
색을 곱해서 섞는 것 말고도 쓸 수 있는 다른 값들은 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어떤 뜻으로 읽히나
아직 안 읽은 링크가 확정된 상태로 바뀌는 느낌을 줍니다.
.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; }
어디서 깨지는가 — 함정 둘
반복되는 배경이 밑줄을 꽉 채웠다
아홉 개 중 하나(03 여러 줄 대응 밑줄)는 처음 만들었을 때 밑줄이 자라는 게 전혀 안 보였습니다. 밑줄을 그리는 배경 그림을 옆으로 반복시켜 뒀더니, 크기가 아주 작은 값이어도 타일이 계속 반복돼 밑줄이 시작부터 이미 꽉 차 있는 것처럼 보였습니다. 반복을 끄고 한 번만 그리게 바꾸자 각 줄의 밑줄이 실제로 0%에서 100%까지 자라는 게 렌더 측정에서도, 눈으로도 확인됐습니다.
최대 너비가 안 먹은 이유
같은 항목에서 두 번째 함정도 겹쳐 나왔습니다. 두 줄로 감싸지게 하려고 부모 요소에 최대 너비를 줬는데, 가운데 정렬 레이아웃 안에서는 그 값이 실제로 적용되지 않고 글자가 원래 폭 그대로 한 줄에 펼쳐졌습니다. 최대 너비 대신 고정된 너비 값으로 바꾸고서야 글자가 두 줄로 감싸지고 밑줄도 줄마다 따로 그려지는 걸 캡처한 이미지로 확인했습니다. 아홉 개 파일이 담긴 압축 파일의 비밀번호는 je9m2xxs이고, 문장 속에 평범하게 적어 두었으니 여기서 그대로 복사하면 됩니다. 노랑 밑줄이 하늘색 배경에서 죽는 색 대비 사고는 시트러스 컬러 랜딩 키트가 채도 짝짓기로 미리 막는 바로 그 사고입니다.
접근성
모션 줄이기에서 남는 것
OS에서 모션 줄이기를 켠 사람에게는 아홉 항목 모두 prefers-reduced-motion: reduce 안에서 전환 자체를 없앱니다. 그래도 실제 호버·포커스 상태 변화는 그대로 남겨 뒀습니다 — 01·02·03·04·07·08·09는 마우스를 올리는 순간 애니메이션 없이 곧바로 최종 모습(밑줄이 그어진, 두꺼워진, 형광펜이 칠해진 상태)으로 바뀌고, 05·06은 색이 흐르거나 물결이 움직이는 동작만 꺼지고 밑줄·물결이 드러나는 것 자체는 남습니다. prefers-reduced-motion 문서에 어느 브라우저부터 지원하는지 정리돼 있습니다.
다른 효과는 어디서 찾나
링크 밑줄 말고 다른 CSS 애니메이션은 텍스트 강조 애니메이션 9종이나 버튼 애니메이션 9종에서 더 찾을 수 있습니다.
FAQ
아홉 개를 한 페이지에 같이 써도 되나요?
가능하지만 같은 화면 안에서는 내비게이션엔 하나, 본문 인라인 링크엔 하나처럼 자리별로 한 가지만 쓰는 게 좋습니다. 여러 밑줄 효과가 뒤섞이면 어떤 게 지금 가리키는 링크인지 오히려 헷갈립니다.
방문한 링크는 css 밑줄 색상을 따로 줄 수 있나요?
네, :visited 선택자에 color 값을 따로 주면 됩니다. 다만 :visited 에서는 보안상 색·투명도 계열 속성만 허용되므로, 이 글의 transform·background 기반 밑줄 애니메이션은 방문 여부와 상관없이 항상 같은 모양으로 동작합니다.
밑줄이 텍스트 줄바꿈 위치에 따라 어긋나 보이는 이유는 뭔가요?
03 여러 줄 대응 밑줄처럼 box-decoration-break: clone 을 쓰지 않았다면 의사요소 밑줄은 보통 링크 전체의 마지막 줄 아래에만 그려집니다. 줄마다 밑줄이 필요하면 의사요소 대신 배경 이미지 방식으로 바꾸고 box-decoration-break: clone 을 더하면 해결됩니다. 밑줄이 아니라 문장을 통째로 도드라지게 하려면 인용구 디자인 CSS가 인용 박스로 그 일을 합니다.








