CSS 텍스트 애니메이션 효과 9종 — 한 줄 복붙으로
CSS 텍스트 애니메이션 효과는 자바스크립트 없이 CSS만으로 제목이나 문구가 움직이며 나타나거나 강조되는 짧은 연출입니다. 아홉 개 전부 transform·opacity·filter 세 가지 속성만 움직이고, 격자에서 눈으로 고른 뒤 마음에 드는 것만 쓰거나 아홉 개를 zip 하나로
01 text-animation-mask-reveal-up
02 text-animation-letter-bounce
03 text-animation-rotate-in
04 text-animation-flip-3d
05 text-animation-blur-focus
06 text-animation-wave
07 text-animation-wiggle-emphasis
08 text-animation-two-tone-sweep
09 text-animation-glitch
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 인기 순이 아니라 텍스트가 화면에서 거치는 순서로 놓았습니다. 처음 등장하는 방식 다섯 가지(01~05), 이를테면 css 텍스트 슬라이드 애니메이션으로 시작해, 등장 뒤에도 계속 도는 대기 상태(06)를 거쳐, 이미 떠 있는 문구에 시선을 붙잡는 강조 두 가지(07~08)로 넘어가고, 마지막은 오류처럼 불안정해 보이는 상태(09)로 마칩니다. 코드는 항목마다 핵심 규칙만 남겼고, 마크업까지 다 붙은 완성 파일은 zip 안에 있습니다. 문장이 통째로 찍히는 쪽이 필요하면 CSS 타이핑 효과 9종이고, 글자 대신 배경이 움직이는 편은 CSS 배경 애니메이션 9종입니다.
01밑에서 마스크 등장
어떻게 움직이나
제목을 담은 상자를 overflow: hidden으로 잘라 두고, 안쪽 글자만 translateY로 상자 바깥(115%)에서 0으로 밀어 올리면 커튼을 걷듯 한 번에 드러납니다. 여기에 글자마다 --n 노이즈만큼 다르게 블러가 걸렸다 풀리는 감쇠를 겹쳐 두어, 마스크가 걷히는 순간과 초점이 맞는 순간이 글자마다 미묘하게 어긋나며 함께 지나갑니다.
어디에 쓰나
상자 자체는 움직이지 않고 안쪽 글자만 이동하기 때문에 주변 레이아웃은 전혀 흔들리지 않습니다.

