CSS 텍스트 강조 애니메이션 9종 — 한 줄 복붙으로
CSS 텍스트 강조 애니메이션은 본문 속 한 단어나 문장에 형광펜·밑줄·취소선 같은 시각 효과를 얹어 읽는 사람의 시선을 그 자리에 멈추게 하는 기법입니다. 아홉 가지를 모았고, 전부 자바스크립트 없이 순수 CSS keyframes만으로 자동 반복됩니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 인기 순이 아니라 문장에서 강조가 필요한 상황 순서로 놓았습니다. 문서 속 핵심 문구를 칠하고(01~03), 경고나 완료를 표시하고(04~05), 링크와 안내선으로 이끌고(06~07), 마지막으로 배지와 숫자를 튀게 합니다(08~09). 항목마다 코드는 핵심만 실었고 전체 파일은 zip에 있습니다. 같은 강조를 유틸리티 클래스 쪽으로 옮기는 변환은 Tailwind 애니메이션 유틸이 보여 주고, 칭찬이 아니라 오류를 알리는 움직임이 필요할 땐 흔들리는 애니메이션이 같은 발상의 반대편입니다.
01형광펜 강조
형광펜 블록을 겹치는 순서
키워드 뒤에 노란 블록을 겹치고 왼쪽부터 차오르게 칠합니다. 여기에 같은 색의 노이즈 레이어를 위아래 지그재그로 한 겹 더 겹쳐 펜 두께가 구석마다 불규칙하게 출렁이는 스트로크로 보이게 했습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
약관 속 핵심 문구에
약관이나 안내문에서 꼭 봐야 할 문구를 가리킬 때 씁니다.
.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마커 겹침
두 겹을 어긋나게 칠하기
같은 형광펜 블록을 두 겹 겹치되 서로 다른 각도와 타이밍으로 칠해 손으로 두 번 그은 듯한 두께를 만듭니다. 각 겹의 위아래 두께가 서로 다른 위상으로 요동쳐 겹침 가장자리가 죽지 않고 계속 살아 움직입니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
가장 중요한 한 단어에
페이지에서 가장 중요한 한 단어에 씁니다.
.hl {
position: relative;
isolation: isolate;
}
.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손글씨 밑줄
css 밑줄 애니메이션을 손으로 그은 것처럼
인라인 SVG 곡선 한 줄을 stroke-dashoffset으로 왼쪽부터 그려 손으로 방금 그은 밑줄처럼 보이게 합니다. 그어지는 동안 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
인용구의 강조어에
인용구나 자기소개의 강조어에 씁니다.
.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물결 밑줄
네이티브 물결선에 무엇을 더하나
네이티브 물결 밑줄에 text-underline-offset만 애니메이션해 경고 문구가 숨 쉬듯 움직이게 합니다. 물결이 오가는 사이사이 굵기도 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
필수 입력 표시에
필수 입력 표시나 맞춤법 경고에 씁니다.
@property --underline-offset {
syntax: "<length>"; inherits: true; initial-value: 1px;
}
.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취소선 긋기
글자 중간 높이를 긋는 선
글자 중간 높이의 가로선을 왼쪽부터 그어 취소선이 눈앞에서 그어지는 순간을 만듭니다. 선의 높이가 1.5px와 3px 사이를 요동쳐 한 번에 긋지 않고 두세 번 힘을 주어 긋는 느낌을 냅니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
할인 전 가격에
할인 전 가격이나 완료된 할 일에 씁니다.
.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 만으로 채우기
링크 배경을 background-size만으로 왼쪽부터 채우는데, 텍스트 마우스 오버 효과로 쓰려면 같은 값을 :hover 에 걸면 됩니다. 채워지는 동안 수직 크기가 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
채운 자리가 밀리지 않는 이유
background-position을 left로 고정해 채워진 자리가 뒤로 밀리지 않습니다.
.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 로 늘리기
점을 반복해서 그린 얇은 막대를 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
다음 단계로 이끄는 선에
단계별 안내에서 다음 단계로 이끄는 선에 씁니다.
.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색 스윕
이음새 없이 훑고 지나가기
대각선 그러데이션을 무한히 흘려보내 빛 한 줄이 이음새 없이 배지를 훑고 지나가게 합니다. 빛 줄기의 수직 두께도 좌우로 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
linear 여야 하는 까닭
linear 이징이어야 한 바퀴가 끝나는 지점에서 튀지 않습니다.
.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텍스트 셰도 팝
커스텀 프로퍼티로 흐림 반경 움직이기
등록한 커스텀 프로퍼티로 그림자 흐림 반경을 애니메이션해 숫자가 한 번 통통 튀며 진하게 강조됩니다. 흐림 반경의 줄어드는 폭이 촘촘히 요동쳐 번짐이 기계의 단일 감쇠가 아니라 잉크가 마르는 결로 보입니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
최종가와 카운트다운에
최종가나 카운트다운 숫자에 씁니다.
@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; }
70% { transform: scale(1); --shadow-blur: 4px; }
100% { transform: scale(1); --shadow-blur: 0px; }
}
어디서 깨지는가 — 함정 둘
z-index -1 은 왜 사라졌나
아홉 개 중 두 개(01 형광펜 강조, 02 마커 겹침)는 처음 만들었을 때 화면에 전혀 안 보였습니다. z-index: -1로 글자 뒤에 숨긴 형광펜 블록을 부모에 isolation: isolate 없이 두면, 포지션이 없는 조상의 배경 페인트보다도 먼저(=아래로) 그려져 완전히 가려집니다. 강조를 담은 요소에 isolation: isolate 한 줄을 더하자 형광펜이 다시 나타났고, 렌더 측정에서 누적면적 0%였던 값이 0.8% 이상으로 올라갔습니다.
은은함과 안 보임은 한 끗 차이
글자가 제 배경에 묻히는 이 실패는 화면 전체를 어두운 계기판으로 덮는 HUD 랜딩 부품에서도 똑같은 대비 계산으로 되풀이됩니다.
04 물결 밑줄도 비슷한 함정이 있었습니다. text-underline-offset을 1px에서 3px 사이로만 흔들자 프레임 사이 변화가 너무 작아 렌더 측정에서 "거의 안 움직이는 애니메이션"으로 잡혔고, 범위를 0px에서 5px로 넓히자 정상적으로 물결이 보였습니다. 값이 작을수록 은은해 보이지만, 은은함과 안 보임은 한 끗 차이입니다. 두 함정 모두 렌더 측정값을 실제로 찍어 보지 않았다면 "눈에는 그럭저럭 보이는데" 정도로 넘어갔을 문제였습니다. 이 두 번의 수정이 이미 반영된 아홉 개 파일을 담은 압축 파일의 비밀번호는 hx9yh6mr이며, 아래 버튼을 누르면 받을 수 있습니다.
접근성
움직임은 꺼도 강조는 남긴다
OS에서 모션 줄이기를 켠 사람에게는 아홉 항목 모두 prefers-reduced-motion: reduce 안에서 각기 다른 결과를 보여 줍니다. 움직임은 끄되 강조 자체는 남기는 게 원칙입니다. 01·02는 형광펜이 칠해진 채로 고정되고, 04는 물결 밑줄이 가운데 값에서 멈추며, 09는 그림자가 흐릿하게 유지된 채로 튐만 사라집니다. 03·05·07처럼 SVG·가로선·점선을 위해 별도 레이어를 추가한 항목은 aria-hidden="true"를 걸어 스크린리더가 장식만 건너뛰고 실제 문장은 그대로 읽게 했습니다. reduced-motion 미디어 쿼리가 어느 브라우저부터 되는지는 MDN 문서에 정리돼 있습니다(참고).
이어서 볼 글
형광펜·밑줄 말고 다른 강조 기법은 CSS 애니메이션 카테고리에서 더 찾을 수 있고, 사이트 운영 방식은 소개 페이지에 적어 두었습니다.
FAQ
css 텍스트 애니메이션 효과를 한 페이지에 여러 개 같이 써도 되나요?
가능하지만 한 문단에는 강조 기법 하나만 쓰는 게 좋습니다. 형광펜(01·02)과 물결 밑줄(04)을 같은 문장에 겹치면 무엇이 중요한지 오히려 흐려집니다. 취소선(05)·배지 스윕(08)처럼 맥락이 다른 요소끼리는 한 화면에 같이 있어도 자연스럽습니다.
강조되는 단어는 어떻게 정하나요?
HTML에서 강조할 부분만 span으로 감싸고 나머지는 그대로 둡니다. React 판은 zip의 react 폴더처럼 keyword나 before·after props로 문장을 나눠 넘기면 됩니다. 서버에서 받은 문자열을 그대로 그 props에 넣으면 동적인 키워드도 그대로 작동합니다.
애니메이션이 스크린리더에서도 문제없나요?
본문 글자 자체는 손대지 않고 배경·밑줄·그림자 같은 장식 레이어만 움직이므로 텍스트는 그대로 읽힙니다. 03·05·07처럼 별도 SVG·가로선·점선 레이어를 추가한 항목은 aria-hidden으로 스크린리더가 건너뛰게 했습니다. 움직임 자체가 부담스러운 사용자는 접근성 처리 덕분에 강조는 남고 애니메이션만 꺼집니다. 형광펜 쓸기 없이 밑줄 하나만 따로 움직이는 경우는 CSS 밑줄 애니메이션에 모아 두었습니다.









