9 CSS Text Animation Effects, One Line, Copy-Paste
These nine css text animation effects move a heading into view, into color, or into a short wiggle using only transform, opacity, and filter — no JavaScript,
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Mask reveal from below
- 02 Letter-by-letter bounce
- 03 Rotate-in entrance
- 04 3D letter flip
- 05 Blur to focus
- 06 Wave text
- 07 Wiggle emphasis
- 08 Two-tone sweep
- 09 Glitch
The order below follows what the text itself goes through rather than a popularity ranking. Items 01 through 05 are first-appearance treatments — a mask lift, a letter bounce, a rotate-in, a 3D flip, and a blur-to-focus. Item 06 is built to keep looping once a heading is already on screen, items 07 and 08 pull a second look toward text that never left, and item 09 closes the set by making a phrase look like it briefly broke. Every demo below sits inside a small, non-scrollable iframe and already loops on a fixed cycle, since none of these nine wait on a pointer. Watch the grid above for a couple of seconds and each one has already played through. For sentences that type themselves out instead, that's the CSS typing effects set, and for the background doing the moving while the words hold still, the CSS background animations carry that half.
01Mask reveal from below
How it works
The heading sits inside a box clipped with overflow: hidden, and only the text inside slides up from translateY(115%) to its resting position on a repeating cycle. Because the box itself never moves, nothing else on the page shifts while the reveal happens — each letter inside that text is now split into its own span at load time and sharpens out of a blur as it rises, staggered by its --i index and a per-letter --n noise value so the letters don't all snap into focus together.
Where it fits
It reads best as a hero headline's first entrance or a section title that reveals itself once scrolled into view.

<main class="stage">
<div class="mask">
<h1 class="mask__text" data-split>Your Story</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 · Mask reveal from below — Measured area 5.417% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
.mask {
overflow: hidden;
max-width: min(380px, 90vw);
}
.mask__text {
color: $color;
animation: mask-up $duration $easing infinite;
}
@keyframes mask-up {
0%, 4% { transform: translateY(115%); }
30%, 78% { transform: translateY(0); }
96%, 100% { transform: translateY(115%); }
}
02Letter-by-letter bounce
How it works
Each letter of the heading lives in its own inline span, and a single --i custom property multiplied by 40ms inside animation-delay staggers every letter's identical bounce keyframes. Because the delay math lives on the letters themselves rather than a parent element, adding or removing letters never touches the keyframes — a second keyframe layered onto that same span now sharpens each letter out of a blur while it bounces, offset by a --n noise value so the focus doesn't resolve at the exact instant every letter lands.
Where it fits
It suits a logo intro or a landing hero title where the letters should feel like they're arriving one at a time.

<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 · Letter-by-letter bounce — Measured area 8.615% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
.letters span {
display: inline-block;
animation: letter-bounce $duration $easing 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); }
}
03Rotate-in entrance
How it works
Every letter transitions rotate and opacity together, starting tilted at -25 degrees and fully transparent before settling flat at 0 degrees and full opacity, each one delayed 90ms further than the last through the same --i variable used in item 02. The tilt reads as a letter flying in slightly off-balance rather than sliding in a straight line — that same span also runs a blur-to-focus keyframe alongside the rotation, so each letter is still slightly soft when it starts tilting and only reaches full sharpness near the end of its swing.
Where it fits
Badge labels and short card titles are a natural fit, since the effect reads clearly even across just a handful of characters.

<main class="stage">
<h1 class="rot" data-split>New Arrival</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 · Rotate-in entrance — Measured area 4.82% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
.rot span {
animation: rotate-in $duration $easing 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 letter flip
How it works
Inside a parent carrying perspective, each letter's own backface-visibility: hidden lets rotateX swing it from 90 degrees down to 0, so every character flips into place like a tiny card turning face-up. The perspective lives on the shared parent while the rotation and its --i delay live on each letter individually — each letter now sharpens out of a blur over that same flip, so a character reads as a soft smear mid-turn and only snaps into full focus once it's lying flat facing forward.
Where it fits
A price that just updated and a game-style title card both benefit from the flip reading as a discrete change rather than a fade.

<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 letter flip — Measured area 3.824% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
.flip {
perspective: 480px;
}
.flip span {
backface-visibility: hidden;
animation: flip-in $duration $easing 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); }
}
05Blur to focus
How it works
filter: blur runs from 14px down to 0 while opacity climbs from 0 to 1 in the same keyframes block, so the heading looks like a lens racking into focus rather than simply fading in. Position never moves — only sharpness and visibility change, which keeps the layout completely still while the text resolves, and that same blur-focus keyframe now runs on each letter's own span instead of the whole phrase at once, so every character racks into focus at a slightly different moment set by its --i and --n values rather than the entire heading snapping sharp on one frame.
Where it fits
Modal titles and photo captions that need to feel deliberate, not instant, are where this one earns its place.

