9 CSS Text Animation Effects for Reveals
A CSS text animation is the short movement a heading or a sentence makes as it arrives on screen.
01 text-reveal-animation-word-stagger-fade
02 text-reveal-animation-diagonal-wipe-mask
03 text-reveal-animation-scale-pop-in
04 text-reveal-animation-line-slide-alternate
05 text-reveal-animation-split-flap
06 text-reveal-animation-scramble-decode
08 text-reveal-animation-stroke-to-fill
09 text-reveal-animation-shadow-build
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Word-by-word fade-up
- 02 Diagonal wipe mask
- 03 Scale pop-in
- 04 Alternating line slide
- 05 Split-flap board
- 06 Scramble decode
- 07 Tracking squeeze
- 08 Outline to fill
- 09 Shadow build
The nine are not ordered by popularity. They follow what a sentence goes through as it reaches the reader: first the quiet entrances that rise or slide in by word and by line (01 to 04), then two louder arrivals — a mechanical flip board and a decoding cipher (05 and 06) — and finally three that work on a heading already sitting there, closing its tracking, filling its outline, or stacking shadows behind it (07 to 09). When the whole panel turns instead of the letters, the card flip animations take over, and card surfaces that answer the cursor are gathered in the CSS card hover effects.
01Word-by-word fade-up
Text reveal animation css, word by word
The sentence breaks into whole words that rise from below one after another, so the eye follows word-sized chunks rather than single letters.

