CSS 下線 アニメーション9選 — 一行でコピペOK
css 下線 アニメーションは、単語や文の下・背後に敷いた線や面を動かして、読む人の視線をその一語に止める技法です。今回はこの技法を9つ選び、それぞれ6〜25行のコードにまとめました。上のグリッドは表示された瞬間から自動で繰り返し動いていて、触っても反応は変わりません。
01 text-highlight-underline-highlight-marker
02 text-highlight-underline-marker-overlap
03 text-highlight-underline-handwritten-underline
04 text-highlight-underline-underline-wave
05 text-highlight-underline-strike-draw
06 text-highlight-underline-bg-fill-hover
07 text-highlight-underline-dotted-grow
08 text-highlight-underline-color-sweep
09 text-highlight-underline-text-shadow-pop
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9つは「css ホバー アニメーション 下線」のような効果の強さではなく、文中で何を伝えるための強調かという順に並べました。まず文の一部を塗って目立たせる3つ(01〜03)、次に注意や完了を伝える2つ(04〜05)、続いてリンクや案内線で先へ誘う2つ(06〜07)、最後にバッジや数字を一度だけ弾ませる2つ(08〜09)という流れです。各項目のコードは要点だけを載せていて、残りはzipの中にまとめてあります。同じ強調をユーティリティクラスへ移す手順はTailwindのアニメーションユーティリティが示し、褒めるためではなくエラーを知らせる動きが要る場面では揺れるアニメーションが同じ発想の裏側になります。
01マーカー強調
「css マーカー アニメーション」の塗り方
キーワードの背後に黄色いブロックを重ね、transform: scaleX(0→1)で左から塗り広げたあと、mix-blend-mode: multiplyで文字の上に馴染ませます。同じ色のノイズ層を上下の縁にジグザグで一枚重ね、ペンの太さが端々で不揃いに揺れるストロークに見せています。規約や案内文の中で必ず読んでほしい一文、チュートリアルの強調語に向いています。