<main class="stage">
<div class="mask">
<h1 class="mask__text" data-split>당신의 이야기</h1>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 마스크가 걷히는 한 주기
$easing: $ease-spring; // 처음 빠르고 끝은 부드럽게
$color: $subject-cream; // 글자 색
@include stage($stage-ink);
.mask {
overflow: hidden; // 상자 밖은 자른다 — 이게 마스크
max-width: min(380px, 90vw);
}
.mask__text {
margin: 0;
font-weight: 800; font-size: clamp(26px, 8vw, 42px); line-height: 1.2; font-family: inherit;
color: $color;
text-align: center;
}
.mask__text .letter {
display: inline-block;
animation: mask-up $duration $easing infinite, letter-blur-in $duration $easing infinite;
animation-delay: calc(var(--i) * 30ms + var(--n) * 70ms); // --i 시차 + --n 노이즈로 기계적 정렬을 깬다
}
@keyframes mask-up {
0%, 4% { transform: translateY(115%); }
30%, 78% { transform: translateY(0); }
96%, 100% { transform: translateY(115%); }
}
@keyframes letter-blur-in {
0%, 4% { filter: blur(10px); }
30%, 78% { filter: blur(0); }
96%, 100% { filter: blur(10px); }
}
@media (prefers-reduced-motion: reduce) {
.mask__text .letter { animation: none; transform: none; filter: none; }
}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);
}
.mask {
overflow: hidden;
max-width: min(380px, 90vw);
}
.mask__text {
margin: 0;
font-weight: 800;
font-size: clamp(26px, 8vw, 42px);
line-height: 1.2;
font-family: inherit;
color: #fff7e6;
text-align: center;
}
.mask__text .letter {
display: inline-block;
animation: mask-up 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, letter-blur-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: calc(var(--i) * 30ms + var(--n) * 70ms);
}
@keyframes mask-up {
0%, 4% {
transform: translateY(115%);
}
30%, 78% {
transform: translateY(0);
}
96%, 100% {
transform: translateY(115%);
}
}
@keyframes letter-blur-in {
0%, 4% {
filter: blur(10px);
}
30%, 78% {
filter: blur(0);
}
96%, 100% {
filter: blur(10px);
}
}
@media (prefers-reduced-motion: reduce) {
.mask__text .letter {
animation: none;
transform: none;
filter: none;
}
}(function(){
function splitLetters(el){
var text = el.textContent;
el.textContent = "";
Array.from(text).forEach(function(ch, i){
var span = document.createElement("span");
span.className = "letter";
var seed = Math.sin((i + 1) * 12.9898) * 43758.5453;
var noise = seed - Math.floor(seed);
span.style.setProperty("--i", i);
span.style.setProperty("--n", noise.toFixed(3));
span.textContent = ch === " " ? " " : ch;
el.appendChild(span);
});
}
document.querySelectorAll("[data-split]").forEach(splitLetters);
})();import type { CSSProperties } from "react";
import styles from "./MaskRevealUp.module.scss";
type Vars = CSSProperties & { "--i"?: number; "--n"?: number };
function noise(i: number): number {
const s = Math.sin((i + 1) * 12.9898) * 43758.5453;
return Number((s - Math.floor(s)).toFixed(3));
}
export type MaskRevealUpProps = {
text: string;
/** 마스크가 걷히는 한 주기 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function MaskRevealUp({ text, duration = 2000, color = "#fff7e6" }: MaskRevealUpProps) {
const vars = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
const chars = Array.from(text);
return (
<div className={styles.mask}>
<h1 className={styles.text} style={vars}>
{chars.map((ch, i) => (
<span key={i} className={styles.letter} style={{ "--i": i, "--n": noise(i) } as Vars}>
{ch === " " ? " " : ch}
</span>
))}
</h1>
</div>
);
}01 · 밑에서 마스크 등장 — 측정 누적면적 5.417% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.mask { overflow: hidden; max-width: min(380px, 90vw); }
.mask__text {
color: #fff7e6;
animation: mask-up 2s cubic-bezier(.2, .8, .2, 1) infinite;
}
@keyframes mask-up {
0%, 4% { transform: translateY(115%); }
30%, 78% { transform: translateY(0); }
96%, 100% { transform: translateY(115%); }
}
02글자별 튀어오름
어떻게 움직이나
로고나 히어로 타이틀의 글자를 하나씩 <span>으로 나누고 인라인 style="--i:N"을 매긴 뒤, animation-delay: calc(var(--i) * 40ms) 한 줄로 시차를 계산합니다. 이 시차에 --n 노이즈를 더해 튀어오르는 동안 글자마다 블러가 풀리는 감쇠가 겹쳐지도록 했더니, 글자들이 기계적으로 딱 맞춰 튀지 않고 살짝 흐트러져 보입니다.
어디에 쓰나
모든 글자가 같은 keyframes를 공유하기 때문에 글자 수가 바뀌어도 시차 공식은 그대로 씁니다.

<main class="stage">
<h1 class="letters" data-split>GODRICH</h1>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 글자들이 한 바퀴 도는 시간 (시차는 $stagger)
$easing: $ease-pop; // 아래에서 튀어오르는 느낌
$stagger: 40ms; // --i 1당 시차
@include stage($stage-yellow);
.letters {
margin: 0;
display: flex;
gap: clamp(2px, 1vw, 6px);
font-weight: 800; font-size: clamp(28px, 10vw, 46px); line-height: 1; font-family: inherit;
color: $subject-ink;
span {
display: inline-block;
animation: letter-bounce $duration $easing infinite, letter-blur-in $duration $easing infinite;
animation-delay: calc(var(--i) * #{$stagger} + var(--n) * 60ms);
}
}
@keyframes letter-bounce {
0%, 4% { opacity: 0; transform: translateY(22px); }
22%, 70% { opacity: 1; transform: translateY(0); }
88%, 100% { opacity: 0; transform: translateY(-14px); }
}
@keyframes letter-blur-in {
0%, 4% { filter: blur(10px); }
22%, 70% { filter: blur(0); }
88%, 100% { filter: blur(10px); }
}
@media (prefers-reduced-motion: reduce) {
.letters span { animation: none; opacity: 1; transform: none; filter: none; }
}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);
}
.letters {
margin: 0;
display: flex;
gap: clamp(2px, 1vw, 6px);
font-weight: 800;
font-size: clamp(28px, 10vw, 46px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.letters span {
display: inline-block;
animation: letter-bounce 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite, letter-blur-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
animation-delay: calc(var(--i) * 40ms + var(--n) * 60ms);
}
@keyframes letter-bounce {
0%, 4% {
opacity: 0;
transform: translateY(22px);
}
22%, 70% {
opacity: 1;
transform: translateY(0);
}
88%, 100% {
opacity: 0;
transform: translateY(-14px);
}
}
@keyframes letter-blur-in {
0%, 4% {
filter: blur(10px);
}
22%, 70% {
filter: blur(0);
}
88%, 100% {
filter: blur(10px);
}
}
@media (prefers-reduced-motion: reduce) {
.letters span {
animation: none;
opacity: 1;
transform: none;
filter: none;
}
}(function(){
function splitLetters(el){
var text = el.textContent;
el.textContent = "";
Array.from(text).forEach(function(ch, i){
var span = document.createElement("span");
span.className = "letter";
var seed = Math.sin((i + 1) * 12.9898) * 43758.5453;
var noise = seed - Math.floor(seed);
span.style.setProperty("--i", i);
span.style.setProperty("--n", noise.toFixed(3));
span.textContent = ch === " " ? " " : ch;
el.appendChild(span);
});
}
document.querySelectorAll("[data-split]").forEach(splitLetters);
})();import type { CSSProperties } from "react";
import styles from "./LetterBounce.module.scss";
type Vars = CSSProperties & { "--i"?: number; "--n"?: number };
function noise(i: number): number {
const s = Math.sin((i + 1) * 12.9898) * 43758.5453;
return Number((s - Math.floor(s)).toFixed(3));
}
export type LetterBounceProps = {
letters: string[];
/** 글자들이 한 바퀴 도는 시간 ms (기본 2000) */
duration?: number;
/** --i 1당 시차 ms (기본 40ms) */
stagger?: number;
color?: string;
};
export default function LetterBounce({ letters, duration = 2000, color = "#17141a" }: LetterBounceProps) {
return (
<h1 className={styles.letters} style={{ "--color": color, "--duration": `${duration}ms` } as CSSProperties}>
{letters.map((ch, i) => (
<span key={i} style={{ "--i": i, "--n": noise(i) } as Vars}>{ch}</span>
))}
</h1>
);
}02 · 글자별 튀어오름 — 측정 누적면적 8.615% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.letters span {
display: inline-block;
animation: letter-bounce 2s cubic-bezier(.2, 1.4, .4, 1) infinite;
animation-delay: calc(var(--i) * 40ms);
}
@keyframes letter-bounce {
0%, 4% { opacity: 0; transform: translateY(22px); }
22%, 70% { opacity: 1; transform: translateY(0); }
88%, 100% { opacity: 0; transform: translateY(-14px); }
}
03회전 등장
어떻게 움직이나
글자마다 --i 변수로 90ms씩 지연을 주고 rotate를 -25도에서 0도로, opacity를 0에서 1로 함께 트랜지션하면 살짝 기울어진 채 날아들다 똑바로 서는 것처럼 보입니다. 회전하며 등장하는 동안에도 --n 노이즈로 지연을 조금씩 흩트리고 글자마다 블러가 풀리는 감쇠를 함께 겹쳤습니다.
어디에 쓰나
배지나 태그처럼 짧은 라벨에 어울립니다.