<main class="stage">
<p class="ws-line is-demo">
<span class="ws-word">Ship</span>
<span class="ws-word">It</span>
<span class="ws-word">Beautifully</span>
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.ws-line {
margin: 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: $sp-2;
font: 700 clamp(22px, 6vw, 34px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: $color;
}
.ws-word {
display: inline-block;
opacity: 0;
transform: translateY($sp-4);
filter: blur(var(--blur, 6px));
}
// 데모 루프: 단어가 하나씩 지연을 두고 떠올랐다가 다시 가라앉아 반복 재생된다 (캡처용)
// 한 겹: 단어별 관성 lerp + blur→focus — 등장 구간(0→20%)을 $ease-lerp-settle 로 목표를 살짝 지나쳤다
// 되돌아오게 하고, 단어마다 --blur 깊이(6→9→12px)를 달리해 무거운 단어일수록 늦게 초점이 맞는다.
@keyframes ws-rise {
0% { opacity: 0; transform: translateY($sp-4); filter: blur(var(--blur, 6px)); animation-timing-function: $ease-lerp-settle; }
20%, 80% { opacity: 1; transform: none; filter: blur(0); }
100% { opacity: 0; transform: translateY($sp-4); filter: blur(var(--blur, 6px)); }
}
.ws-line.is-demo .ws-word { animation: ws-rise $duration $easing infinite; }
.ws-line.is-demo .ws-word:nth-child(2) { animation-delay: 160ms; --blur: 9px; }
.ws-line.is-demo .ws-word:nth-child(3) { animation-delay: 320ms; --blur: 12px; }
@media (prefers-reduced-motion: reduce) {
.ws-word,
.ws-line.is-demo .ws-word {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}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);
}
.ws-line {
margin: 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
font: 700 clamp(22px, 6vw, 34px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: #fff7e6;
}
.ws-word {
display: inline-block;
opacity: 0;
transform: translateY(16px);
filter: blur(var(--blur, 6px));
}
@keyframes ws-rise {
0% {
opacity: 0;
transform: translateY(16px);
filter: blur(var(--blur, 6px));
animation-timing-function: linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1);
}
20%, 80% {
opacity: 1;
transform: none;
filter: blur(0);
}
100% {
opacity: 0;
transform: translateY(16px);
filter: blur(var(--blur, 6px));
}
}
.ws-line.is-demo .ws-word {
animation: ws-rise 2s ease-out infinite;
}
.ws-line.is-demo .ws-word:nth-child(2) {
animation-delay: 160ms;
--blur: 9px;
}
.ws-line.is-demo .ws-word:nth-child(3) {
animation-delay: 320ms;
--blur: 12px;
}
@media (prefers-reduced-motion: reduce) {
.ws-word,
.ws-line.is-demo .ws-word {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./WordStaggerFade.module.scss";
export interface WordStaggerFadeProps {
words?: string[];
duration?: string;
easing?: string;
stagger?: string;
}
// 실사용: IntersectionObserver 가 문장이 뷰포트에 들어올 때 한 번 in 을 켠다. 단어마다 transition-delay 만 다르다.
export default function WordStaggerFade({
words = ["Ship", "It", "Beautifully"],
duration = "500ms",
easing = "ease-out",
stagger = "140ms",
}: WordStaggerFadeProps) {
const ref = useRef<HTMLParagraphElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { setInView(true); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, []);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<p className={styles.line} style={style} ref={ref}>
{words.map((w, i) => (
<span
key={w + i}
className={`${styles.word} ${inView ? styles.in : ""}`}
style={{ transitionDelay: inView ? `calc(${stagger} * ${i})` : undefined }}
>
{w}
</span>
))}
</p>
);
}01 · Word-by-word fade-up — Measured area 5.099% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Only the delay changes
Only animation-delay changes from word to word — 0, 160ms, and 320ms — and that stagger is the whole effect. Each entrance runs on an inertial lerp curve that slightly overshoots and settles, while per-word blur depth (6→9→12px) makes heavier words focus later.
.ws-word {
display: inline-block;
opacity: 0;
transform: translateY(16px);
filter: blur(var(--blur, 6px));
}
@keyframes ws-rise {
0% { opacity: 0; transform: translateY(16px); filter: blur(var(--blur, 6px)); animation-timing-function: $ease-lerp-settle; }
20%, 80% { opacity: 1; transform: none; filter: blur(0); }
100% { opacity: 0; transform: translateY(16px); filter: blur(var(--blur, 6px)); }
}
.ws-line.is-demo .ws-word { animation: ws-rise 2s ease-out infinite; }
.ws-line.is-demo .ws-word:nth-child(2) { animation-delay: 160ms; --blur: 9px; }
.ws-line.is-demo .ws-word:nth-child(3) { animation-delay: 320ms; --blur: 12px; }
02Diagonal wipe mask
A slanted edge sweeping across
A slanted edge sweeps across the line and leaves the text behind it, so the line looks cut open on a diagonal rather than lifted like a curtain.

<main class="stage">
<div class="dw-wrap is-demo">
<span class="dw-text">NEW COLLECTION</span>
<span class="dw-mask" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.dw-wrap {
position: relative;
overflow: hidden;
padding: $sp-2 0;
}
.dw-text {
display: block;
font: 800 clamp(20px, 6vw, 30px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: $color;
letter-spacing: .01em;
white-space: nowrap;
}
.dw-mask {
position: absolute;
inset: -20% -10%;
background: $stage-yellow;
transform: skewX(-18deg) translateX(0);
}
// 데모 루프: 무대와 같은 색 마스크가 사선으로 밀려나며 글자를 드러냈다가 다시 덮는다
// 한 겹: 관성 lerp — 마스크가 목표 지점(140%)을 지나쳤다가 되돌아오듯 $ease-lerp-settle 로 정착한다.
@keyframes dw-sweep {
0%, 10% { transform: skewX(-18deg) translateX(0); }
45%, 70% { transform: skewX(-18deg) translateX(140%); }
100% { transform: skewX(-18deg) translateX(0); }
}
.dw-wrap.is-demo .dw-mask { animation: dw-sweep $duration $ease-lerp-settle infinite; }
@media (prefers-reduced-motion: reduce) {
.dw-mask,
.dw-wrap.is-demo .dw-mask {
animation: none !important;
transform: skewX(-18deg) translateX(140%) !important;
}
}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);
}
.dw-wrap {
position: relative;
overflow: hidden;
padding: 8px 0;
}
.dw-text {
display: block;
font: 800 clamp(20px, 6vw, 30px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: #17141a;
letter-spacing: 0.01em;
white-space: nowrap;
}
.dw-mask {
position: absolute;
inset: -20% -10%;
background: #ffd23f;
transform: skewX(-18deg) translateX(0);
}
@keyframes dw-sweep {
0%, 10% {
transform: skewX(-18deg) translateX(0);
}
45%, 70% {
transform: skewX(-18deg) translateX(140%);
}
100% {
transform: skewX(-18deg) translateX(0);
}
}
.dw-wrap.is-demo .dw-mask {
animation: dw-sweep 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.dw-mask,
.dw-wrap.is-demo .dw-mask {
animation: none !important;
transform: skewX(-18deg) translateX(140%) !important;
}
}import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./DiagonalWipeMask.module.scss";
export interface DiagonalWipeMaskProps {
text?: string;
duration?: string;
easing?: string;
}
// 실사용: 뷰포트에 들어오면 같은 배경색 마스크가 사선으로 한 번 밀려나며 글자를 드러낸다.
export default function DiagonalWipeMask({
text = "NEW COLLECTION",
duration = "700ms",
easing = "ease-out",
}: DiagonalWipeMaskProps) {
const ref = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { setInView(true); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, []);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<div className={styles.wrap} style={style} ref={ref}>
<span className={styles.text}>{text}</span>
<span className={`${styles.mask} ${inView ? styles.swept : ""}`} aria-hidden="true" />
</div>
);
}02 · Diagonal wipe mask — Measured area 2.394% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why no seam shows
The mask is painted in the exact stage color, so a plain translateX on a skewX block is enough, and no seam shows. The mask settles on an inertial lerp curve, slightly overshooting its resting point before it comes to rest.
.dw-mask {
position: absolute;
inset: -20% -10%;
background: $stage-yellow;
transform: skewX(-18deg) translateX(0);
}
@keyframes dw-sweep {
0%, 10% { transform: skewX(-18deg) translateX(0); }
45%, 70% { transform: skewX(-18deg) translateX(140%); }
100% { transform: skewX(-18deg) translateX(0); }
}
.dw-wrap.is-demo .dw-mask { animation: dw-sweep 2s $ease-lerp-settle infinite; }
03Scale pop-in
Overshoot, then settle
The heading starts small, overshoots its final size, then settles back with a short bounce.