<main class="stage">
<h1 class="blur" data-split>Today's Moment</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 · Blur to focus — Measured area 6.333% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
.blur {
animation: blur-focus $duration $easing 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); }
}
06Wave text
Keeping a css text animation loop running
Every letter shares one wave-up keyframes block that moves translateY between 0 and -16px, and only the --i-based animation-delay tells each letter when to start its own copy of that identical motion. Because there's no entrance or exit built in, the line just keeps rippling for as long as it stays on screen — a light blur now rides along near the top of each letter's arc too, easing in and back out right at the crest of its bounce so the peak reads faintly like motion blur rather than a sharp bounce.
Where it fits
It's built for a loading hint or empty-state text that needs to feel alive while someone waits.

<main class="stage">
<h1 class="wave" data-split>Please Wait</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 · Wave text — Measured area 6.247% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.wave span {
animation: wave-up $duration $easing infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes wave-up {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16px); }
}
07Wiggle emphasis
How it works
The whole phrase sits dead still for the first 60% of the loop, then rocks between rotate(-2deg) and translateX(-2px) and their mirrored opposites for a handful of short steps before settling flat again. That long pause between wiggles is deliberate — it reads as an occasional nudge rather than a constant tremor, and before any of that wiggling starts each letter now runs a one-time blur-to-focus pass on entrance so the phrase resolves into sharpness first.
Where it fits
A hint next to a new badge or a discount label that needs a glance, not a stare, is the right home for it.

<main class="stage">
<p class="wiggle" data-split>New Arrival</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 · Wiggle emphasis — Measured area 4.14% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
.wiggle {
animation: wiggle $duration $easing 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); }
}
08Two-tone sweep
How it works
The same phrase is stacked twice in flat, solid colors, and only the top copy's clip-path wipes from fully hidden to fully shown left-to-right, so the color underneath appears to get painted on rather than blended into it. There's no gradient anywhere in the mix — the edge between the two colors stays hard the entire time, and both stacked copies are now split into per-letter spans too, each one blurring in on entrance before the clip-path wipe reaches it without softening that hard edge.
Where it fits
Pricing highlights and campaign copy that need one sharp burst of color change use it well.

<main class="stage">
<div class="sweep">
<span class="sweep__base" data-split>Half-Price Sale</span>
<span class="sweep__top" aria-hidden="true" data-split>Half-Price Sale</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 · Two-tone sweep — Measured area 6.474% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
.sweep__top {
position: absolute;
inset: 0;
animation: sweep-wipe $duration $easing 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); }
}
09Glitch
How it works
Two color-shifted copies of the same phrase sit in mix-blend-mode: screen, and each one gets clip-path sliced into thin horizontal bands that briefly offset with translateX before snapping back into alignment. The two copies run that offset twice per loop rather than once, which is what makes the break read as an interruption instead of a single stumble — each letter across both color layers also gets a short blur-in on entrance now, so the phrase arrives looking momentarily out of focus before the RGB split kicks in.
Where it fits
Error states and tech-brand logo moments are the two places where a screen genuinely looking broken for an instant actually helps.

