CSS 文字 アニメーション 9選 — 一行でコピペOK
css 文字 アニメーションは、JavaScriptを使わずCSSのtransform・opacity・filterだけで見出しや文言を動かす小さな演出です。この記事は9種類を選び、6〜25行のコードに絞って実装を確かめました。冒頭のグリッドは記事を開いた瞬間からループ再生され、クリックしても止まりません。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9つの並び順は派手さではなく、文字が画面でたどる時間の流れに合わせています。まず現れる瞬間を担う5つ(01〜05)を置き、現れ終わったあとも回り続ける待機演出(06)を挟んで、すでに見えている文言へ視線だけを引き戻す2つ(07・08)に進み、最後は異常事態を示す崩れ(09)で締めくくりました。9つの css 文字 アニメーション サンプル は、data-split属性を付けるだけで全項目が一文字ずつ自動的にspanへ分割され、--i(順序)と--n(ランダムノイズ)で着地の時差にゆらぎを加えたうえ、登場する瞬間にぼかしから焦点が合う一段が文字ごとに重なります。 文がひとつずつ打たれていく側はCSS タイピング アニメーション 9選で、文字ではなく背景が動く半分はCSS 背景 アニメーション 9選が担います。
01マスクの奥から立ち上がる文字
どう動くか
見出しを包む要素にoverflow: hiddenをかけ、内側の.mask__textだけをtransform: translateYで115%下の位置から0まで押し上げると、箱そのものは動かさずに文字だけがカーテンの奥から現れます。マスクが抜けるのと同時に一文字ずつfilter: blurが晴れていく感衰も重なるので、持ち上がるだけでなくピントも合っていくように見えます。
どこで使うか
ヒーロー見出しの初回登場や、スクロールで現れるセクション見出しに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.mask {
overflow: hidden; // 箱の外に出た分は隠れる
max-width: min(380px, 90vw);
}
.mask__text {
color: $color;
animation: mask-up 2s $ease-spring infinite;
}
@keyframes mask-up {
0%, 4% { transform: translateY(115%); }
30%, 78% { transform: translateY(0); }
96%, 100% { transform: translateY(115%); }
}
02一文字ずつ弾んで着地する見出し
どう動くか
data-split属性を付けるだけで、開いた瞬間にスクリプトが見出しの文字数に合わせて一文字ずつspanへ自動分割し--i(順序)と--n(ランダムノイズ)を振ります。着地の時差はvar(--i)にvar(--n)のゆらぎを足したanimation-delayの一行で作り、弾んで着地するあいだも一文字ずつぼかしが晴れていく感衰が重なります。
どこで使うか
ロゴのイントロやランディングページのヒーロータイトルに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.letters span {
display: inline-block;
animation: letter-bounce 2s $ease-pop 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へ同時に遷移させると、斜めに飛び込んだ文字が一文字ずつ真っすぐに収まって見えます。回転して収まるあいだにも一文字ずつぼかしが晴れる感衰が重なるので、着地の瞬間にピントも合います。
どこで使うか
バッジやタグのような短いラベルの登場に向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.rot span {
animation: rotate-in 2s $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を与え、子のspanにbackface-visibility: hiddenを付けたうえでrotateXを90度から0度へ回すと、カードが手前に倒れ込むように文字が反転して現れます。反転して現れるあいだにも一文字ずつぼかしが晴れる感衰が重なるので、数字が切り替わる瞬間の情報量が増します。
どこで使うか
このデモでは₩290という価格表示を例に、金額が更新された瞬間をはっきり見せています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.flip {
perspective: 480px;
}
.flip span {
backface-visibility: hidden;
animation: flip-in .6s $ease-spring 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へ同時に遷移させていましたが、今はdata-splitで一文字ずつ分割し、--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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.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途切れず波打つ待機テキスト
css アニメーション 文字 流れる ループ
文字ごとに90msずつ時差を付けた同じkeyframesでtranslateYを0と-16pxの間で往復させると、登場も退場もない常時ループの波が生まれます。波の頂点付近ではごくわずかにfilter: blurがかかり、モーションブラーの残像のような一段が重なります。
どこで使うか
読み込み待ちの案内文や、空の状態を飾る文言にちょうど良い密度です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.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一瞬だけ視線を引く小さな揺れ
どう動くか
rotateとtranslateXをたった1〜2度・2pxだけ動かす短い往復を、ループの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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.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で拭き取ると、グラデーションではなくはっきりした二色の境界で色が塗られたように見えます。このスイープに加えて、登場する瞬間には一文字ずつぼかしから焦点が合う感衰も重なります。
どこで使うか
料金表のハイライトやキャンペーンコピーの強調に向いています。

<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 · 2色スイープ — 計測 累積面積 6.474% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.sweep__top {
position: absolute;
inset: 0;
animation: sweep-wipe 2s $ease-spring 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のずれを組み合わせて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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.glitch__r, .glitch__b {
position: absolute;
inset: 0;
mix-blend-mode: screen;
}
.glitch__r { 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()で時差を計算する9つ全ては、data-split属性でスクリプトが自動生成したspanごとに--i(順序)と--n(ランダムノイズ)を振り、時差の計算を親要素ではなく文字自身のanimation-delayに持たせています。文字数が変わってもSCSSはもちろんHTMLも手で触らずに済む代わりに、親の要素側にopacityのトランジションを別で足すと、そのトランジションは--iや--nの値を知らないため全文字が同時に切り替わってしまいます。時差は必ず文字自身のanimationに持たせてください。
明るい舞台では消える
二つ目は明るさです。09のグリッチはmix-blend-mode: screenで赤と青の二層を重ねて色が分離したように見せていますが、MDNのmix-blend-modeの解説にある通りscreenは背景が明るいほど二色を白く溶かして消してしまいます。だからこそ09は暗い舞台(ink)にだけ置き、明るい舞台へ移すならscreenの代わりにdifferenceか不透明度のある単色レイヤーへ変える必要があります。
フレーム数で見た差
実際に計測すると、06の波は24フレーム中23フレームで変化が拾え、07の小さな揺れは登場時に一文字ずつぼかしが晴れる感衰が加わったことで24フレーム中15フレームまで変化が拾えるようになりました(この層を足す前は7フレーム)。この差まで踏まえて整えた9本のzipを開く合言葉は97me82wyで、下のカードから取り出せます。 文字を幾重にも貼り重ねる美学のほうへ進むと、コラージュ デザインのLPパーツ9選がその溢れをセクション単位で作ってあります。
速くスクロールされると
07のように短い区間だけに動きを詰め込む演出は、スクロールを速くめくる人には見えないまま通り過ぎてしまうことがあります。画面に入った瞬間に一度だけ再生して終えるより、今回のように2秒周期で繰り返すか、登場直後の短い時間にまとめて再生したほうが見落とされません。
アクセシビリティ
動きを減らす設定では
prefers-reduced-motion: reduceでは、9つとも最終状態へ即座に切り替わり、要素そのものは消えません。01マスクと05ぼかしも含め9つ全てがdata-splitで文字ごとのspanに分かれ、新しく重ねたletter-blur-inの感衰ごと全てのspanをopacity 1・transformやfilterなしに揃えます。
飾りと情報の切り分け
07の揺れと09のグリッチのように情報を運ばず視線を引くためだけの動きはanimation: noneで完全に止め、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 文字 アニメーション 一文字ずつ の動きを作れますか?
はい、動き自体は9つ全てJavaScriptなしでCSSのkeyframesだけで進みます。ただし文字ごとに分ける下準備は、今は9つ全てがdata-split属性を使っていて、ページが開いた瞬間に短いスクリプトが文字数に合わせて自動でspanへ分割し--i(順序)・--n(ランダムノイズ)を振ります。 動かす対象が文字ではなく画像になる場合は、CSS 画像 ホバー エフェクト 9選が同じ場所を写真の側から担います。
見出しの文字数が変わったら何を直せばいいですか?
何も直さなくて大丈夫です。data-split属性が付いた要素はスクリプトが見出しの文字数を読み取って自動的にspan・--i・--nを作るので、手でspanを増減させたり--iを振り直したりする作業は要りません。animation-delayの計算はvar(--i)とvar(--n)を使う一行のままなので、keyframesやSCSS側も変更しなくて済みます。
再生速度を変えるにはどこを直しますか?
各項目のSCSS冒頭にある$duration相当の値を一つ変えるだけです。9つ全てが--iと--nを使うようになったので、どの項目も時差の間隔は別の数値として並んでおり、全体の速さと文字間の時差を別々に調整できます。