<main class="stage">
<h2 class="sp-text is-demo">LAUNCHING SOON</h2>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
.sp-text {
margin: 0;
font: 800 clamp(22px, 7vw, 34px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: $color;
text-align: center;
opacity: 0;
transform: scale(.4);
}
// 데모 루프: 작게 눌려 있다가 살짝 커졌다 제자리로 돌아오며 튕기듯 나타난다
// 한 겹: 관성 lerp — 등장 구간(0→18%)을 $ease-lerp-settle 로 목표를 12.6% 넘겼다 되돌아오게 한다.
@keyframes sp-pop {
0% { opacity: 0; transform: scale(.4); animation-timing-function: $ease-lerp-settle; }
18% { opacity: 1; transform: scale(1.1); }
30% { transform: scale(.96); }
40%, 78% { transform: scale(1); opacity: 1; }
100% { opacity: 0; transform: scale(.4); }
}
.sp-text.is-demo { animation: sp-pop $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.sp-text,
.sp-text.is-demo {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}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);
}
.sp-text {
margin: 0;
font: 800 clamp(22px, 7vw, 34px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: #fff7e6;
text-align: center;
opacity: 0;
transform: scale(0.4);
}
@keyframes sp-pop {
0% {
opacity: 0;
transform: scale(0.4);
animation-timing-function: linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1);
}
18% {
opacity: 1;
transform: scale(1.1);
}
30% {
transform: scale(0.96);
}
40%, 78% {
transform: scale(1);
opacity: 1;
}
100% {
opacity: 0;
transform: scale(0.4);
}
}
.sp-text.is-demo {
animation: sp-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.sp-text,
.sp-text.is-demo {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./ScalePopIn.module.scss";
export interface ScalePopInProps {
text?: string;
duration?: string;
easing?: string;
}
// 실사용: 뷰포트에 들어오면 작게 눌려 있던 글자가 한 번 튕기듯 커지며 자리 잡는다.
export default function ScalePopIn({
text = "LAUNCHING SOON",
duration = "550ms",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
}: ScalePopInProps) {
const ref = useRef<HTMLHeadingElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { setInView(true); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, []);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<h2 className={`${styles.text} ${inView ? styles.in : ""}`} style={style} ref={ref}>
{text}
</h2>
);
}03 · Scale pop-in — Measured area 6.413% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
One keyframe and one curve
The overshoot is a single keyframe near the start, and the spring curve cubic-bezier(.2, 1.4, .4, 1) does the rest. The entrance runs on an inertial lerp curve that slightly overshoots the target before settling, so the growth carries a sense of weight.
.sp-text {
opacity: 0;
transform: scale(.4);
}
@keyframes sp-pop {
0% { opacity: 0; transform: scale(.4); animation-timing-function: $ease-lerp-settle; }
18% { opacity: 1; transform: scale(1.1); }
30% { transform: scale(.96); }
40%, 78% { transform: scale(1); opacity: 1; }
100% { opacity: 0; transform: scale(.4); }
}
.sp-text.is-demo { animation: sp-pop 2s cubic-bezier(.2, 1.4, .4, 1) infinite; }
04Alternating line slide
Two lines from opposite sides
In a two-line block the first line comes in from the left and the second from the right, landing in a zigzag.

<main class="stage">
<div class="ls-block is-demo">
<p class="ls-line ls-line--l">DESIGN WITH</p>
<p class="ls-line ls-line--r">INTENTION</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-lerp-settle; // 관성 lerp — 줄마다 목표 위치를 살짝 지나쳤다 정착한다 (2026-09-15 형태 2차 한 겹)
$color: $subject-blue;
@include stage($stage-paper);
.ls-block {
display: grid;
gap: $sp-1;
text-align: center;
}
.ls-line {
margin: 0;
font: 800 clamp(20px, 6vw, 30px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: $color;
opacity: 0;
filter: blur(7px);
}
.ls-line--l { transform: translateX(-$sp-10); }
.ls-line--r { transform: translateX($sp-10); }
// 데모 루프: 홀수 줄은 왼쪽에서, 짝수 줄은 오른쪽에서 밀려 들어와 지그재그로 자리를 잡는다
// 한 겹: 각 줄이 blur(7px)→0 으로 초점이 맞으며 들어온다 — $easing(관성 lerp)과 같은 구간에서 수렴
@keyframes ls-in-l {
0% { opacity: 0; transform: translateX(-$sp-10); filter: blur(7px); }
25%, 80% { opacity: 1; transform: none; filter: blur(0); }
100% { opacity: 0; transform: translateX(-$sp-10); filter: blur(7px); }
}
@keyframes ls-in-r {
0% { opacity: 0; transform: translateX($sp-10); filter: blur(7px); }
25%, 80% { opacity: 1; transform: none; filter: blur(0); }
100% { opacity: 0; transform: translateX($sp-10); filter: blur(7px); }
}
.ls-block.is-demo .ls-line--l { animation: ls-in-l $duration $easing infinite; }
.ls-block.is-demo .ls-line--r { animation: ls-in-r $duration $easing infinite; animation-delay: 160ms; }
@media (prefers-reduced-motion: reduce) {
.ls-line,
.ls-block.is-demo .ls-line {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}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);
}
.ls-block {
display: grid;
gap: 4px;
text-align: center;
}
.ls-line {
margin: 0;
font: 800 clamp(20px, 6vw, 30px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: #2f6df6;
opacity: 0;
filter: blur(7px);
}
.ls-line--l {
transform: translateX(-40px);
}
.ls-line--r {
transform: translateX(40px);
}
@keyframes ls-in-l {
0% {
opacity: 0;
transform: translateX(-40px);
filter: blur(7px);
}
25%, 80% {
opacity: 1;
transform: none;
filter: blur(0);
}
100% {
opacity: 0;
transform: translateX(-40px);
filter: blur(7px);
}
}
@keyframes ls-in-r {
0% {
opacity: 0;
transform: translateX(40px);
filter: blur(7px);
}
25%, 80% {
opacity: 1;
transform: none;
filter: blur(0);
}
100% {
opacity: 0;
transform: translateX(40px);
filter: blur(7px);
}
}
.ls-block.is-demo .ls-line--l {
animation: ls-in-l 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite;
}
.ls-block.is-demo .ls-line--r {
animation: ls-in-r 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite;
animation-delay: 160ms;
}
@media (prefers-reduced-motion: reduce) {
.ls-line,
.ls-block.is-demo .ls-line {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./LineSlideAlternate.module.scss";
export interface LineSlideAlternateProps {
lines?: string[];
duration?: string;
easing?: string;
stagger?: string;
}
// 실사용: 뷰포트에 들어오면 홀수 줄은 왼쪽, 짝수 줄은 오른쪽에서 한 번씩 밀려 들어온다.
export default function LineSlideAlternate({
lines = ["DESIGN WITH", "INTENTION"],
duration = "550ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
stagger = "120ms",
}: LineSlideAlternateProps) {
const ref = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { setInView(true); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, []);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<div className={styles.block} style={style} ref={ref}>
{lines.map((line, i) => (
<p
key={line}
className={`${styles.line} ${i % 2 === 0 ? styles.fromLeft : styles.fromRight} ${inView ? styles.in : ""}`}
style={{ transitionDelay: inView ? `calc(${stagger} * ${i})` : undefined }}
>
{line}
</p>
))}
</div>
);
}04 · Alternating line slide — Measured area 7.392% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
No script for more lines
Two translateX rules of the same 40px with opposite signs and a 160ms gap are the entire mechanism, and a modifier class (--l or --r) sets each line's direction, so more lines need no script. Each line settles on an inertial lerp curve while blur(7px)→0 runs over the same span.
.ls-line--l { transform: translateX(-40px); }
.ls-line--r { transform: translateX(40px); }
@keyframes ls-in-r {
0% { opacity: 0; transform: translateX(40px); filter: blur(7px); }
25%, 80% { opacity: 1; transform: none; filter: blur(0); }
100% { opacity: 0; transform: translateX(40px); filter: blur(7px); }
}
.ls-block.is-demo .ls-line--r {
animation: ls-in-r 2s $ease-lerp-settle infinite;
animation-delay: 160ms;
}
05Split-flap board
Plates flipping to a stop
Each plate flips down like an airport departure board and stops on the right letter.

<main class="stage">
<div class="sf-board is-demo" aria-hidden="true">
<span class="sf-char">S</span>
<span class="sf-char">A</span>
<span class="sf-char">L</span>
<span class="sf-char">E</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.sf-board {
display: flex;
gap: $sp-2;
perspective: 600px;
}
.sf-char {
display: inline-block;
width: clamp(32px, 9vw, 48px);
padding: $sp-2 0;
border-radius: $r-sm;
background: rgba(255, 255, 255, .08);
color: $color;
font: 800 clamp(20px, 6vw, 28px)/1 -apple-system, "Segoe UI", sans-serif;
text-align: center;
transform-origin: center;
transform: rotateX(90deg);
opacity: 0;
filter: blur(3px);
}
// 데모 루프: 글자판이 위에서 아래로 뒤집히며 나타났다가 짧게 위로 되감겨 다시 뒤집힌다 (steps 로 기계적인 느낌)
// 한 겹: blur→focus — 판이 모서리로 서는 중간 지점(edge-on, rotateX 근처)에서 살짝 흐려졌다가 착지하며
// 초점이 맞는다(실제 플랩보드의 모션 블러를 흉내). steps() 타이밍은 기계적 느낌이라 건드리지 않는다.
@keyframes sf-flip {
0% { transform: rotateX(90deg); opacity: 0; filter: blur(3px); }
15% { filter: blur(4px); }
30% { transform: rotateX(0deg); opacity: 1; filter: blur(0); }
90% { transform: rotateX(0deg); opacity: 1; filter: blur(0); }
95% { filter: blur(4px); }
100% { transform: rotateX(90deg); opacity: 0; filter: blur(3px); }
}
.sf-board.is-demo .sf-char { animation: sf-flip $duration steps(3, end) infinite; }
.sf-board.is-demo .sf-char:nth-child(1) { animation-delay: 0ms; }
.sf-board.is-demo .sf-char:nth-child(2) { animation-delay: 120ms; }
.sf-board.is-demo .sf-char:nth-child(3) { animation-delay: 240ms; }
.sf-board.is-demo .sf-char:nth-child(4) { animation-delay: 360ms; }
@media (prefers-reduced-motion: reduce) {
.sf-char,
.sf-board.is-demo .sf-char {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}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);
}
.sf-board {
display: flex;
gap: 8px;
perspective: 600px;
}
.sf-char {
display: inline-block;
width: clamp(32px, 9vw, 48px);
padding: 8px 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #fff7e6;
font: 800 clamp(20px, 6vw, 28px)/1 -apple-system, "Segoe UI", sans-serif;
text-align: center;
transform-origin: center;
transform: rotateX(90deg);
opacity: 0;
filter: blur(3px);
}
@keyframes sf-flip {
0% {
transform: rotateX(90deg);
opacity: 0;
filter: blur(3px);
}
15% {
filter: blur(4px);
}
30% {
transform: rotateX(0deg);
opacity: 1;
filter: blur(0);
}
90% {
transform: rotateX(0deg);
opacity: 1;
filter: blur(0);
}
95% {
filter: blur(4px);
}
100% {
transform: rotateX(90deg);
opacity: 0;
filter: blur(3px);
}
}
.sf-board.is-demo .sf-char {
animation: sf-flip 2s steps(3, end) infinite;
}
.sf-board.is-demo .sf-char:nth-child(1) {
animation-delay: 0ms;
}
.sf-board.is-demo .sf-char:nth-child(2) {
animation-delay: 120ms;
}
.sf-board.is-demo .sf-char:nth-child(3) {
animation-delay: 240ms;
}
.sf-board.is-demo .sf-char:nth-child(4) {
animation-delay: 360ms;
}
@media (prefers-reduced-motion: reduce) {
.sf-char,
.sf-board.is-demo .sf-char {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./SplitFlapBoard.module.scss";
export interface SplitFlapBoardProps {
text?: string;
duration?: string;
stagger?: string;
}
// 실사용: 뷰포트에 들어오면 글자판이 위에서 아래로 한 번씩 시차를 두고 뒤집히며 자리 잡는다.
export default function SplitFlapBoard({
text = "SALE",
duration = "500ms",
stagger = "120ms",
}: SplitFlapBoardProps) {
const ref = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { setInView(true); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, []);
const style = { "--duration": duration } as CSSProperties;
return (
<div className={styles.board} style={style} ref={ref}>
{text.split("").map((ch, i) => (
<span
key={i}
className={`${styles.char} ${inView ? styles.in : ""}`}
style={{ transitionDelay: inView ? `calc(${stagger} * ${i})` : undefined }}
>
{ch}
</span>
))}
</div>
);
}05 · Split-flap board — Measured area 5.078% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Timing, not transform
The mechanical feel lives in the timing rather than the transform: steps(3, end) chops a plain rotateX into three hard stages, and swapping it for an ease curve turns the effect into an ordinary smooth 3D flip. A blur pass mimics motion blur: the plate softens edge-on and sharpens as it lands.
.sf-char {
transform-origin: center;
transform: rotateX(90deg);
opacity: 0;
filter: blur(3px);
}
@keyframes sf-flip {
0% { transform: rotateX(90deg); opacity: 0; filter: blur(3px); }
15% { filter: blur(4px); }
30% { transform: rotateX(0deg); opacity: 1; filter: blur(0); }
90% { transform: rotateX(0deg); opacity: 1; filter: blur(0); }
95% { filter: blur(4px); }
100% { transform: rotateX(90deg); opacity: 0; filter: blur(3px); }
}
.sf-board.is-demo .sf-char { animation: sf-flip 2s steps(3, end) infinite; }
.sf-board.is-demo .sf-char:nth-child(2) { animation-delay: 120ms; }
06Scramble decode
Glyphs locking left to right
Random glyphs flicker every 60ms and lock into the real letters from left to right, as if a cipher were being cracked.

<main class="stage">
<p class="sd-text" id="sd" aria-live="polite">DECODE</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.sd-text {
margin: 0;
font: 800 clamp(28px, 9vw, 44px)/1.2 "Space Mono", "Courier New", monospace;
letter-spacing: .06em;
color: $color;
}
// 이 데모는 CSS keyframes 가 아니라 짧은 JS(setInterval)로 글자를 무작위로 바꾼다 —
// 그래서 reduced-motion 은 CSS 가 아니라 스크립트 안의 matchMedia 체크로 처리한다 (index.html 참고)
@media (prefers-reduced-motion: reduce) {
.sd-text { color: $color; }
}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);
}
.sd-text {
margin: 0;
font: 800 clamp(28px, 9vw, 44px)/1.2 "Space Mono", "Courier New", monospace;
letter-spacing: 0.06em;
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.sd-text {
color: #17141a;
}
}var target = "DECODE";
var glyphs = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
var el = document.getElementById("sd");
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function tick(locked) {
if (reduced) { el.textContent = target; return; }
var out = "";
for (var i = 0; i < target.length; i++) {
out += i < locked ? target[i] : glyphs[Math.floor(Math.random() * glyphs.length)];
}
el.textContent = out;
}
var frame = 0;
setInterval(function () {
frame++;
var locked = Math.max(0, Math.floor((frame % 26) / 3) - 2);
tick(Math.min(locked, target.length));
}, 60);import { useEffect, useRef, useState } from "react";
import styles from "./ScrambleDecode.module.scss";
export interface ScrambleDecodeProps {
text?: string;
interval?: number;
}
const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
// 실사용: 뷰포트에 들어오면 무작위 글자가 바뀌다가 왼쪽부터 한 번씩 진짜 글자로 잠긴다. 다 풀리면 멈춘다.
export default function ScrambleDecode({ text = "DECODE", interval = 40 }: ScrambleDecodeProps) {
const ref = useRef<HTMLParagraphElement>(null);
const [display, setDisplay] = useState(text);
useEffect(() => {
const el = ref.current;
if (!el) return;
let timer: ReturnType<typeof setInterval> | null = null;
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || timer) return;
io.unobserve(entry.target);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) { setDisplay(text); return; }
let frame = 0;
timer = setInterval(() => {
frame += 1;
const locked = Math.min(Math.max(0, Math.floor(frame / 3) - 2), text.length);
let out = "";
for (let i = 0; i < text.length; i++) {
out += i < locked ? text[i] : GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
}
setDisplay(out);
if (locked >= text.length && timer) { clearInterval(timer); timer = null; }
}, interval);
});
}, { threshold: 0.3 });
io.observe(el);
return () => { io.disconnect(); if (timer) clearInterval(timer); };
}, [text, interval]);
return (
<p ref={ref} className={styles.text} aria-live="polite">{display}</p>
);
}06 · Scramble decode — Measured area 2.665% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The one item CSS cannot do
CSS cannot rewrite text content, so this is the only one of the nine that runs no @keyframes at all and leans on JavaScript instead. The number of locked letters is itself the progress value, so no separate easing math is needed.
var frame = 0;
setInterval(function () {
frame++;
var locked = Math.max(0, Math.floor((frame % 26) / 3) - 2);
var out = "";
for (var i = 0; i < target.length; i++) {
out += i < locked ? target[i] : glyphs[Math.floor(Math.random() * glyphs.length)];
}
el.textContent = out;
}, 60);
07Tracking squeeze
Spread apart, then drawn in
Letters begin spread far apart and transparent, then draw together into a crisp headline.
<main class="stage">
<h2 class="tk-word is-demo">
<span class="tk-ch" style="--x:-40px">F</span>
<span class="tk-ch" style="--x:-20px">O</span>
<span class="tk-ch" style="--x:0px">C</span>
<span class="tk-ch" style="--x:20px">U</span>
<span class="tk-ch" style="--x:40px">S</span>
</h2>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-lerp-settle; // 관성 lerp — 벌어진 글자가 목표(자간 0)를 살짝 지나쳤다 정착한다 (2026-09-15 형태 2차 한 겹)
$color: $subject-cream;
@include stage($stage-orange);
.tk-word {
margin: 0;
font: 700 clamp(26px, 7vw, 40px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: $color;
}
.tk-ch {
display: inline-block;
opacity: 0;
transform: translateX(var(--x, 0));
filter: blur(var(--tk-blur, 6px));
}
// 데모 루프: 글자마다 --x 만큼 벌어진 채로 있다가 0 으로 모이며 자간이 좁아지듯 보인다
// 한 겹: 수렴 구간을 $ease-lerp-settle(관성 lerp) 로 목표를 살짝 지나쳤다 되돌아오게 하고,
// 같은 구간에서 blur(var(--tk-blur))→0 으로 초점이 맞아 좁아질수록 또렷해진다.
@keyframes tk-converge {
0% { opacity: 0; transform: translateX(var(--x, 0)); filter: blur(var(--tk-blur, 6px)); }
35%, 80% { opacity: 1; transform: translateX(0); filter: blur(0); }
100% { opacity: 0; transform: translateX(var(--x, 0)); filter: blur(var(--tk-blur, 6px)); }
}
.tk-word.is-demo .tk-ch { animation: tk-converge $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.tk-ch,
.tk-word.is-demo .tk-ch {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}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);
}
.tk-word {
margin: 0;
font: 700 clamp(26px, 7vw, 40px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: #fff7e6;
}
.tk-ch {
display: inline-block;
opacity: 0;
transform: translateX(var(--x, 0));
filter: blur(var(--tk-blur, 6px));
}
@keyframes tk-converge {
0% {
opacity: 0;
transform: translateX(var(--x, 0));
filter: blur(var(--tk-blur, 6px));
}
35%, 80% {
opacity: 1;
transform: translateX(0);
filter: blur(0);
}
100% {
opacity: 0;
transform: translateX(var(--x, 0));
filter: blur(var(--tk-blur, 6px));
}
}
.tk-word.is-demo .tk-ch {
animation: tk-converge 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.tk-ch,
.tk-word.is-demo .tk-ch {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./TrackingSqueeze.module.scss";
export interface TrackingSqueezeProps {
text?: string;
duration?: string;
easing?: string;
spread?: number;
}
// 실사용: 뷰포트에 들어오면 벌어져 있던 글자들이 한 번 좁혀지며 자간이 정리된 제목으로 모인다.
export default function TrackingSqueeze({
text = "FOCUS",
duration = "650ms",
easing = "ease-out",
spread = 20,
}: TrackingSqueezeProps) {
const ref = useRef<HTMLHeadingElement>(null);
const [inView, setInView] = useState(false);
const chars = text.split("");
const mid = (chars.length - 1) / 2;
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { setInView(true); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, []);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<h2 className={styles.word} style={style} ref={ref}>
{chars.map((ch, i) => (
<span
key={i}
className={`${styles.ch} ${inView ? styles.in : ""}`}
style={{ "--x": `${(i - mid) * spread}px` } as CSSProperties}
>
{ch}
</span>
))}
</h2>
);
}07 · Tracking squeeze — Measured area 3.327% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why not letter-spacing
Animating letter-spacing would force a layout pass on every frame, so each character carries its own --x offset and only transform moves. The convergence settles on an inertial lerp curve while blur clears as the tracking tightens.
.tk-ch {
display: inline-block;
opacity: 0;
transform: translateX(var(--x, 0));
filter: blur(6px);
}
@keyframes tk-converge {
0% { opacity: 0; transform: translateX(var(--x, 0)); filter: blur(6px); }
35%, 80% { opacity: 1; transform: translateX(0); filter: blur(0); }
100% { opacity: 0; transform: translateX(var(--x, 0)); filter: blur(6px); }
}
.tk-word.is-demo .tk-ch { animation: tk-converge 2s $ease-lerp-settle infinite; }
08Outline to fill
Hollow first, filled after
The letters show up as hollow outlines first, then a fill sweeps in from the left until they are solid.

<main class="stage">
<div class="fl-wrap is-demo">
<span class="fl-text fl-text--outline" aria-hidden="true">SIGNATURE</span>
<span class="fl-text fl-text--fill">SIGNATURE</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.fl-wrap {
position: relative;
overflow: hidden;
font: 800 clamp(18px, 6vw, 28px)/1.2 -apple-system, "Segoe UI", sans-serif;
letter-spacing: .02em;
}
.fl-text {
display: block;
white-space: nowrap;
}
.fl-text--outline {
color: transparent;
-webkit-text-stroke: 1px $color;
}
.fl-text--fill {
position: absolute;
inset: 0;
color: $color;
clip-path: inset(0 100% 0 0);
filter: blur(6px);
}
// 데모 루프: 테두리만 있던 글자 위로 채워진 글자가 왼쪽부터 clip-path 로 드러났다가 다시 걷힌다
// 한 겹: blur→focus — 채움이 흘러드는 동안 blur(6px)→0 으로 잉크가 스며들 듯 초점이 맞는다
// (clip-path 는 오버슈트가 안 보이는 값이라 lerp-settle 대신 초점 수렴으로 계산 한 겹을 얹는다).
@keyframes fl-wipe {
0%, 10% { clip-path: inset(0 100% 0 0); filter: blur(6px); }
55%, 70% { clip-path: inset(0 0 0 0); filter: blur(0); }
90% { clip-path: inset(0 0 0 0); filter: blur(0); }
100% { clip-path: inset(0 100% 0 0); filter: blur(6px); }
}
.fl-wrap.is-demo .fl-text--fill { animation: fl-wipe $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fl-text--fill,
.fl-wrap.is-demo .fl-text--fill {
animation: none !important;
clip-path: inset(0 0 0 0) !important;
filter: none !important;
}
}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);
}
.fl-wrap {
position: relative;
overflow: hidden;
font: 800 clamp(18px, 6vw, 28px)/1.2 -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0.02em;
}
.fl-text {
display: block;
white-space: nowrap;
}
.fl-text--outline {
color: transparent;
-webkit-text-stroke: 1px #2f6df6;
}
.fl-text--fill {
position: absolute;
inset: 0;
color: #2f6df6;
clip-path: inset(0 100% 0 0);
filter: blur(6px);
}
@keyframes fl-wipe {
0%, 10% {
clip-path: inset(0 100% 0 0);
filter: blur(6px);
}
55%, 70% {
clip-path: inset(0 0 0 0);
filter: blur(0);
}
90% {
clip-path: inset(0 0 0 0);
filter: blur(0);
}
100% {
clip-path: inset(0 100% 0 0);
filter: blur(6px);
}
}
.fl-wrap.is-demo .fl-text--fill {
animation: fl-wipe 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.fl-text--fill,
.fl-wrap.is-demo .fl-text--fill {
animation: none !important;
clip-path: inset(0 0 0 0) !important;
filter: none !important;
}
}import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./StrokeToFill.module.scss";
export interface StrokeToFillProps {
text?: string;
duration?: string;
easing?: string;
}
// 실사용: 뷰포트에 들어오면 테두리만 있던 글자가 왼쪽부터 한 번 채워지며 완성된 글자로 바뀐다.
export default function StrokeToFill({
text = "SIGNATURE",
duration = "700ms",
easing = "ease-out",
}: StrokeToFillProps) {
const ref = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { setInView(true); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, []);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<div className={styles.wrap} style={style} ref={ref}>
<span className={styles.outline} aria-hidden="true">{text}</span>
<span className={`${styles.fill} ${inView ? styles.in : ""}`}>{text}</span>
</div>
);
}08 · Outline to fill — Measured area 2.835% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Two layers, one clip-path
An outline layer drawn with -webkit-text-stroke sits underneath, and the colored copy on top is held back by a clip-path that opens from a full inset to zero. The fill sharpens blur(6px)→0 as it flows in, like ink soaking into the letterforms.
.fl-text--outline {
color: transparent;
-webkit-text-stroke: 1px $color;
}
.fl-text--fill {
position: absolute;
inset: 0;
color: $color;
clip-path: inset(0 100% 0 0);
filter: blur(6px);
}
@keyframes fl-wipe {
0%, 10% { clip-path: inset(0 100% 0 0); filter: blur(6px); }
55%, 70% { clip-path: inset(0 0 0 0); filter: blur(0); }
90% { clip-path: inset(0 0 0 0); filter: blur(0); }
100% { clip-path: inset(0 100% 0 0); filter: blur(6px); }
}
.fl-wrap.is-demo .fl-text--fill { animation: fl-wipe 2s ease-out infinite; }
09Shadow build
Shadows stacking into depth
Layers of shadow stack up behind flat text until it reads as extruded, then flatten again.