<main class="stage">
<p class="line">返金は営業日ベースで <span class="hl">7日以内に</span> 処理されます。</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────
$duration: $dur-loop; // 형광펜이 한 번 칠해졌다 지워지는 한 주기
$easing: $ease-spring; // 칠할 때는 빠르게 시작해 부드럽게 멈춘다
$color: $stage-yellow; // 형광펜 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
// ── 문장: 키워드 뒤에 형광펜 블록을 겹친다 ─────────────
.line {
max-width: 92%;
margin: 0;
color: $stage-ink;
font-weight: 600;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1.6;
font-family: inherit;
text-align: center;
}
.hl {
position: relative;
isolation: isolate; // z-index:-1 을 이 안에 가둬 무대 배경 뒤로 숨지 않게 한다
padding: 0 $sp-1;
// 형광펜 블록: 글자 뒤(z-index -1)에서 왼쪽부터 차오른다
&::before {
content: "";
position: absolute;
inset: 12% -2% 6%;
background: $color;
border-radius: $r-xs;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 형광펜 왼쪽에서 칠함
mix-blend-mode: multiply; // 종이 위에 형광펜을 겹친 것처럼 밑 색과 섞인다
z-index: -1;
animation: mark $duration $easing infinite, mark-fill-noise $duration ease-in-out infinite;
}
// 한 겹: 노이즈 가장자리 레이어 — 같은 색을 낮은 불투명도로 위·아래 미세 지그재그
// 가장자리에 깔아 ::before 보다 0.08s 늦게 같은 scaleX 로 펴진다. 두 겹의 시차와
// 지그재그가 겹쳐 펜 두께가 불규칙하게 요동치는 스트로크로 보인다
&::after {
content: "";
position: absolute;
inset: 10.5% -2.2% 4.5%;
background:
repeating-linear-gradient(88deg, $color 0 4px, transparent 4px 5.5px) top / 100% 38% no-repeat,
repeating-linear-gradient(92deg, $color 0 4px, transparent 4px 6px) bottom / 100% 38% no-repeat;
opacity: .38;
transform: scaleX(0);
transform-origin: left center; // 규칙8: ::before 와 같은 방향, 0.08s 위상 차
mix-blend-mode: multiply;
z-index: -1;
rotate: .2deg; // rotate 는 개별 속성 — mark 의 transform(scaleX) 과 충돌 없음
animation: mark $duration $easing infinite .08s, stroke-wobble 1.3s ease-in-out infinite;
}
}
@keyframes mark {
0% { transform: scaleX(0); }
20% { transform: scaleX(1); }
75% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
// scaleX 확장 중 border-radius 가 출렁인다 — 균일한 사각 블록이 아니게 (transform 아님)
@keyframes mark-fill-noise {
0% { border-radius: 6%; }
30% { border-radius: 20%; }
60% { border-radius: 10%; }
85% { border-radius: 16%; }
100% { border-radius: 6%; }
}
// 노이즈 레이어의 미세 각도 진동 — rotate 개별 속성이라 mark 의 transform 과 병렬 가능
@keyframes stroke-wobble {
0% { rotate: .3deg; }
25% { rotate: -.4deg; }
50% { rotate: .2deg; }
75% { rotate: -.25deg; }
100% { rotate: .3deg; }
}
// ── 접근성: 움직임 줄이기 설정이면 처음부터 칠해진 채로 고정 ──
@media (prefers-reduced-motion: reduce) {
.hl::before { animation: none; transform: scaleX(1); }
.hl::after { animation: none; transform: scaleX(1); rotate: 0deg; }
}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);
}
.line {
max-width: 92%;
margin: 0;
color: #17141a;
font-weight: 600;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1.6;
font-family: inherit;
text-align: center;
}
.hl {
position: relative;
isolation: isolate;
padding: 0 4px;
}
.hl::before {
content: "";
position: absolute;
inset: 12% -2% 6%;
background: #ffd23f;
border-radius: 4px;
transform: scaleX(0);
transform-origin: left center;
mix-blend-mode: multiply;
z-index: -1;
animation: mark 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, mark-fill-noise 2s ease-in-out infinite;
}
.hl::after {
content: "";
position: absolute;
inset: 10.5% -2.2% 4.5%;
background: repeating-linear-gradient(88deg, #ffd23f 0 4px, transparent 4px 5.5px) top/100% 38% no-repeat, repeating-linear-gradient(92deg, #ffd23f 0 4px, transparent 4px 6px) bottom/100% 38% no-repeat;
opacity: 0.38;
transform: scaleX(0);
transform-origin: left center;
mix-blend-mode: multiply;
z-index: -1;
rotate: 0.2deg;
animation: mark 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite 0.08s, stroke-wobble 1.3s ease-in-out infinite;
}
@keyframes mark {
0% {
transform: scaleX(0);
}
20% {
transform: scaleX(1);
}
75% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
@keyframes mark-fill-noise {
0% {
border-radius: 6%;
}
30% {
border-radius: 20%;
}
60% {
border-radius: 10%;
}
85% {
border-radius: 16%;
}
100% {
border-radius: 6%;
}
}
@keyframes stroke-wobble {
0% {
rotate: 0.3deg;
}
25% {
rotate: -0.4deg;
}
50% {
rotate: 0.2deg;
}
75% {
rotate: -0.25deg;
}
100% {
rotate: 0.3deg;
}
}
@media (prefers-reduced-motion: reduce) {
.hl::before {
animation: none;
transform: scaleX(1);
}
.hl::after {
animation: none;
transform: scaleX(1);
rotate: 0deg;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./HighlightMarker.module.scss";
export type HighlightMarkerProps = {
/** 강조어 앞 글자 */
before: ReactNode;
/** 형광펜이 칠해지는 단어 */
keyword: ReactNode;
/** 강조어 뒤 글자 */
after?: ReactNode;
/** 칠해지는 데 걸리는 시간 (기본 2000ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 형광펜 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function HighlightMarker({
before,
keyword,
after,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ffd23f",
}: HighlightMarkerProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<p className={styles.line}>
{before} <span className={styles.hl} style={vars}>{keyword}</span> {after}
</p>
);
}01 · マーカー強調 — 計測 累積面積 1.458% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
規約や案内文の一文に
.hl {
position: relative;
isolation: isolate;
&::before {
content: ""; position: absolute; inset: 12% -2% 6%;
background: $color; border-radius: $r-xs;
transform: scaleX(0); transform-origin: left center;
mix-blend-mode: multiply; z-index: -1;
animation: mark $duration $easing infinite;
}
}
@keyframes mark {
0% { transform: scaleX(0); }
20% { transform: scaleX(1); }
75% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
02マーカー重ね
2枚を違う角度と遅延で重ねる
同じ黄色ブロックを::beforeと::afterの2枚に分け、それぞれ違うrotate角度と遅延でscaleXを重ねて塗り、手で二度なぞったようなずれた太さを作ります。それぞれの縁が位相をずらして上下に膨らむので、重なった輪郭が静止せず生きたまま動き続けます。ページの中で最も重要な一語や、FAQの答えのキーワードに向いています。

<main class="stage">
<p class="line">このプランの核心は <span class="hl">無制限のストレージ</span>です。</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue; // 두 번째 스트로크 색 (첫 번째는 무대 노랑)
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.line {
max-width: 92%;
margin: 0;
color: $stage-ink;
font-weight: 700;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1.6;
font-family: inherit;
text-align: center;
}
// 같은 자리에 두 겹의 마커 스트로크를 서로 다른 각도로 겹쳐 칠한다 (018 체크 게이트와 무관 — 여긴 체크 아이콘이 아니다)
.hl {
position: relative;
isolation: isolate; // z-index:-1 을 이 안에 가둬 무대 배경 뒤로 숨지 않게 한다
padding: 0 $sp-1;
&::before, &::after {
content: "";
position: absolute;
inset: 10% -2% 4%;
border-radius: $r-xs;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 마커 겹칠 왼쪽 칠함
mix-blend-mode: multiply;
z-index: -1;
}
&::before {
background: $stage-yellow;
rotate: -1.2deg;
animation: mark $duration $easing infinite, mark-thick-1 $duration ease-in-out infinite;
}
&::after {
background: $color;
opacity: .55;
rotate: 1deg;
animation: mark $duration $easing infinite .3s, mark-thick-2 $duration ease-in-out infinite .15s;
}
}
@keyframes mark {
0% { transform: scaleX(0); }
20% { transform: scaleX(1); }
75% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
// 한 겹: 각 스트로크의 위·아래 두께 요동 — inset(top/bottom) 변조라 transform 아님.
// 두 스트로크가 서로 다른 위상으로 출렁여 겹침 가장자리가 살아 움직인다
@keyframes mark-thick-1 {
0% { inset: 10% -2% 4%; }
25% { inset: 8% -2% 5.5%; }
50% { inset: 11.5% -2% 3%; }
75% { inset: 9% -2% 6%; }
100% { inset: 10% -2% 4%; }
}
@keyframes mark-thick-2 {
0% { inset: 10% -2% 4%; }
30% { inset: 12% -2% 2.5%; }
55% { inset: 7.5% -2% 6.5%; }
80% { inset: 11% -2% 3.5%; }
100% { inset: 10% -2% 4%; }
}
@media (prefers-reduced-motion: reduce) {
.hl::before, .hl::after { animation: none; 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);
}
.line {
max-width: 92%;
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1.6;
font-family: inherit;
text-align: center;
}
.hl {
position: relative;
isolation: isolate;
padding: 0 4px;
}
.hl::before, .hl::after {
content: "";
position: absolute;
inset: 10% -2% 4%;
border-radius: 4px;
transform: scaleX(0);
transform-origin: left center;
mix-blend-mode: multiply;
z-index: -1;
}
.hl::before {
background: #ffd23f;
rotate: -1.2deg;
animation: mark 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, mark-thick-1 2s ease-in-out infinite;
}
.hl::after {
background: #2f6df6;
opacity: 0.55;
rotate: 1deg;
animation: mark 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite 0.3s, mark-thick-2 2s ease-in-out infinite 0.15s;
}
@keyframes mark {
0% {
transform: scaleX(0);
}
20% {
transform: scaleX(1);
}
75% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
@keyframes mark-thick-1 {
0% {
inset: 10% -2% 4%;
}
25% {
inset: 8% -2% 5.5%;
}
50% {
inset: 11.5% -2% 3%;
}
75% {
inset: 9% -2% 6%;
}
100% {
inset: 10% -2% 4%;
}
}
@keyframes mark-thick-2 {
0% {
inset: 10% -2% 4%;
}
30% {
inset: 12% -2% 2.5%;
}
55% {
inset: 7.5% -2% 6.5%;
}
80% {
inset: 11% -2% 3.5%;
}
100% {
inset: 10% -2% 4%;
}
}
@media (prefers-reduced-motion: reduce) {
.hl::before, .hl::after {
animation: none;
transform: scaleX(1);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./MarkerOverlap.module.scss";
export type MarkerOverlapProps = {
before: ReactNode;
keyword: ReactNode;
after?: ReactNode;
duration?: number;
easing?: string;
/** 두 번째(위) 스트로크 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function MarkerOverlap({
before,
keyword,
after,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MarkerOverlapProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<p className={styles.line}>
{before} <span className={styles.hl} style={vars}>{keyword}</span>{after}
</p>
);
}02 · マーカー重ね — 計測 累積面積 1.945% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ページで最も重要な一語に
.hl::before, .hl::after {
content: ""; position: absolute; inset: 10% -2% 4%;
transform: scaleX(0); transform-origin: left center;
mix-blend-mode: multiply; z-index: -1;
}
.hl::before { background: $stage-yellow; rotate: -1.2deg; animation: mark $duration $easing infinite; }
.hl::after { background: $color; opacity: .55; rotate: 1deg; animation: mark $duration $easing infinite .3s; }
03手描き下線
stroke-dashoffsetで左から引く
インラインSVGの曲線パスをstroke-dashoffsetで140から0へ動かし、ペンで今しがた引いたように左から下線が現れます。引いている間、stroke-widthが3pxと4.5pxの間を行き来して、本物のペン圧のように太さが増減します。引用文中のキーワードや、自己紹介ページの強調語に向いています。

<main class="stage">
<p class="quote">
“<span class="hl">継続</span>が実力に勝る”
<svg class="hl__svg" viewBox="0 0 120 20" preserveAspectRatio="none" aria-hidden="true">
<path class="hl__path" d="M2 12 C 20 18, 45 6, 62 11 S 100 17, 118 9" />
</svg>
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: 600ms; // 손으로 한 획을 긋는 데 걸리는 시간
$easing: $ease-out;
$color: $subject-blue; // 밑줄 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.quote {
position: relative;
display: inline-block;
margin: 0;
color: $subject-cream;
font-weight: 600;
font-size: clamp(17px, 4.8vw, 23px);
line-height: 1.7;
font-family: inherit;
text-align: center;
}
.hl {
position: relative;
color: $subject-cream;
}
// 인용구 바로 아래, 키워드 폭만큼 걸쳐지는 SVG 밑줄 — 살짝 삐뚤한 손글씨 곡선
.hl__svg {
position: absolute;
left: 6%;
right: 46%; // "꾸준함" 위치에 맞춘 대략적 폭 (데모 전용 좌표)
bottom: -6px;
height: 16px;
overflow: visible;
}
.hl__path {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 140;
stroke-dashoffset: 140;
animation: draw $duration $easing infinite, draw-thick $duration ease-in-out infinite;
animation-delay: .2s;
}
@keyframes draw {
0% { stroke-dashoffset: 140; }
35% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -140; }
}
// 한 겹: 그어지는 동안 펜 압력처럼 stroke-width 가 요동 — stroke-width 는 transform 이
// 아니라 draw(stroke-dashoffset) 와 병렬로 돌아도 서로 덮지 않는다
@keyframes draw-thick {
0% { stroke-width: 3px; }
25% { stroke-width: 4.5px; }
45% { stroke-width: 3.5px; }
65% { stroke-width: 4px; }
85% { stroke-width: 3.2px; }
100% { stroke-width: 3px; }
}
@media (prefers-reduced-motion: reduce) {
.hl__path { animation: none; stroke-dashoffset: 0; }
}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);
}
.quote {
position: relative;
display: inline-block;
margin: 0;
color: #fff7e6;
font-weight: 600;
font-size: clamp(17px, 4.8vw, 23px);
line-height: 1.7;
font-family: inherit;
text-align: center;
}
.hl {
position: relative;
color: #fff7e6;
}
.hl__svg {
position: absolute;
left: 6%;
right: 46%;
bottom: -6px;
height: 16px;
overflow: visible;
}
.hl__path {
fill: none;
stroke: #2f6df6;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 140;
stroke-dashoffset: 140;
animation: draw 600ms ease-out infinite, draw-thick 600ms ease-in-out infinite;
animation-delay: 0.2s;
}
@keyframes draw {
0% {
stroke-dashoffset: 140;
}
35% {
stroke-dashoffset: 0;
}
80% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -140;
}
}
@keyframes draw-thick {
0% {
stroke-width: 3px;
}
25% {
stroke-width: 4.5px;
}
45% {
stroke-width: 3.5px;
}
65% {
stroke-width: 4px;
}
85% {
stroke-width: 3.2px;
}
100% {
stroke-width: 3px;
}
}
@media (prefers-reduced-motion: reduce) {
.hl__path {
animation: none;
stroke-dashoffset: 0;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./HandwrittenUnderline.module.scss";
export type HandwrittenUnderlineProps = {
/** 밑줄이 걸리는 강조어 */
keyword: ReactNode;
/** 강조어 뒤 문장 */
after?: ReactNode;
/** 한 획을 긋는 데 걸리는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 밑줄 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function HandwrittenUnderline({
keyword,
after,
duration = 600,
easing = "ease-out",
color = "#2f6df6",
}: HandwrittenUnderlineProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<p className={styles.quote} style={vars}>
“<span className={styles.hl}>{keyword}</span>{after}
<svg className={styles.svg} viewBox="0 0 120 20" preserveAspectRatio="none" aria-hidden="true">
<path className={styles.path} d="M2 12 C 20 18, 45 6, 62 11 S 100 17, 118 9" />
</svg>
</p>
);
}03 · 手描き下線 — 計測 累積面積 0.301% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
引用文のキーワードに
.hl__path {
fill: none; stroke: $color; stroke-width: 3; stroke-linecap: round;
stroke-dasharray: 140; stroke-dashoffset: 140;
animation: draw $duration $easing infinite;
}
@keyframes draw {
0% { stroke-dashoffset: 140; }
35% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -140; }
}
04波線下線
@propertyで揺らす値
ネイティブのtext-decoration-style: wavyはそのまま使い、@propertyで登録した長さの値をtext-underline-offsetに入れて0pxと5pxの間で揺らします。波の合間には太さも2pxから3pxへ揺れ、線全体が筆で押したように不揃いの濃さを持ちます。入力必須の合図や、スペルチェック風に注意を引きたい警告文に向いています。

<main class="stage">
<p class="line">この項目は <span class="hl">必須</span> 入力です。</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: 1s; // 물결이 한 번 오가는 시간
$easing: $ease-out;
$color: $error; // 경고 색 밑줄
// @property 로 <length> 타입을 등록해야 브라우저가 이 값을 보간(애니메이션)한다
@property --underline-offset {
syntax: "<length>";
inherits: true;
initial-value: 1px;
}
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.line {
max-width: 92%;
margin: 0;
color: $subject-ink; // 주황 무대 위 글자 — AA 4.5 (blog-design 글자 대비)
font-weight: 600;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1.6;
font-family: inherit;
text-align: center;
}
// 네이티브 물결 밑줄 — text-decoration-style: wavy 가 곡선을 그리고,
// text-underline-offset 만 --underline-offset 을 통해 오간다
.hl {
text-decoration-line: underline;
text-decoration-style: wavy;
text-decoration-color: $color;
text-decoration-thickness: 2px;
text-underline-offset: var(--underline-offset);
animation: wave $duration linear infinite;
}
// 한 겹: 펜 두께 불균일 — thickness 진동을 wave 안에 싣는다(같은 요소라 별도 키프레임
// 대신 기존 0/50/100% 스텝은 유지하고 사이 스텝에 thickness 요동을 추가)
@keyframes wave {
0% { --underline-offset: 0px; text-decoration-thickness: 2px; text-decoration-color: $color; }
18% { --underline-offset: 1.8px; text-decoration-thickness: 3px; }
34% { --underline-offset: 3.4px; text-decoration-thickness: 2.2px; }
50% { --underline-offset: 5px; text-decoration-thickness: 2.6px; text-decoration-color: color-mix(in srgb, $color 55%, white); }
66% { --underline-offset: 3.6px; text-decoration-thickness: 3.2px; }
82% { --underline-offset: 1.6px; text-decoration-thickness: 2px; }
100% { --underline-offset: 0px; text-decoration-thickness: 2px; text-decoration-color: $color; }
}
@media (prefers-reduced-motion: reduce) {
.hl { animation: none; text-underline-offset: 2px; }
}@property --underline-offset {
syntax: "<length>";
inherits: true;
initial-value: 1px;
}
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);
}
.line {
max-width: 92%;
margin: 0;
color: #17141a;
font-weight: 600;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1.6;
font-family: inherit;
text-align: center;
}
.hl {
text-decoration-line: underline;
text-decoration-style: wavy;
text-decoration-color: #c2361a;
text-decoration-thickness: 2px;
text-underline-offset: var(--underline-offset);
animation: wave 1s linear infinite;
}
@keyframes wave {
0% {
--underline-offset: 0px;
text-decoration-thickness: 2px;
text-decoration-color: #c2361a;
}
18% {
--underline-offset: 1.8px;
text-decoration-thickness: 3px;
}
34% {
--underline-offset: 3.4px;
text-decoration-thickness: 2.2px;
}
50% {
--underline-offset: 5px;
text-decoration-thickness: 2.6px;
text-decoration-color: color-mix(in srgb, #c2361a 55%, white);
}
66% {
--underline-offset: 3.6px;
text-decoration-thickness: 3.2px;
}
82% {
--underline-offset: 1.6px;
text-decoration-thickness: 2px;
}
100% {
--underline-offset: 0px;
text-decoration-thickness: 2px;
text-decoration-color: #c2361a;
}
}
@media (prefers-reduced-motion: reduce) {
.hl {
animation: none;
text-underline-offset: 2px;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./WavyUnderline.module.scss";
export type WavyUnderlineProps = {
before: ReactNode;
keyword: ReactNode;
after?: ReactNode;
/** 한 번 오가는 시간 (기본 1000ms) */
duration?: number;
/** 밑줄 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--color"?: string;
};
export default function WavyUnderline({
before,
keyword,
after,
duration = 1000,
color = "#c2361a",
}: WavyUnderlineProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--color": color,
};
return (
<p className={styles.line} style={vars}>
{before} <span className={styles.hl}>{keyword}</span> {after}
</p>
);
}04 · 波線下線 — 計測 累積面積 0.284% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
入力必須の合図に
@property --underline-offset {
syntax: "<length>"; inherits: true; initial-value: 0px;
}
.hl {
text-decoration-line: underline;
text-decoration-style: wavy;
text-decoration-color: $color;
text-underline-offset: var(--underline-offset);
animation: wave $duration linear infinite;
}
@keyframes wave {
0%, 100% { --underline-offset: 0px; }
50% { --underline-offset: 5px; }
}
05取り消し線アニメーション
中央の高さに重ねたバー
文字の中央の高さに重ねた.price__strikeのバーをtransform: scaleX(0→1)で左から引き、値下げや完了処理の瞬間を目の前で見せます。バーの高さは1.5pxと3pxの間で揺れ、一度に引くのではなく二三度力を込めて引くような手触りを出します。割引前の価格表示や、片付いたToDo項目に向いています。

<main class="stage">
<p class="price">
<span class="price__old">39,000ウォン<span class="price__strike" aria-hidden="true"></span></span>
<span class="price__new">29,000ウォン</span>
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: 500ms;
$easing: $ease-spring;
$color: $error; // 취소선 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.price {
display: flex;
align-items: baseline;
gap: $sp-3;
margin: 0;
font-weight: 700;
font-size: clamp(18px, 5vw, 24px);
line-height: 1;
font-family: inherit;
}
.price__old {
position: relative;
color: color-mix(in srgb, $stage-ink 55%, transparent);
font-weight: 500;
}
// 글자 중간 높이에 놓인 가로선을 왼쪽부터 그어 취소선을 만든다
.price__strike {
position: absolute;
left: -2%;
right: -2%;
top: 52%;
height: 2px;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 취소선 왼쪽에서 그어짐
animation: strike $duration $easing infinite, strike-thick $duration ease-in-out infinite;
}
.price__new {
color: $stage-ink;
}
@keyframes strike {
0% { transform: scaleX(0); }
30% { transform: scaleX(1); }
80% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
// 한 겹: 취소선 두께 요동 — height 변조(transform 아님)라 strike 의 scaleX 와 병렬 가능.
// rotate 진동은 transform 충돌이라 생략하고 height 로만 펜 압력을 표현한다
@keyframes strike-thick {
0% { height: 2px; }
25% { height: 3px; }
50% { height: 1.5px; }
75% { height: 2.5px; }
100% { height: 2px; }
}
@media (prefers-reduced-motion: reduce) {
.price__strike { animation: none; 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.price {
display: flex;
align-items: baseline;
gap: 12px;
margin: 0;
font-weight: 700;
font-size: clamp(18px, 5vw, 24px);
line-height: 1;
font-family: inherit;
}
.price__old {
position: relative;
color: color-mix(in srgb, #17141a 55%, transparent);
font-weight: 500;
}
.price__strike {
position: absolute;
left: -2%;
right: -2%;
top: 52%;
height: 2px;
background: #c2361a;
transform: scaleX(0);
transform-origin: left center;
animation: strike 500ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite, strike-thick 500ms ease-in-out infinite;
}
.price__new {
color: #17141a;
}
@keyframes strike {
0% {
transform: scaleX(0);
}
30% {
transform: scaleX(1);
}
80% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
@keyframes strike-thick {
0% {
height: 2px;
}
25% {
height: 3px;
}
50% {
height: 1.5px;
}
75% {
height: 2.5px;
}
100% {
height: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.price__strike {
animation: none;
transform: scaleX(1);
}
}import type { CSSProperties } from "react";
import styles from "./StrikeDraw.module.scss";
export type StrikeDrawProps = {
/** 취소선이 그어지는 이전 가격 */
oldPrice: string;
/** 강조되는 새 가격 */
newPrice: string;
/** 선이 그어지는 시간 (기본 500ms) */
duration?: number;
easing?: string;
/** 취소선 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function StrikeDraw({
oldPrice,
newPrice,
duration = 500,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#c2361a",
}: StrikeDrawProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<p className={styles.price} style={vars}>
<span className={styles.old}>
{oldPrice}
<span className={styles.strike} aria-hidden="true" />
</span>
<span className={styles.new}>{newPrice}</span>
</p>
);
}05 · 取り消し線アニメーション — 計測 累積面積 0.204% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
割引前の価格表示に
.price__strike {
position: absolute; left: -2%; right: -2%; top: 52%; height: 2px;
background: $color;
transform: scaleX(0); transform-origin: left center;
animation: strike $duration $easing infinite;
}
@keyframes strike {
0% { transform: scaleX(0); }
30% { transform: scaleX(1); }
80% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
06背景塗りつぶし
background-sizeだけで満たす
「css リンク 下線 アニメーション」の代わりに、リンクの背景をbackground-sizeだけで0%から100%まで満たし、background-position: leftで固定して塗られた縁が後戻りしないようにします。満ちていく間、縦の大きさも88%から104%まで不揃いに波打ち、絵の具がにじむように縁が動きます。ナビゲーションのリンクや、カード内のテキストボタンに向いています。

<main class="stage">
<a class="nav" href="#">詳しく見る</a>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue; // 채워지는 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.nav {
display: inline-block;
padding: $sp-2 $sp-4;
border-radius: $r-sm;
color: $subject-cream;
text-decoration: none;
font-weight: 700;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1;
font-family: inherit;
// 배경만 왼쪽부터 차오른다. 위치는 left 로 고정해 채워진 가장자리가 밀리지 않는다
background: linear-gradient(90deg, $color, $color);
background-repeat: no-repeat;
background-position: left center;
background-size: 0% 100%;
animation: fill-thickness $duration $easing infinite;
}
@keyframes fill {
0% { background-size: 0% 100%; }
25% { background-size: 100% 100%; }
75% { background-size: 100% 100%; }
100% { background-size: 0% 100%; }
}
// 페인트 물성: 채워지는 동안 수직 두께(펜 압력)가 불규칙 요동친다 — 이중 background-size 값 보간
@keyframes fill-thickness {
0% { background-size: 0% 100%; }
6% { background-size: 22% 92%; }
12% { background-size: 44% 96%; }
18% { background-size: 68% 88%; }
25% { background-size: 100% 104%; }
32% { background-size: 100% 96%; }
45% { background-size: 100% 101%; }
60% { background-size: 100% 94%; }
75% { background-size: 100% 102%; }
82% { background-size: 74% 97%; }
90% { background-size: 38% 93%; }
100% { background-size: 0% 100%; }
}
@media (prefers-reduced-motion: reduce) {
.nav { animation: none; background-size: 100% 100%; }
}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);
}
.nav {
display: inline-block;
padding: 8px 16px;
border-radius: 8px;
color: #fff7e6;
text-decoration: none;
font-weight: 700;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1;
font-family: inherit;
background: linear-gradient(90deg, #2f6df6, #2f6df6);
background-repeat: no-repeat;
background-position: left center;
background-size: 0% 100%;
animation: fill-thickness 2s ease-out infinite;
}
@keyframes fill {
0% {
background-size: 0% 100%;
}
25% {
background-size: 100% 100%;
}
75% {
background-size: 100% 100%;
}
100% {
background-size: 0% 100%;
}
}
@keyframes fill-thickness {
0% {
background-size: 0% 100%;
}
6% {
background-size: 22% 92%;
}
12% {
background-size: 44% 96%;
}
18% {
background-size: 68% 88%;
}
25% {
background-size: 100% 104%;
}
32% {
background-size: 100% 96%;
}
45% {
background-size: 100% 101%;
}
60% {
background-size: 100% 94%;
}
75% {
background-size: 100% 102%;
}
82% {
background-size: 74% 97%;
}
90% {
background-size: 38% 93%;
}
100% {
background-size: 0% 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.nav {
animation: none;
background-size: 100% 100%;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./BgFillLink.module.scss";
export type BgFillLinkProps = {
children: ReactNode;
href?: string;
/** 배경이 다 차는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 채워지는 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function BgFillLink({
children,
href = "#",
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: BgFillLinkProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<a className={styles.nav} href={href} style={vars}>
{children}
</a>
);
}06 · 背景塗りつぶし — 計測 累積面積 3.281% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
縁が後戻りしない理由
.nav {
background: linear-gradient(90deg, $color, $color);
background-repeat: no-repeat;
background-position: left center;
background-size: 0% 100%;
animation: fill $duration $easing infinite;
}
@keyframes fill {
0% { background-size: 0% 100%; }
25% { background-size: 100% 100%; }
75% { background-size: 100% 100%; }
100% { background-size: 0% 100%; }
}
07点線が伸びる
点の帯をscaleXで伸ばす
点を一定間隔で描いた細い帯を文字の下に置き、transform: scaleXで左から伸ばして点線が継ぎ足されるように見せます。帯の高さは2.5pxから4pxまで伸びるリズムと同じ拍子で震え、点も一緒に太くなったり細くなったりします。段階ガイドで次の手順へ導く経路や、脚注をつなぐ線に向いています。

<main class="stage">
<p class="line">詳しくは <span class="hl">ヘルプドキュメント<span class="hl__dots" aria-hidden="true"></span></span>をご確認ください。</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: 700ms;
$easing: $ease-out;
$color: $stage-ink; // 점선 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.line {
max-width: 92%;
margin: 0;
color: $stage-ink;
font-weight: 600;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1.6;
font-family: inherit;
text-align: center;
}
.hl {
position: relative;
font-weight: 700;
}
// 일정 간격의 점을 그린 얇은 막대를 글자 아래 두고 왼쪽부터 늘린다
.hl__dots {
position: absolute;
left: 0;
right: 0;
bottom: -4px;
height: 3px;
background-image: repeating-linear-gradient(90deg, $color 0 3px, transparent 3px 8px);
transform: scaleX(0);
transform-origin: left center; // 규칙8: 점선 왼쪽에서 늘어남
animation: grow $duration $easing infinite, grow-thick $duration $easing infinite;
}
@keyframes grow {
0% { transform: scaleX(0); }
40% { transform: scaleX(1); }
80% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
// 페인트 물성: 점선 두께(펜 압력)가 늘어나는 주기와 같은 리듬으로 불규칙 진동 — height 는 transform 아님
@keyframes grow-thick {
0% { height: 3px; }
15% { height: 4px; }
30% { height: 2.5px; }
45% { height: 3.5px; }
60% { height: 2.5px; }
75% { height: 4px; }
90% { height: 3px; }
100% { height: 3px; }
}
@media (prefers-reduced-motion: reduce) {
.hl__dots { animation: none; 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);
}
.line {
max-width: 92%;
margin: 0;
color: #17141a;
font-weight: 600;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1.6;
font-family: inherit;
text-align: center;
}
.hl {
position: relative;
font-weight: 700;
}
.hl__dots {
position: absolute;
left: 0;
right: 0;
bottom: -4px;
height: 3px;
background-image: repeating-linear-gradient(90deg, #17141a 0 3px, transparent 3px 8px);
transform: scaleX(0);
transform-origin: left center;
animation: grow 700ms ease-out infinite, grow-thick 700ms ease-out infinite;
}
@keyframes grow {
0% {
transform: scaleX(0);
}
40% {
transform: scaleX(1);
}
80% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
@keyframes grow-thick {
0% {
height: 3px;
}
15% {
height: 4px;
}
30% {
height: 2.5px;
}
45% {
height: 3.5px;
}
60% {
height: 2.5px;
}
75% {
height: 4px;
}
90% {
height: 3px;
}
100% {
height: 3px;
}
}
@media (prefers-reduced-motion: reduce) {
.hl__dots {
animation: none;
transform: scaleX(1);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./DottedGrow.module.scss";
export type DottedGrowProps = {
before: ReactNode;
keyword: ReactNode;
after?: ReactNode;
/** 점선이 다 자라는 시간 (기본 700ms) */
duration?: number;
easing?: string;
/** 점선 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function DottedGrow({
before,
keyword,
after,
duration = 700,
easing = "ease-out",
color = "#17141a",
}: DottedGrowProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<p className={styles.line} style={vars}>
{before} <span className={styles.hl}>{keyword}<span className={styles.dots} aria-hidden="true" /></span>{after}
</p>
);
}07 · 点線が伸びる — 計測 累積面積 0.312% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
次の手順へ導く経路に
.hl__dots {
background-image: repeating-linear-gradient(90deg, $color 0 3px, transparent 3px 8px);
transform: scaleX(0); transform-origin: left center;
animation: grow $duration $easing infinite;
}
@keyframes grow {
0% { transform: scaleX(0); }
40% { transform: scaleX(1); }
80% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
08色のスイープ
幅250%の斜めグラデーション
バッジの上に幅250%の斜めグラデーション層を敷き、background-positionを150%から-150%へlinear・infiniteで動かして光の帯を継ぎ目なく走らせます。帯の縦の厚みも左右に1px前後ずつ押し流され、ガラスではなく濡れた絵の具の上を渡るような筋になります。プロモーションバッジや、新着・限定を示すラベルに向いています。

<main class="stage">
<span class="badge">期間限定セール</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: $dur-loop;
$easing: linear; // 이음새 없이 계속 도는 스윕이라 등속이 맞다
$color: $subject-cream; // 빛 줄기 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.badge {
position: relative;
overflow: hidden;
display: inline-block;
padding: $sp-2 $sp-5;
border-radius: $r-pill;
background: color-mix(in srgb, $stage-ink 30%, $stage-orange);
color: $subject-cream;
font-weight: 700;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1;
font-family: inherit;
// 대각선 그러데이션을 폭 250% 로 깔고 background-position 을 끝없이 옮긴다 (linear·infinite → 이음새 없음)
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(115deg, transparent 30%, $color 48%, transparent 66%);
background-size: 250% 100%;
mix-blend-mode: overlay;
animation: sweep $duration $easing infinite, sweep-thick $duration $easing infinite;
}
}
@keyframes sweep {
0% { background-position: 150% 0; }
100% { background-position: -150% 0; }
}
// 페인트 물성: 스윕 줄기의 수직 두께가 불규칙 요동 — inset 진동 (transform 아님, 병렬 안전)
@keyframes sweep-thick {
0% { inset: 0 -1px; }
14% { inset: 1px 0; }
28% { inset: -1px 1px; }
42% { inset: 0.5px -0.5px; }
57% { inset: -0.5px 1px; }
71% { inset: 1px 0.5px; }
85% { inset: -1px -0.5px; }
100% { inset: 0 -1px; }
}
@media (prefers-reduced-motion: reduce) {
.badge::after { animation: none; opacity: 0; }
}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);
}
.badge {
position: relative;
overflow: hidden;
display: inline-block;
padding: 8px 20px;
border-radius: 999px;
background: color-mix(in srgb, #17141a 30%, #ff4d1f);
color: #fff7e6;
font-weight: 700;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1;
font-family: inherit;
}
.badge::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(115deg, transparent 30%, #fff7e6 48%, transparent 66%);
background-size: 250% 100%;
mix-blend-mode: overlay;
animation: sweep 2s linear infinite, sweep-thick 2s linear infinite;
}
@keyframes sweep {
0% {
background-position: 150% 0;
}
100% {
background-position: -150% 0;
}
}
@keyframes sweep-thick {
0% {
inset: 0 -1px;
}
14% {
inset: 1px 0;
}
28% {
inset: -1px 1px;
}
42% {
inset: 0.5px -0.5px;
}
57% {
inset: -0.5px 1px;
}
71% {
inset: 1px 0.5px;
}
85% {
inset: -1px -0.5px;
}
100% {
inset: 0 -1px;
}
}
@media (prefers-reduced-motion: reduce) {
.badge::after {
animation: none;
opacity: 0;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ColorSweepBadge.module.scss";
export type ColorSweepBadgeProps = {
children: ReactNode;
/** 빛 한 줄이 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
/** 배지 바탕색 */
bg?: string;
/** 빛 줄기 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--bg"?: string;
"--color"?: string;
};
export default function ColorSweepBadge({
children,
duration = 2000,
bg = "#ff4d1f",
color = "#fff7e6",
}: ColorSweepBadgeProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--bg": bg,
"--color": color,
};
return (
<span className={styles.badge} style={vars}>
{children}
</span>
);
}08 · 色のスイープ — 計測 累積面積 3.189% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
継ぎ目なく走らせる
.badge::after {
background: linear-gradient(115deg, transparent 30%, $color 48%, transparent 66%);
background-size: 250% 100%;
mix-blend-mode: overlay;
animation: sweep $duration linear infinite;
}
@keyframes sweep {
0% { background-position: 150% 0; }
100% { background-position: -150% 0; }
}
09テキストシャドウポップ
ぼかし半径と弾みを同時に
@propertyで登録した長さの値をfilter: drop-shadow()のぼかし半径に入れて0から10pxへ動かしつつ、同時にtransform: scale(1→1.06→1)で軽く弾ませます。ぼかしの減りの幅も細かく乱れており、機械の単一減衰ではなくインクが乾いていくような筋に見えます。価格表の最終価格や、カウントダウン数字を一度だけ強く見せたいときに向いています。

<main class="stage">
<p class="line">最終価格 <span class="hl">₩12,900</span></p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ─────────────────────────────
$duration: 1s; // 한 번 통통 튀는 데 걸리는 시간
$easing: $ease-pop;
$color: $subject-blue; // 글로우 색
// @property 로 <length> 를 등록해야 filter 안의 흐림 반경이 부드럽게 보간된다
@property --shadow-blur {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
// 페인트 물성: 글로우의 번짐 위치(x-offset)도 변수로 등록해 따로 보간시킨다
@property --shadow-x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.line {
margin: 0;
color: $subject-cream;
font-weight: 600;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1.4;
font-family: inherit;
}
.hl {
display: inline-block;
color: $subject-cream;
font-weight: 800;
filter: drop-shadow(var(--shadow-x, 0px) 0 var(--shadow-blur) $color);
animation: pop $duration $easing infinite, pop-glow $duration $easing infinite;
}
@keyframes pop {
0% { transform: scale(1); --shadow-blur: 0px; }
40% { transform: scale(1.06); --shadow-blur: 10px; }
70% { transform: scale(1); --shadow-blur: 4px; }
100% { transform: scale(1); --shadow-blur: 0px; }
}
// 페인트 물성: blur 하강을 촘촘히 요동시키고(불균일 번짐) x-offset 도 미세 진동 — 변수 2개가 각자 보간
@keyframes pop-glow {
0% { --shadow-blur: 0px; --shadow-x: 0px; }
12% { --shadow-blur: 11px; --shadow-x: -1px; }
24% { --shadow-blur: 3px; --shadow-x: 1.5px; }
36% { --shadow-blur: 8px; --shadow-x: -0.5px; }
48% { --shadow-blur: 1px; --shadow-x: 1px; }
60% { --shadow-blur: 6px; --shadow-x: -1.5px; }
72% { --shadow-blur: 2px; --shadow-x: 0.5px; }
84% { --shadow-blur: 4px; --shadow-x: -1px; }
100% { --shadow-blur: 0px; --shadow-x: 0px; }
}
@media (prefers-reduced-motion: reduce) {
.hl { animation: none; filter: drop-shadow(0 0 4px $color); }
}@property --shadow-blur {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --shadow-x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
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);
}
.line {
margin: 0;
color: #fff7e6;
font-weight: 600;
font-size: clamp(16px, 4.6vw, 22px);
line-height: 1.4;
font-family: inherit;
}
.hl {
display: inline-block;
color: #fff7e6;
font-weight: 800;
filter: drop-shadow(var(--shadow-x, 0px) 0 var(--shadow-blur) #2f6df6);
animation: pop 1s cubic-bezier(0.2, 1.4, 0.4, 1) infinite, pop-glow 1s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes pop {
0% {
transform: scale(1);
--shadow-blur: 0px;
}
40% {
transform: scale(1.06);
--shadow-blur: 10px;
}
70% {
transform: scale(1);
--shadow-blur: 4px;
}
100% {
transform: scale(1);
--shadow-blur: 0px;
}
}
@keyframes pop-glow {
0% {
--shadow-blur: 0px;
--shadow-x: 0px;
}
12% {
--shadow-blur: 11px;
--shadow-x: -1px;
}
24% {
--shadow-blur: 3px;
--shadow-x: 1.5px;
}
36% {
--shadow-blur: 8px;
--shadow-x: -0.5px;
}
48% {
--shadow-blur: 1px;
--shadow-x: 1px;
}
60% {
--shadow-blur: 6px;
--shadow-x: -1.5px;
}
72% {
--shadow-blur: 2px;
--shadow-x: 0.5px;
}
84% {
--shadow-blur: 4px;
--shadow-x: -1px;
}
100% {
--shadow-blur: 0px;
--shadow-x: 0px;
}
}
@media (prefers-reduced-motion: reduce) {
.hl {
animation: none;
filter: drop-shadow(0 0 4px #2f6df6);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./TextShadowPop.module.scss";
export type TextShadowPopProps = {
label: ReactNode;
/** 튀는 값 */
value: ReactNode;
/** 한 번 통통 튀는 시간 (기본 1000ms) */
duration?: number;
easing?: string;
/** 글로우 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function TextShadowPop({
label,
value,
duration = 1000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: TextShadowPopProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<p className={styles.line}>
{label} <span className={styles.hl} style={vars}>{value}</span>
</p>
);
}09 · テキストシャドウポップ — 計測 累積面積 3.196% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
価格表の最終価格に
@property --shadow-blur {
syntax: "<length>"; inherits: false; initial-value: 0px;
}
.hl {
filter: drop-shadow(0 0 var(--shadow-blur) $color);
animation: pop $duration $easing infinite;
}
@keyframes pop {
0% { transform: scale(1); --shadow-blur: 0px; }
40% { transform: scale(1.06); --shadow-blur: 10px; }
100% { transform: scale(1); --shadow-blur: 0px; }
}
見えなくなる落とし穴はどこか
isolation: isolateがないと隠れる
9つのうち01のマーカー強調と02のマーカー重ねは、最初に組んだときは画面にまったく映りませんでした。z-index: -1で文字の裏に置いた黄色いブロックを、親の.hlにisolation: isolateを付けずに置くと、位置指定のない祖先(舞台の背景)の描画よりも先に、つまり下に描かれてしまい完全に隠れます。isolation: isolateを1行足すとマーカーが現れ、レンダリング計測で0%だった塗られた面積が0.8%以上まで上がりました。
控えめすぎると動いて見えない
文字が自分の背景に沈むこの失敗は、暗い計器盤のような画面を敷くHUDのLPパーツでも同じコントラスト計算として繰り返されます。
04の波線下線にも似た落とし穴がありました。text-underline-offsetを1pxから3pxの間だけで揺らすと、フレーム同士の変化がピクセルの判定基準より小さく、計測では「ほぼ動いていないアニメーション」として弾かれました。範囲を0pxから5pxに広げると波が普通に見えるようになりました。text-decoration-thicknessとtext-underline-offsetそのものはMDNの対応表にあるとおり主要ブラウザで広く安定して使えます。ここまでの修正が反映済みの9ファイルを開く合言葉はhx9yh6mrで、下のカードから取り出せます。
アクセシビリティ
動きを止めても強調は残る
OSで「視覚効果を減らす」を選んだ人には、9項目それぞれ違う形で強調だけが残ります。01・02のマーカーは塗られた状態のまま止まり、03の下線は描き終えた形で固定され、04の波線は真ん中の位置で動きを止めます。05の取り消し線は引かれた状態のまま残り、09の影は淡く残ってジャンプだけが消え、03・05・07のように別レイヤーを足した項目にはaria-hidden="true"を付けて読み上げが装飾を飛ばし本文だけを読むようにしています。prefers-reduced-motion自体がどのブラウザから使えるかはMDNのドキュメントにまとまっています。
@media (prefers-reduced-motion: reduce) {
.hl::before, .hl::after, .hl__path,
.price__strike, .hl__dots, .badge::after { animation: none; }
.hl { animation: none; text-underline-offset: 2px; filter: drop-shadow(0 0 4px $color); }
}
次に読む
強調以外のCSSアニメーションはこちらの一覧からたどれます。サイトの成り立ちは紹介ページに書きました。
FAQ
マーカーと波線下線を同じ文に重ねてもいいですか?
技術的にはできますが、一つの文には一つの強調だけにとどめるのが読みやすいです。01・02のマーカーと04の波線を同じ語に重ねると、どちらが本当に大事な部分なのか分かりにくくなります。05の取り消し線と08のバッジのスイープのように文脈が離れた要素同士なら、同じ画面にあっても違和感はありません。
「css 下線 アニメーション 複数行」の場合も強調する単語はどうやって指定しますか?
HTMLでは強調したい部分だけをspanで囲み、残りはそのままにします。zipのreactフォルダに入っているReact版では、対象の文字列をpropsとして渡すだけで、動きの長さや色はSCSS側の変数が受け持ちます。
スクリーンリーダーではどう聞こえますか?
本文の文字そのものは触らず、背景や下線、影といった装飾の層だけを動かしているので、読み上げの内容は変わりません。03の手描き下線・05の取り消し線・07の点線のように装飾だけの層を足した項目にはaria-hidden="true"を付け、読み上げが装飾を飛ばして本文だけを読むようにしています。蛍光ペンの掃きがなく下線だけが単独で動く例はCSSリンク下線アニメーションにまとめてあります。