<main class="stage">
<h1 class="rot" data-split>신상품</h1>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 글자들이 한 바퀴 도는 시간 (시차는 $stagger)
$easing: $ease-out; // 시작 빠르고 끝은 부드럽게
$stagger: 90ms; // --i 1당 시차
@include stage($stage-orange);
.rot {
margin: 0;
display: flex;
gap: clamp(4px, 1.4vw, 10px);
font-weight: 800; font-size: clamp(30px, 11vw, 48px); line-height: 1; font-family: inherit;
color: $subject-cream;
span {
display: inline-block;
animation: rotate-in $duration $easing infinite, letter-blur-in $duration $easing infinite;
animation-delay: calc(var(--i) * #{$stagger} + var(--n) * 60ms);
}
}
@keyframes rotate-in {
0%, 4% { opacity: 0; transform: rotate(-25deg); }
26%, 72% { opacity: 1; transform: rotate(0deg); }
90%, 100% { opacity: 0; transform: rotate(18deg); }
}
@keyframes letter-blur-in {
0%, 4% { filter: blur(9px); }
26%, 72% { filter: blur(0); }
90%, 100% { filter: blur(9px); }
}
@media (prefers-reduced-motion: reduce) {
.rot span { animation: none; opacity: 1; transform: none; filter: none; }
}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);
}
.rot {
margin: 0;
display: flex;
gap: clamp(4px, 1.4vw, 10px);
font-weight: 800;
font-size: clamp(30px, 11vw, 48px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.rot span {
display: inline-block;
animation: rotate-in 2s ease-out infinite, letter-blur-in 2s ease-out infinite;
animation-delay: calc(var(--i) * 90ms + var(--n) * 60ms);
}
@keyframes rotate-in {
0%, 4% {
opacity: 0;
transform: rotate(-25deg);
}
26%, 72% {
opacity: 1;
transform: rotate(0deg);
}
90%, 100% {
opacity: 0;
transform: rotate(18deg);
}
}
@keyframes letter-blur-in {
0%, 4% {
filter: blur(9px);
}
26%, 72% {
filter: blur(0);
}
90%, 100% {
filter: blur(9px);
}
}
@media (prefers-reduced-motion: reduce) {
.rot span {
animation: none;
opacity: 1;
transform: none;
filter: none;
}
}(function(){
function splitLetters(el){
var text = el.textContent;
el.textContent = "";
Array.from(text).forEach(function(ch, i){
var span = document.createElement("span");
span.className = "letter";
var seed = Math.sin((i + 1) * 12.9898) * 43758.5453;
var noise = seed - Math.floor(seed);
span.style.setProperty("--i", i);
span.style.setProperty("--n", noise.toFixed(3));
span.textContent = ch === " " ? " " : ch;
el.appendChild(span);
});
}
document.querySelectorAll("[data-split]").forEach(splitLetters);
})();import type { CSSProperties } from "react";
import styles from "./RotateIn.module.scss";
type Vars = CSSProperties & { "--i"?: number; "--n"?: number };
function noise(i: number): number {
const s = Math.sin((i + 1) * 12.9898) * 43758.5453;
return Number((s - Math.floor(s)).toFixed(3));
}
export type RotateInProps = {
letters: string[];
/** 글자들이 한 바퀴 도는 시간 ms (기본 2000) */
duration?: number;
/** --i 1당 시차 ms (기본 90ms) */
stagger?: number;
color?: string;
};
export default function RotateIn({ letters, duration = 2000, color = "#fff7e6" }: RotateInProps) {
return (
<h1 className={styles.rot} style={{ "--color": color, "--duration": `${duration}ms` } as CSSProperties}>
{letters.map((ch, i) => (
<span key={i} style={{ "--i": i, "--n": noise(i) } as Vars}>{ch}</span>
))}
</h1>
);
}03 · 회전 등장 — 측정 누적면적 4.82% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.rot span {
animation: rotate-in 300ms ease-out infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes rotate-in {
0%, 4% { opacity: 0; transform: rotate(-25deg); }
26%, 72% { opacity: 1; transform: rotate(0deg); }
90%, 100% { opacity: 0; transform: rotate(18deg); }
}
043D 뒤집기 글자
어떻게 움직이나
글자를 담은 부모에 perspective를 주고 각 글자에 backface-visibility: hidden을 둔 채 rotateX를 90도에서 0도로 돌리면, 낱장 카드가 앞으로 넘어오듯 뒤집히며 나타납니다. 뒤집히는 동안 --n 노이즈로 지연을 살짝 흩트리고 글자마다 블러가 풀리는 감쇠를 함께 걸었습니다.
어디에 쓰나
가격이나 숫자가 갱신되는 자리에 쓰면 값이 바뀌었다는 신호가 분명해집니다.

<main class="stage">
<h1 class="flip" data-split>₩290</h1>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 글자들이 한 바퀴 도는 시간 (시차는 $stagger)
$easing: $ease-spring;
$stagger: 70ms; // --i 1당 시차
@include stage($stage-paper);
.flip {
margin: 0;
display: flex;
gap: clamp(2px, 1vw, 8px);
font-weight: 800; font-size: clamp(32px, 12vw, 52px); line-height: 1; font-family: inherit;
color: $subject-ink;
perspective: 480px;
span {
display: inline-block;
backface-visibility: hidden;
animation: flip-in $duration $easing infinite, letter-blur-in $duration $easing infinite;
animation-delay: calc(var(--i) * #{$stagger} + var(--n) * 60ms);
}
}
@keyframes flip-in {
0%, 4% { opacity: 0; transform: rotateX(90deg); }
30%, 74% { opacity: 1; transform: rotateX(0deg); }
94%, 100% { opacity: 0; transform: rotateX(-90deg); }
}
@keyframes letter-blur-in {
0%, 4% { filter: blur(9px); }
30%, 74% { filter: blur(0); }
94%, 100% { filter: blur(9px); }
}
@media (prefers-reduced-motion: reduce) {
.flip span { animation: none; opacity: 1; transform: none; filter: none; }
}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);
}
.flip {
margin: 0;
display: flex;
gap: clamp(2px, 1vw, 8px);
font-weight: 800;
font-size: clamp(32px, 12vw, 52px);
line-height: 1;
font-family: inherit;
color: #17141a;
perspective: 480px;
}
.flip span {
display: inline-block;
backface-visibility: hidden;
animation: flip-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, letter-blur-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: calc(var(--i) * 70ms + var(--n) * 60ms);
}
@keyframes flip-in {
0%, 4% {
opacity: 0;
transform: rotateX(90deg);
}
30%, 74% {
opacity: 1;
transform: rotateX(0deg);
}
94%, 100% {
opacity: 0;
transform: rotateX(-90deg);
}
}
@keyframes letter-blur-in {
0%, 4% {
filter: blur(9px);
}
30%, 74% {
filter: blur(0);
}
94%, 100% {
filter: blur(9px);
}
}
@media (prefers-reduced-motion: reduce) {
.flip span {
animation: none;
opacity: 1;
transform: none;
filter: none;
}
}(function(){
function splitLetters(el){
var text = el.textContent;
el.textContent = "";
Array.from(text).forEach(function(ch, i){
var span = document.createElement("span");
span.className = "letter";
var seed = Math.sin((i + 1) * 12.9898) * 43758.5453;
var noise = seed - Math.floor(seed);
span.style.setProperty("--i", i);
span.style.setProperty("--n", noise.toFixed(3));
span.textContent = ch === " " ? " " : ch;
el.appendChild(span);
});
}
document.querySelectorAll("[data-split]").forEach(splitLetters);
})();import type { CSSProperties } from "react";
import styles from "./Flip3D.module.scss";
type Vars = CSSProperties & { "--i"?: number; "--n"?: number };
function noise(i: number): number {
const s = Math.sin((i + 1) * 12.9898) * 43758.5453;
return Number((s - Math.floor(s)).toFixed(3));
}
export type Flip3DProps = {
letters: string[];
/** 글자들이 한 바퀴 도는 시간 ms (기본 2000) */
duration?: number;
/** --i 1당 시차 ms (기본 70ms) */
stagger?: number;
color?: string;
};
export default function Flip3D({ letters, duration = 2000, color = "#17141a" }: Flip3DProps) {
return (
<h1 className={styles.flip} style={{ "--color": color, "--duration": `${duration}ms` } as CSSProperties}>
{letters.map((ch, i) => (
<span key={i} style={{ "--i": i, "--n": noise(i) } as Vars}>{ch}</span>
))}
</h1>
);
}04 · 3D 뒤집기 글자 — 측정 누적면적 3.824% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.flip { perspective: 480px; }
.flip span {
backface-visibility: hidden;
animation: flip-in 600ms cubic-bezier(.2, .8, .2, 1) infinite;
animation-delay: calc(var(--i) * 70ms);
}
@keyframes flip-in {
0%, 4% { opacity: 0; transform: rotateX(90deg); }
30%, 74% { opacity: 1; transform: rotateX(0deg); }
94%, 100% { opacity: 0; transform: rotateX(-90deg); }
}
05블러+포커스
어떻게 움직이나
filter: blur 값을 14px에서 0으로, opacity를 0에서 1로 같이 트랜지션하면 초점이 맞아가듯 문구가 또렷해집니다. 이제 문구 전체가 한 덩어리로 흐려졌다 풀리는 게 아니라, 글자마다 --n 노이즈로 흩어진 시점에 각자 초점이 맞아가도록 바뀌었습니다.
어디에 쓰나
transform을 전혀 쓰지 않아 위치는 고정된 채 선명도만 바뀌는 것이 특징입니다.

<main class="stage">
<h1 class="blur" data-split>오늘의 순간</h1>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 흐림에서 초점까지 한 주기
$easing: $ease-out;
$blur: 14px; // 시작 흐림 정도
@include stage($stage-ink);
.blur {
margin: 0;
font-weight: 800; font-size: clamp(28px, 8.6vw, 44px); line-height: 1.2; font-family: inherit;
color: $subject-cream;
text-align: center;
}
.blur .letter {
display: inline-block;
animation: blur-focus $duration $easing infinite;
animation-delay: calc(var(--i) * 40ms + var(--n) * 80ms); // 글자마다 흐림이 풀리는 순간을 흩뜨린다
}
@keyframes blur-focus {
0%, 4% { opacity: 0; filter: blur(#{$blur}); }
30%, 76% { opacity: 1; filter: blur(0); }
96%, 100% { opacity: 0; filter: blur(#{$blur}); }
}
@media (prefers-reduced-motion: reduce) {
.blur .letter { animation: none; opacity: 1; filter: none; }
}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);
}
.blur {
margin: 0;
font-weight: 800;
font-size: clamp(28px, 8.6vw, 44px);
line-height: 1.2;
font-family: inherit;
color: #fff7e6;
text-align: center;
}
.blur .letter {
display: inline-block;
animation: blur-focus 2s ease-out infinite;
animation-delay: calc(var(--i) * 40ms + var(--n) * 80ms);
}
@keyframes blur-focus {
0%, 4% {
opacity: 0;
filter: blur(14px);
}
30%, 76% {
opacity: 1;
filter: blur(0);
}
96%, 100% {
opacity: 0;
filter: blur(14px);
}
}
@media (prefers-reduced-motion: reduce) {
.blur .letter {
animation: none;
opacity: 1;
filter: none;
}
}(function(){
function splitLetters(el){
var text = el.textContent;
el.textContent = "";
Array.from(text).forEach(function(ch, i){
var span = document.createElement("span");
span.className = "letter";
var seed = Math.sin((i + 1) * 12.9898) * 43758.5453;
var noise = seed - Math.floor(seed);
span.style.setProperty("--i", i);
span.style.setProperty("--n", noise.toFixed(3));
span.textContent = ch === " " ? " " : ch;
el.appendChild(span);
});
}
document.querySelectorAll("[data-split]").forEach(splitLetters);
})();import type { CSSProperties } from "react";
import styles from "./BlurFocus.module.scss";
type Vars = CSSProperties & { "--i"?: number; "--n"?: number };
function noise(i: number): number {
const s = Math.sin((i + 1) * 12.9898) * 43758.5453;
return Number((s - Math.floor(s)).toFixed(3));
}
export type BlurFocusProps = {
text: string;
/** 흐림에서 초점까지 한 주기 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function BlurFocus({ text, duration = 2000, color = "#fff7e6" }: BlurFocusProps) {
const vars = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
const chars = Array.from(text);
return (
<h1 className={styles.blur} style={vars}>
{chars.map((ch, i) => (
<span key={i} className={styles.letter} style={{ "--i": i, "--n": noise(i) } as Vars}>
{ch === " " ? " " : ch}
</span>
))}
</h1>
);
}05 · 블러+포커스 — 측정 누적면적 6.333% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.blur {
animation: blur-focus 2s ease-out infinite;
}
@keyframes blur-focus {
0%, 4% { opacity: 0; filter: blur(14px); }
30%, 76% { opacity: 1; filter: blur(0); }
96%, 100% { opacity: 0; filter: blur(14px); }
}
06물결치는 텍스트
어떻게 움직이나
글자마다 --i 변수로 90ms씩 지연을 주고 모든 글자가 translateY를 0에서 -16px로 오가는 같은 keyframes를 공유하면, 지연 차이만으로 한 줄 전체가 물결처럼 계속 일렁입니다. 파도가 정점에 오른 순간에는 --n 노이즈로 살짝 블러가 걸려 모션 잔상처럼 보이는 겹을 추가했습니다.
어디에 쓰나
등장이나 퇴장 없이 상시 도는 루프라 로딩 대기 문구에 어울립니다.

<main class="stage">
<h1 class="wave" data-split>기다려요</h1>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 한 글자가 오르내리는 한 주기
$easing: $ease-out;
$stagger: 90ms; // --i 1당 시차 — 이 간격이 파도를 만든다
@include stage($stage-yellow);
.wave {
margin: 0;
display: flex;
gap: clamp(2px, 1vw, 6px);
font-weight: 800; font-size: clamp(28px, 10vw, 46px); line-height: 1; font-family: inherit;
color: $subject-ink;
span {
display: inline-block;
animation: wave-up $duration $easing infinite, letter-blur-in $duration $easing infinite;
animation-delay: calc(var(--i) * #{$stagger} + var(--n) * 70ms); // 노이즈로 파도가 기계적으로 안 맞게
}
}
@keyframes wave-up {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16px); }
}
@keyframes letter-blur-in {
0%, 100% { filter: blur(0); }
50% { filter: blur(3px); } // 정점에서 살짝 흐려져 움직임 잔상처럼 보인다
}
@media (prefers-reduced-motion: reduce) {
.wave span { animation: none; transform: none; filter: none; }
}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);
}
.wave {
margin: 0;
display: flex;
gap: clamp(2px, 1vw, 6px);
font-weight: 800;
font-size: clamp(28px, 10vw, 46px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.wave span {
display: inline-block;
animation: wave-up 2s ease-out infinite, letter-blur-in 2s ease-out infinite;
animation-delay: calc(var(--i) * 90ms + var(--n) * 70ms);
}
@keyframes wave-up {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-16px);
}
}
@keyframes letter-blur-in {
0%, 100% {
filter: blur(0);
}
50% {
filter: blur(3px);
}
}
@media (prefers-reduced-motion: reduce) {
.wave span {
animation: none;
transform: none;
filter: none;
}
}(function(){
function splitLetters(el){
var text = el.textContent;
el.textContent = "";
Array.from(text).forEach(function(ch, i){
var span = document.createElement("span");
span.className = "letter";
var seed = Math.sin((i + 1) * 12.9898) * 43758.5453;
var noise = seed - Math.floor(seed);
span.style.setProperty("--i", i);
span.style.setProperty("--n", noise.toFixed(3));
span.textContent = ch === " " ? " " : ch;
el.appendChild(span);
});
}
document.querySelectorAll("[data-split]").forEach(splitLetters);
})();import type { CSSProperties } from "react";
import styles from "./WaveText.module.scss";
type Vars = CSSProperties & { "--i"?: number; "--n"?: number };
function noise(i: number): number {
const s = Math.sin((i + 1) * 12.9898) * 43758.5453;
return Number((s - Math.floor(s)).toFixed(3));
}
export type WaveTextProps = {
letters: string[];
/** 글자들이 한 바퀴 도는 시간 ms (기본 2000) */
duration?: number;
/** --i 1당 시차 ms (기본 90ms) — 이 간격이 파도를 만든다 */
stagger?: number;
color?: string;
};
export default function WaveText({ letters, duration = 2000, color = "#17141a" }: WaveTextProps) {
return (
<h1 className={styles.wave} style={{ "--color": color, "--duration": `${duration}ms` } as CSSProperties}>
{letters.map((ch, i) => (
<span key={i} style={{ "--i": i, "--n": noise(i) } as Vars}>{ch}</span>
))}
</h1>
);
}06 · 물결치는 텍스트 — 측정 누적면적 6.247% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.wave span {
animation: wave-up 2s ease-out infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes wave-up {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16px); }
}
07흔들리는 강조
css 텍스트 강조 애니메이션은 언제 쓰나
문구 전체를 rotate 1~2도와 translateX 2px 사이에서 아주 짧게 왕복시키면 에러 흔들림보다 훨씬 약하게 시선만 붙잡습니다. 기존 흔들림(wiggle)은 그대로 두고, 등장할 때 글자마다 블러가 풀리는 감쇠가 먼저 한 번 지나간 뒤에 흔들림이 이어지도록 겹을 하나 더 얹었습니다.
어디에 쓰나
루프의 0~60%는 완전히 정지해 있다가 66~90% 구간에서만 흔들리므로, 늘 움직이는 배경 속에서도 그 순간만 튀어 보입니다.

<main class="stage">
<p class="wiggle" data-split>신규 입고</p>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-out;
$angle: 2deg; // 회전 폭 — 에러 흔들림보다 훨씬 작다
@include stage($stage-orange);
.wiggle {
margin: 0;
font-weight: 800; font-size: clamp(26px, 8vw, 40px); line-height: 1.2; font-family: inherit;
color: $subject-cream;
text-align: center;
animation: wiggle $duration $easing infinite;
}
.wiggle .letter {
display: inline-block;
animation: letter-blur-in $duration $easing infinite;
animation-delay: calc(var(--i) * 35ms + var(--n) * 60ms);
}
@keyframes wiggle {
0%, 60%, 100% { transform: rotate(0) translateX(0); }
66% { transform: rotate(-#{$angle}) translateX(-2px); }
72% { transform: rotate(#{$angle}) translateX(2px); }
78% { transform: rotate(-#{$angle}) translateX(-2px); }
84% { transform: rotate(#{$angle}) translateX(2px); }
90% { transform: rotate(0) translateX(0); }
}
@keyframes letter-blur-in {
0%, 4% { filter: blur(8px); opacity: .3; }
22%, 100% { filter: blur(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.wiggle { animation: none; transform: none; }
.wiggle .letter { animation: none; filter: none; opacity: 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);
}
.wiggle {
margin: 0;
font-weight: 800;
font-size: clamp(26px, 8vw, 40px);
line-height: 1.2;
font-family: inherit;
color: #fff7e6;
text-align: center;
animation: wiggle 2s ease-out infinite;
}
.wiggle .letter {
display: inline-block;
animation: letter-blur-in 2s ease-out infinite;
animation-delay: calc(var(--i) * 35ms + var(--n) * 60ms);
}
@keyframes wiggle {
0%, 60%, 100% {
transform: rotate(0) translateX(0);
}
66% {
transform: rotate(-2deg) translateX(-2px);
}
72% {
transform: rotate(2deg) translateX(2px);
}
78% {
transform: rotate(-2deg) translateX(-2px);
}
84% {
transform: rotate(2deg) translateX(2px);
}
90% {
transform: rotate(0) translateX(0);
}
}
@keyframes letter-blur-in {
0%, 4% {
filter: blur(8px);
opacity: 0.3;
}
22%, 100% {
filter: blur(0);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.wiggle {
animation: none;
transform: none;
}
.wiggle .letter {
animation: none;
filter: none;
opacity: 1;
}
}(function(){
function splitLetters(el){
var text = el.textContent;
el.textContent = "";
Array.from(text).forEach(function(ch, i){
var span = document.createElement("span");
span.className = "letter";
var seed = Math.sin((i + 1) * 12.9898) * 43758.5453;
var noise = seed - Math.floor(seed);
span.style.setProperty("--i", i);
span.style.setProperty("--n", noise.toFixed(3));
span.textContent = ch === " " ? " " : ch;
el.appendChild(span);
});
}
document.querySelectorAll("[data-split]").forEach(splitLetters);
})();import type { CSSProperties } from "react";
import styles from "./WiggleEmphasis.module.scss";
type Vars = CSSProperties & { "--i"?: number; "--n"?: number };
function noise(i: number): number {
const s = Math.sin((i + 1) * 12.9898) * 43758.5453;
return Number((s - Math.floor(s)).toFixed(3));
}
export type WiggleEmphasisProps = {
text: string;
/** 왕복 한 주기 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function WiggleEmphasis({ text, duration = 2000, color = "#fff7e6" }: WiggleEmphasisProps) {
const vars = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
const chars = Array.from(text);
return (
<p className={styles.wiggle} style={vars}>
{chars.map((ch, i) => (
<span key={i} className={styles.letter} style={{ "--i": i, "--n": noise(i) } as Vars}>
{ch === " " ? " " : ch}
</span>
))}
</p>
);
}07 · 흔들리는 강조 — 측정 누적면적 4.14% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.wiggle {
animation: wiggle 2s ease-out infinite;
}
@keyframes wiggle {
0%, 60%, 100% { transform: rotate(0) translateX(0); }
66% { transform: rotate(-2deg) translateX(-2px); }
72% { transform: rotate(2deg) translateX(2px); }
78% { transform: rotate(-2deg) translateX(-2px); }
84% { transform: rotate(2deg) translateX(2px); }
90% { transform: rotate(0) translateX(0); }
}
08두 색 스윕
어떻게 움직이나
같은 문구를 두 겹으로 쌓아 아래 겹은 고정 색을, 위 겹은 다른 색을 칠한 뒤 위 겹에만 clip-path를 왼쪽에서 오른쪽으로 걷어내는 keyframes를 걸면 그라데이션이 아니라 뚜렷한 두 색 경계로 색이 칠해지는 것처럼 보입니다. 기존 스윕(clip-path 와이프)은 그대로 두고, 글자마다 --n 노이즈로 흩어진 진입 블러 감쇠를 추가로 얹었습니다.
어디에 쓰나
가격표나 캠페인 카피처럼 한순간 시선을 끌어야 하는 자리에 씁니다.

<main class="stage">
<div class="sweep">
<span class="sweep__base" data-split>반값 세일</span>
<span class="sweep__top" aria-hidden="true" data-split>반값 세일</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$base: $subject-ink; // 아래 색
$sweep: $subject-blue; // 위에서 걷히는 색
@include stage($stage-paper);
.sweep {
position: relative;
font-weight: 800; font-size: clamp(26px, 8.4vw, 42px); line-height: 1.2; font-family: inherit;
}
.sweep__base, .sweep__top {
display: block;
margin: 0;
text-align: center;
}
.sweep__base { color: $base; }
.sweep__top {
position: absolute;
inset: 0;
color: $sweep;
animation: sweep-wipe $duration $easing infinite;
}
.sweep .letter {
display: inline-block;
animation: letter-blur-in $duration $easing infinite;
animation-delay: calc(var(--i) * 30ms + var(--n) * 50ms);
}
@keyframes sweep-wipe {
0%, 4% { clip-path: inset(0 100% 0 0); }
40%, 62% { clip-path: inset(0 0 0 0); }
96%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes letter-blur-in {
0%, 2% { filter: blur(9px); }
14%, 100% { filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
.sweep__top { animation: none; clip-path: inset(0 0 0 0); }
.sweep .letter { animation: none; filter: none; }
}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);
}
.sweep {
position: relative;
font-weight: 800;
font-size: clamp(26px, 8.4vw, 42px);
line-height: 1.2;
font-family: inherit;
}
.sweep__base, .sweep__top {
display: block;
margin: 0;
text-align: center;
}
.sweep__base {
color: #17141a;
}
.sweep__top {
position: absolute;
inset: 0;
color: #2f6df6;
animation: sweep-wipe 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.sweep .letter {
display: inline-block;
animation: letter-blur-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: calc(var(--i) * 30ms + var(--n) * 50ms);
}
@keyframes sweep-wipe {
0%, 4% {
clip-path: inset(0 100% 0 0);
}
40%, 62% {
clip-path: inset(0 0 0 0);
}
96%, 100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes letter-blur-in {
0%, 2% {
filter: blur(9px);
}
14%, 100% {
filter: blur(0);
}
}
@media (prefers-reduced-motion: reduce) {
.sweep__top {
animation: none;
clip-path: inset(0 0 0 0);
}
.sweep .letter {
animation: none;
filter: none;
}
}(function(){
function splitLetters(el){
var text = el.textContent;
el.textContent = "";
Array.from(text).forEach(function(ch, i){
var span = document.createElement("span");
span.className = "letter";
var seed = Math.sin((i + 1) * 12.9898) * 43758.5453;
var noise = seed - Math.floor(seed);
span.style.setProperty("--i", i);
span.style.setProperty("--n", noise.toFixed(3));
span.textContent = ch === " " ? " " : ch;
el.appendChild(span);
});
}
document.querySelectorAll("[data-split]").forEach(splitLetters);
})();import type { CSSProperties } from "react";
import styles from "./TwoToneSweep.module.scss";
type Vars = CSSProperties & { "--i"?: number; "--n"?: number };
function noise(i: number): number {
const s = Math.sin((i + 1) * 12.9898) * 43758.5453;
return Number((s - Math.floor(s)).toFixed(3));
}
export type TwoToneSweepProps = {
text: string;
/** 스윕 한 주기 (기본 2000ms) */
duration?: number;
base?: string;
sweep?: string;
};
export default function TwoToneSweep({ text, duration = 2000, base = "#17141a", sweep = "#2f6df6" }: TwoToneSweepProps) {
const vars = { "--duration": `${duration}ms`, "--base": base, "--sweep": sweep } as CSSProperties;
const chars = Array.from(text);
const renderLetters = () =>
chars.map((ch, i) => (
<span key={i} className={styles.letter} style={{ "--i": i, "--n": noise(i) } as Vars}>
{ch === " " ? " " : ch}
</span>
));
return (
<div className={styles.sweep} style={vars}>
<span className={styles.base}>{renderLetters()}</span>
<span className={styles.top} aria-hidden="true">{renderLetters()}</span>
</div>
);
}08 · 두 색 스윕 — 측정 누적면적 6.474% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.sweep__top {
position: absolute;
inset: 0;
animation: sweep-wipe 2s cubic-bezier(.2, .8, .2, 1) infinite;
}
@keyframes sweep-wipe {
0%, 4% { clip-path: inset(0 100% 0 0); }
40%, 62% { clip-path: inset(0 0 0 0); }
96%, 100% { clip-path: inset(0 100% 0 0); }
}
09글리치
어떻게 움직이나
같은 문구를 색만 다르게 겹친 두 겹에 mix-blend-mode: screen과 clip-path를 걸어 짧게 translateX로 어긋나게 했다가 되돌리면, 화면이 순간 깨진 듯한 글리치 효과가 납니다. 기존 RGB 분리 글리치(glitch-r·glitch-b)는 그대로 두고, 글자마다 --n 노이즈로 흩어진 짧은 진입 블러 감쇠를 추가했습니다.
어디에 쓰나
어긋남을 0~24%, 50~66% 두 구간에 나눠 넣어야 한 번의 짧은 튐이 아니라 두 번 반복되는 리듬으로 보입니다.

<main class="stage">
<div class="glitch">
<span class="glitch__base" data-split>오류 발생</span>
<span class="glitch__r" aria-hidden="true" data-split>오류 발생</span>
<span class="glitch__b" aria-hidden="true" data-split>오류 발생</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 글리치 두 번이 도는 한 주기
$easing: linear;
$base: $subject-cream;
@include stage($stage-ink);
.glitch {
position: relative;
font-weight: 800; font-size: clamp(26px, 8.4vw, 42px); line-height: 1.2; font-family: inherit;
text-align: center;
}
.glitch__base, .glitch__r, .glitch__b {
display: block;
margin: 0;
}
.glitch__base { color: $base; }
.glitch__r, .glitch__b {
position: absolute;
inset: 0;
mix-blend-mode: screen;
}
.glitch__r { color: $error; animation: glitch-r $duration $easing infinite; }
.glitch__b { color: $subject-blue; animation: glitch-b $duration $easing infinite; }
.glitch .letter {
display: inline-block;
animation: letter-blur-in $duration $ease-out infinite;
animation-delay: calc(var(--i) * 25ms + var(--n) * 50ms);
}
@keyframes glitch-r {
0% { transform: translateX(0); clip-path: inset(0 0 0 0); }
8% { transform: translateX(-6px); clip-path: inset(12% 0 58% 0); }
16% { transform: translateX(4px); clip-path: inset(48% 0 14% 0); }
24% { transform: translateX(0); clip-path: inset(0 0 0 0); }
50% { transform: translateX(-4px); clip-path: inset(20% 0 40% 0); }
58% { transform: translateX(5px); clip-path: inset(55% 0 8% 0); }
66% { transform: translateX(0); clip-path: inset(0 0 0 0); }
100% { transform: translateX(0); clip-path: inset(0 0 0 0); }
}
@keyframes glitch-b {
0% { transform: translateX(0); clip-path: inset(0 0 0 0); }
8% { transform: translateX(6px); clip-path: inset(58% 0 10% 0); }
16% { transform: translateX(-4px); clip-path: inset(14% 0 48% 0); }
24% { transform: translateX(0); clip-path: inset(0 0 0 0); }
50% { transform: translateX(4px); clip-path: inset(40% 0 22% 0); }
58% { transform: translateX(-5px); clip-path: inset(8% 0 55% 0); }
66% { transform: translateX(0); clip-path: inset(0 0 0 0); }
100% { transform: translateX(0); clip-path: inset(0 0 0 0); }
}
@keyframes letter-blur-in {
0%, 2% { filter: blur(8px); }
10%, 100% { filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
.glitch__r, .glitch__b { animation: none; opacity: 0; }
.glitch .letter { animation: none; filter: none; }
}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);
}
.glitch {
position: relative;
font-weight: 800;
font-size: clamp(26px, 8.4vw, 42px);
line-height: 1.2;
font-family: inherit;
text-align: center;
}
.glitch__base, .glitch__r, .glitch__b {
display: block;
margin: 0;
}
.glitch__base {
color: #fff7e6;
}
.glitch__r, .glitch__b {
position: absolute;
inset: 0;
mix-blend-mode: screen;
}
.glitch__r {
color: #c2361a;
animation: glitch-r 2s linear infinite;
}
.glitch__b {
color: #2f6df6;
animation: glitch-b 2s linear infinite;
}
.glitch .letter {
display: inline-block;
animation: letter-blur-in 2s ease-out infinite;
animation-delay: calc(var(--i) * 25ms + var(--n) * 50ms);
}
@keyframes glitch-r {
0% {
transform: translateX(0);
clip-path: inset(0 0 0 0);
}
8% {
transform: translateX(-6px);
clip-path: inset(12% 0 58% 0);
}
16% {
transform: translateX(4px);
clip-path: inset(48% 0 14% 0);
}
24% {
transform: translateX(0);
clip-path: inset(0 0 0 0);
}
50% {
transform: translateX(-4px);
clip-path: inset(20% 0 40% 0);
}
58% {
transform: translateX(5px);
clip-path: inset(55% 0 8% 0);
}
66% {
transform: translateX(0);
clip-path: inset(0 0 0 0);
}
100% {
transform: translateX(0);
clip-path: inset(0 0 0 0);
}
}
@keyframes glitch-b {
0% {
transform: translateX(0);
clip-path: inset(0 0 0 0);
}
8% {
transform: translateX(6px);
clip-path: inset(58% 0 10% 0);
}
16% {
transform: translateX(-4px);
clip-path: inset(14% 0 48% 0);
}
24% {
transform: translateX(0);
clip-path: inset(0 0 0 0);
}
50% {
transform: translateX(4px);
clip-path: inset(40% 0 22% 0);
}
58% {
transform: translateX(-5px);
clip-path: inset(8% 0 55% 0);
}
66% {
transform: translateX(0);
clip-path: inset(0 0 0 0);
}
100% {
transform: translateX(0);
clip-path: inset(0 0 0 0);
}
}
@keyframes letter-blur-in {
0%, 2% {
filter: blur(8px);
}
10%, 100% {
filter: blur(0);
}
}
@media (prefers-reduced-motion: reduce) {
.glitch__r, .glitch__b {
animation: none;
opacity: 0;
}
.glitch .letter {
animation: none;
filter: none;
}
}(function(){
function splitLetters(el){
var text = el.textContent;
el.textContent = "";
Array.from(text).forEach(function(ch, i){
var span = document.createElement("span");
span.className = "letter";
var seed = Math.sin((i + 1) * 12.9898) * 43758.5453;
var noise = seed - Math.floor(seed);
span.style.setProperty("--i", i);
span.style.setProperty("--n", noise.toFixed(3));
span.textContent = ch === " " ? " " : ch;
el.appendChild(span);
});
}
document.querySelectorAll("[data-split]").forEach(splitLetters);
})();import type { CSSProperties } from "react";
import { useEffect, useRef } from "react";
import styles from "./GlitchText.module.scss";
type Vars = CSSProperties & { "--i"?: number; "--n"?: number };
function noise(i: number): number {
const s = Math.sin((i + 1) * 12.9898) * 43758.5453;
return Number((s - Math.floor(s)).toFixed(3));
}
export type GlitchTextProps = {
text: string;
/** 글리치 두 번이 도는 한 주기 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function GlitchText({ text, duration = 2000, color = "#fff7e6" }: GlitchTextProps) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
// reduced-motion 이면 재생 자체를 걸지 않는다 (el.animate() 를 쓰는 항목의 정리 규칙과 같다)
return () => ref.current?.getAnimations().forEach((a) => a.cancel());
}, []);
const vars = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
const chars = Array.from(text);
const renderLetters = () =>
chars.map((ch, i) => (
<span key={i} className={styles.letter} style={{ "--i": i, "--n": noise(i) } as Vars}>
{ch === " " ? " " : ch}
</span>
));
return (
<div ref={ref} className={styles.glitch} style={vars}>
<span className={styles.base}>{renderLetters()}</span>
<span className={styles.r} aria-hidden="true">{renderLetters()}</span>
<span className={styles.b} aria-hidden="true">{renderLetters()}</span>
</div>
);
}09 · 글리치 — 측정 누적면적 6.18% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.glitch__r { mix-blend-mode: screen; animation: glitch-r 2s linear infinite; }
@keyframes glitch-r {
0% { transform: translateX(0); clip-path: inset(0 0 0 0); }
8% { transform: translateX(-6px); clip-path: inset(12% 0 58% 0); }
16% { transform: translateX(4px); clip-path: inset(48% 0 14% 0); }
24% { transform: translateX(0); clip-path: inset(0 0 0 0); }
50% { transform: translateX(-4px); clip-path: inset(20% 0 40% 0); }
58% { transform: translateX(5px); clip-path: inset(55% 0 8% 0); }
66% { transform: translateX(0); clip-path: inset(0 0 0 0); }
}
어디서 깨지는가 — 함정 하나
시차는 어디에 있어야 하나
--i 변수와 calc()로 시차를 계산하는 아홉 개 전부는 계산이 부모가 아니라 글자 자신의 animation-delay에 걸려 있습니다. 그래서 글자 수가 늘어나도 SCSS를 손댈 필요가 없다는 장점이 있는 대신, 부모 요소에 opacity 트랜지션을 따로 걸어 두면 그 트랜지션은 --i를 모르기 때문에 글자마다 다른 시점에 시작하지 않고 전부 동시에 움직입니다. 시차는 언제나 글자 자신의 animation에만 있어야 합니다.
밝은 무대에서 글리치가 사라진다
두 번째는 밝기입니다. 09 글리치는 mix-blend-mode: screen으로 빨강·파랑 두 겹을 겹쳐 색이 갈라지는 것처럼 보이게 하는데, MDN의 mix-blend-mode 문서에 정리돼 있듯 screen은 배경이 밝을수록 두 색을 하얗게 섞어 지웁니다. 그래서 09는 어두운 무대(ink)에만 뒀고, 밝은 무대에 옮겨 쓰려면 screen 대신 difference나 불투명도가 있는 단색 겹으로 바꿔야 합니다.
프레임으로 잰 차이
실제로 렌더링해 측정해 보니 06 물결은 24프레임 중 23프레임에서 움직임이 잡혔지만 07 흔들리는 강조는 15프레임에서 잡혔는데, 원래는 7프레임에 그쳤던 것이 글자별 블러 감쇠 겹이 추가되면서 흔들림 구간 바깥에서도 픽셀이 바뀌는 프레임이 늘어난 결과입니다. 이 차이까지 반영해 다듬은 아홉 개 파일의 압축 비밀번호는 97me82wy이고 아래 버튼으로 받을 수 있습니다. 글자를 겹겹이 붙이는 미학까지 가면 맥시멀리즘 콜라주 랜딩 키트가 그 넘침을 섹션 단위로 만들어 둔 편입니다.
스크롤이 빠르면 못 본다
흔들리는 강조처럼 좁은 구간에만 움직임을 몰아넣는 효과는 스크롤을 빠르게 내리는 사람에게는 아예 안 보일 수 있습니다. 화면에 들어오자마자 한 번만 재생하고 끝내기보다는, 지금처럼 2초 주기로 반복하거나 등장 직후 짧은 시간 안에 몰아서 재생해야 놓치지 않습니다.
접근성
동작 줄이기에서
아홉 개 전부 이제 data-split로 글자를 나누고 등장 시 블러→포커스 감쇠 겹을 얹었는데, prefers-reduced-motion: reduce에서는 이 겹까지 포함해 keyframes 애니메이션을 모두 끄고 최종 상태만 즉시 보여줍니다. 05 블러는 opacity 1·filter 없음으로 바로 보이고, 01 마스크와 06 물결처럼 transform만 움직이던 항목은 transform 없음으로 제자리에 멈추며, 02·03·04처럼 처음부터 숨어 있던 글자는 opacity 1·transform 없음·filter 없음으로 한 번에 자리를 잡습니다.
장식과 정보의 차이
07 흔들리는 강조와 09 글리치처럼 정보 전달이 아니라 순수하게 시선을 끄는 장식만 담당하는 움직임은 reduce에서 완전히 꺼도 되지만, 08 두 색 스윕처럼 색 자체가 강조 정보인 경우는 스윕 없이도 최종 색이 바로 보이도록 clip-path를 처음부터 0으로 고정했습니다. 세부 처리 범위는 MDN의 prefers-reduced-motion 문서에서 확인할 수 있습니다.
@media (prefers-reduced-motion: reduce) {
.mask__text, .blur, .wiggle { animation: none; transform: none; filter: none; opacity: 1; }
.letters span, .rot span, .flip span, .wave span { animation: none; opacity: 1; transform: none; }
.sweep__top { animation: none; clip-path: inset(0 0 0 0); }
.glitch__r, .glitch__b { animation: none; opacity: 0; }
}
다른 텍스트·타이핑 관련 글은 CSS 애니메이션 카테고리 안에도 있고, 이 블로그가 데모를 어떻게 만들고 검증하는지는 소개 페이지에 있습니다.
FAQ
CSS만으로 텍스트 애니메이션을 만들 수 있습니까?
네, 애니메이션 자체는 아홉 개 전부 자바스크립트 없이 CSS keyframes만으로 동작합니다. 다만 글자를 나누는 작업은 이제 data-split 속성을 붙이면 페이지가 열릴 때 짧은 런타임 스크립트가 텍스트 길이에 맞춰 자동으로 <span>과 --i·--n 값을 매겨 주고, 애니메이션이 시작하는 순간이 되면 스크립트 역할은 끝나고 그 뒤는 CSS keyframes가 전부 맡습니다. 움직임의 대상이 글자가 아니라 그림이라면 CSS 이미지 호버 효과 9종이 같은 자리를 사진 쪽에서 맡습니다.
css 애니메이션 시간차는 글자 수가 달라지면 얼마나 고쳐야 합니까?
손으로 span을 고칠 필요가 없습니다. data-split이 붙은 요소는 글자 수가 몇 개든 스크립트가 텍스트 길이에 맞춰 자동으로 span·--i·--n을 만들어 주기 때문에, 번역마다 글자 수가 달라지는 영어·일본어·베트남어 사본도 그대로 둡니다. animation-delay: calc(var(--i) * 시차 + var(--n) * 지터) 한 줄이 글자 수와 상관없이 시차를 계산하기 때문에 keyframes나 SCSS 쪽도 손댈 필요가 없습니다.
애니메이션 속도를 조절하려면 어디를 고칩니까?
각 항목 SCSS의 animation 줄에 적힌 재생 시간 값을 바꾸면 됩니다. 02·03·04·06처럼 --i를 쓰는 항목은 $stagger 값도 함께 있어서, 전체 속도와 글자 사이 시차를 따로 조절할 수 있습니다.