<main class="stage">
<h2 class="sh-text is-demo">ARCADE</h2>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-lerp-settle; // 관성 lerp — 쌓이는 층이 목표 두께를 살짝 지나쳤다 정착한다 (2026-09-15 형태 2차 한 겹)
$color: $subject-cream;
@include stage($stage-ink);
.sh-text {
margin: 0;
font: 900 clamp(26px, 8vw, 40px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: $color;
text-shadow: none;
filter: blur(5px);
}
// 데모 루프: 평평한 글자 뒤로 그림자 층이 겹겹이 쌓이며 입체적인 두께가 생겼다가 다시 평평해진다
// 한 겹: 관성 lerp — $ease-lerp-settle 로 층 두께가 목표를 살짝 지나쳤다 되돌아오며 쌓이고(블록이 얹히듯),
// 같은 구간에서 blur(5px)→0 초점 수렴이 겹쳐 두꺼워질수록 또렷해진다.
@keyframes sh-extrude {
0%, 15% { text-shadow: none; filter: blur(5px); }
55%, 85% {
text-shadow:
1px 1px 0 $subject-blue,
2px 2px 0 $subject-blue,
3px 3px 0 $subject-blue,
4px 4px 0 $subject-blue,
5px 5px 8px rgba(0, 0, 0, .35);
filter: blur(0);
}
100% { text-shadow: none; filter: blur(5px); }
}
.sh-text.is-demo { animation: sh-extrude $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.sh-text,
.sh-text.is-demo {
animation: none !important;
filter: none !important;
text-shadow:
1px 1px 0 $subject-blue,
2px 2px 0 $subject-blue,
3px 3px 0 $subject-blue,
4px 4px 0 $subject-blue,
5px 5px 8px rgba(0, 0, 0, .35) !important;
}
}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);
}
.sh-text {
margin: 0;
font: 900 clamp(26px, 8vw, 40px)/1.2 -apple-system, "Segoe UI", sans-serif;
color: #fff7e6;
text-shadow: none;
filter: blur(5px);
}
@keyframes sh-extrude {
0%, 15% {
text-shadow: none;
filter: blur(5px);
}
55%, 85% {
text-shadow: 1px 1px 0 #2f6df6, 2px 2px 0 #2f6df6, 3px 3px 0 #2f6df6, 4px 4px 0 #2f6df6, 5px 5px 8px rgba(0, 0, 0, 0.35);
filter: blur(0);
}
100% {
text-shadow: none;
filter: blur(5px);
}
}
.sh-text.is-demo {
animation: sh-extrude 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.sh-text,
.sh-text.is-demo {
animation: none !important;
filter: none !important;
text-shadow: 1px 1px 0 #2f6df6, 2px 2px 0 #2f6df6, 3px 3px 0 #2f6df6, 4px 4px 0 #2f6df6, 5px 5px 8px rgba(0, 0, 0, 0.35) !important;
}
}import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./ShadowBuild.module.scss";
export interface ShadowBuildProps {
text?: string;
duration?: string;
easing?: string;
}
// 실사용: 뷰포트에 들어오면 평평한 글자 뒤로 그림자 층이 한 번 쌓이며 입체적인 두께가 생긴다.
export default function ShadowBuild({
text = "ARCADE",
duration = "500ms",
easing = "ease-out",
}: ShadowBuildProps) {
const ref = useRef<HTMLHeadingElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { setInView(true); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, []);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<h2 className={`${styles.text} ${inView ? styles.in : ""}`} style={style} ref={ref}>
{text}
</h2>
);
}09 · Shadow build — Measured area 4.672% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
One property, five copies
Four offset copies from 1px to 4px plus one blurred layer are written as a single text-shadow value, so the whole build is one property changing. The stack settles on an inertial lerp curve — slightly past the target thickness and back — while blur clears as depth grows.
.sh-text { text-shadow: none; filter: blur(5px); }
@keyframes sh-extrude {
0%, 15% { text-shadow: none; filter: blur(5px); }
55%, 85% {
text-shadow:
1px 1px 0 $subject-blue,
2px 2px 0 $subject-blue,
3px 3px 0 $subject-blue,
4px 4px 0 $subject-blue,
5px 5px 8px rgba(0, 0, 0, .35);
filter: blur(0);
}
100% { text-shadow: none; filter: blur(5px); }
}
.sh-text.is-demo { animation: sh-extrude 2s $ease-lerp-settle infinite; }
Where CSS text animation breaks
A fill layer that hid the outline
The first item that actually broke was 08. The colored copy of the letters sat over the outline copy with nothing holding it back, and because it was absolutely positioned it covered the outline completely, so the outline stage never reached the screen. From the outside the loop looked healthy; opening the captured frames one by one showed frame 03 with a single stray colored letter on an otherwise empty stage.
The opposite move that worked
The instinct was to keep everything on transform by sliding a cover in the stage color away from the fill, but that cover was exactly what hid the step worth showing. What worked was the opposite move: putting clip-path on the colored layer itself and widening it from the left, which finally let the outline step show before the fill. The corrected sources for all nine are in the archive, which opens with the password jagvrc78 and holds a React copy of every item next to the plain CSS one.
Stacking shadows per letter until the budget breaks meets the same ceiling, surface by surface, in the skeuomorphism landing kit, where texture piles up in layers.
Accessibility: what the nine keep under reduced-motion
What holds under reduced motion
Under prefers-reduced-motion: reduce all nine stop moving and hold their finished state. The eight built on SCSS @keyframes pin that state with !important: words fully up, the diagonal mask parked past the text, tracking closed, the outline filled, the shadows stacked.
Why 06 checks matchMedia
A reader who has turned motion off still gets the result rather than a blank stage. Number 06 has no CSS animation to switch off, so the script checks matchMedia itself and prints the finished word without ever scrambling it.
Where to go next
Other ways to bring letters in one at a time are collected in 9 CSS text animation effects, and the wider set lives in the CSS-only category. The syntax used in 08 is documented in the MDN clip-path reference.
FAQ
Do css text animation examples need JavaScript?
Eight of the nine run on SCSS @keyframes alone. Number 06 is the exception, because it has to replace the text content itself and CSS has no way to do that, so eighteen lines of script handle the swap.
For a headline, should I use 01 or 04?
A headline of one or two lines reads better split by word, the way 01 does. A manifesto or mission statement of two or three lines suits 04, where whole lines enter from alternating sides and give the block a rhythm that word-level staggering cannot match.
Is the React code different from the vanilla code?
The motion itself is the same in both. What differs is the trigger: the vanilla files force an endless loop through an is-demo class so the effect can be filmed, while each React component watches with an IntersectionObserver and plays once when the element scrolls into view. To make the reveal read like a cipher resolving, the decode text reveal effects are that variation.