<main class="stage">
<div class="glitch">
<span class="glitch__base" data-split>Error Occurred</span>
<span class="glitch__r" aria-hidden="true" data-split>Error Occurred</span>
<span class="glitch__b" aria-hidden="true" data-split>Error Occurred</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 · Glitch — Measured area 6.18% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
.glitch__r, .glitch__b {
position: absolute;
inset: 0;
mix-blend-mode: screen;
}
.glitch__r { animation: glitch-r $duration 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); }
}
Where it breaks — the trap
All nine — every item here now that each one splits its text into per-letter spans — compute their stagger entirely through animation-delay on each letter's own span, driven by that span's own --i value alongside a --n noise value for the blur layer. It's tempting to also drop a plain opacity transition onto the shared parent, the way most css text transition effects are wired, expecting the fade to stagger along with them the same way. Instead every letter changes at once, because a transition sitting on the parent has no --i of its own to read — the stagger only ever exists on whichever element's own animation-delay carries it.
Why glitch needs a dark stage
Item 09's glitch leans on mix-blend-mode: screen to split its two color layers apart, and screen's own blending math lightens toward white wherever the backdrop underneath is already bright. That's exactly why 09 only ships on a dark stage here — drop the same two layers onto a light background and screen washes the color split out to almost nothing. Swapping in mix-blend-mode: difference, or giving each layer a flat, semi-opaque fill instead of screen, is what keeps the glitch visible on a lighter surface.
What the frame counts showed
Measuring all nine after render backs that emphasis up unevenly: item 06's wave kept moving across 23 of its 24 captured frames, while item 07's wiggle moved in 15 of them — higher than the wiggle alone would produce, because the new per-letter blur-in pass changes pixels across additional early frames even though the wiggle itself still spends most of its loop sitting still on purpose. The nine files here already carry that difference tuned in — grab them from the archive, unlocked with 97me82wy entered exactly as it's spelled right in this sentence, nothing added and nothing left out. Carrying the layered-word aesthetic all the way into overflowing sections is what the maximalist collage landing kit does with it.
Accessibility
What each one rests on
All nine turn off their looping keyframes under prefers-reduced-motion: reduce, but each settles on a different resting frame rather than one shared rule. 01's mask and 05's blur snap straight to their fully revealed, fully sharp end state — transform: none and filter: none put the heading exactly where the animation would have left it anyway. 02, 03, and 04 do the same per letter: animation: none paired with opacity: 1 and transform: none puts every span in its resting position at once, with none of the stagger left to see. 06's wave drops to animation: none and transform: none alone — wave never touched opacity to begin with, so there's nothing to restore. 07's wiggle turns off completely rather than freezing on a rotated frame, since it exists purely to catch a glance and carries no information a static heading would lose.
When the outcome must stay
08 is the one built to keep its outcome legible. Its clip-path locks to inset(0 0 0 0) so the swept-in color shows in full rather than freezing mid-wipe, because here the color itself is the point. 09 sets both glitch layers to opacity: 0, leaving only the plain base text on screen with no split-color layers left to confuse anyone. MDN's write-up on the prefers-reduced-motion media feature covers the operating-system settings behind that toggle.
The rest of this site's animation notes live in the CSS category hub, and what actually ships inside the archive — and what doesn't — is spelled out on the about page.
FAQ
Do these css text animation effects still work if the heading's text length changes?
Yes, all nine now. Every item carries a data-split attribute, and a small script that runs once at page load splits its text into per-letter spans, sizing itself to however many characters are actually there and assigning each span its own --i index and --n noise value as it goes — nothing about the span count is fixed ahead of time or hand-written into the markup. A longer or shorter phrase, including this English copy running against a different letter count than the original, just works without anyone touching a single span by hand. The animation itself still runs entirely on CSS keyframes and animation-delay reading those custom properties; the script's only job is generating the spans and their --i/--n values, not driving any of the motion. When the thing under the pointer is a picture rather than a phrase, the CSS image hover effects fill that side of the same slot.
Will these work as tailwind css text animation effects, or do they need SCSS?
The demos compile from SCSS down to plain CSS classes ahead of time, so the shipped rules don't depend on a build tool at all — they sit fine next to Tailwind's own utility classes on the same element. All nine are built from keyframes, so each one still needs a small custom animation defined outside Tailwind's default utility set, since staggered per-letter delays aren't something its utilities generate by themselves.
Is there a css text animation generator inside the zip, or just the nine files?
Just the nine — the archive hands over each effect's SCSS, its compiled CSS, and a React version, rather than a tool for dialing up new variants on demand. Changing the pace or the per-letter delay means opening one file's SCSS variables directly, mainly $duration and $stagger, instead of regenerating the animation from a form. The gap between letters itself is a plain clamp() value inline in the same rule, not a named variable, so that one gets edited directly.









