CSS 타이핑 효과 9종 — 한 줄 복붙으로
CSS 타이핑 효과는 자바스크립트 라이브러리 없이 CSS만으로 문장이 한 글자씩 나타나거나 지워지는 것처럼 보이게 만드는 짧은 애니메이션입니다. 아래 아홉 개는 각각 다른 CSS 속성 하나에 기대고 있고, 필요한 건 격자에서 눈으로 고른 뒤 zip으로 통째로 가져가면 됩니다.
01 typing-text-effect-caret-blink
02 typing-text-effect-steps-typing
03 typing-text-effect-multi-glyph-compose
04 typing-text-effect-multi-line
05 typing-text-effect-delete-retype
06 typing-text-effect-ai-streaming-caret
07 typing-text-effect-terminal-prompt
08 typing-text-effect-handwriting-svg
09 typing-text-effect-word-swap
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 커서 깜빡임
- 02 steps() 타자 효과
- 03 다국어 글자 조합 타이핑
- 04 여러 줄 타이핑
- 05 지우고 다시 타이핑
- 06 AI 답변 스트리밍 느낌
- 07 터미널 프롬프트
- 08 SVG 손글씨 효과
- 09 단어 교체 타이핑
아홉 개는 인기 순이 아니라 브라우저가 실제로 타이핑을 표현하는 방법 순으로 놓았습니다. 커서만 깜빡이는 것(01)에서 시작해 폭을 단계별로 늘리는 기본기(02), 그 기본기가 다국어에서 깨지는 지점을 고치는 방법(03), 여러 줄(04)과 삭제 후 재입력(05)으로 응용을 넓히고, AI 응답(06)과 터미널(07) 같은 실제 화면, SVG 선 그리기(08)와 단어 교체(09)로 마무리됩니다. 코드는 항목마다 css 텍스트 애니메이션 효과의 규칙 한 덩어리만 실었고, 마크업이 전부 붙은 완성 파일은 zip 하나에 있습니다. 포인터 쪽을 꾸미고 싶어지면 CSS 마우스 커서 효과 9종이 이웃이고, 타이핑 말고 문장이 움직이는 방식 전반은 CSS 텍스트 애니메이션 효과에 모여 있습니다.
01커서 깜빡임
어떻게 움직이나
타이핑이 끝난 자리에 문자 폭만한 막대를 두고 opacity를 두 값 사이에서 뚝뚝 끊어 전환하면 실제 텍스트 커서처럼 보입니다.
어디에 쓰나
폭이나 위치는 전혀 건드리지 않아 레이아웃 재계산이 없습니다.

<main class="stage">
<p class="line"><span class="line__text">무엇을 도와드릴까요?</span><span class="line__caret" aria-hidden="true"></span></p>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 깜빡임 한 주기
$easing: steps(2, jump-none); // 뚝뚝 끊어 깜빡이게 (부드럽게 섞지 않는다)
$color: $subject-blue; // 커서 색
@include stage($stage-ink);
.line {
display: flex;
align-items: baseline;
margin: 0;
font-weight: 700; font-size: clamp(20px, 5.6vw, 34px); line-height: 1.4; font-family: inherit;
color: $subject-cream;
&__caret {
display: inline-block;
width: clamp(8px, 2.6vw, 14px);
height: 1.3em;
margin-left: clamp(2px, 1vw, 6px);
background: $color;
animation: caret-blink $duration $easing infinite;
}
}
@keyframes caret-blink {
from { opacity: 1; }
to { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.line__caret { animation: 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.line {
display: flex;
align-items: baseline;
margin: 0;
font-weight: 700;
font-size: clamp(20px, 5.6vw, 34px);
line-height: 1.4;
font-family: inherit;
color: #fff7e6;
}
.line__caret {
display: inline-block;
width: clamp(8px, 2.6vw, 14px);
height: 1.3em;
margin-left: clamp(2px, 1vw, 6px);
background: #2f6df6;
animation: caret-blink 300ms steps(2, jump-none) infinite;
}
@keyframes caret-blink {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.line__caret {
animation: none;
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./CaretBlink.module.scss";
export type CaretBlinkProps = {
text: string;
/** 깜빡임 한 주기 (기본 300ms) */
duration?: number;
color?: string;
};
export default function CaretBlink({ text, duration = 300, color = "#2f6df6" }: CaretBlinkProps) {
const vars = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<p className={styles.line} style={vars}>
<span className={styles.text}>{text}</span>
<span className={styles.caret} aria-hidden="true" />
</p>
);
}01 · 커서 깜빡임 — 측정 누적면적 0.316% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.line__caret {
width: 10px;
height: 1.3em;
background: #2f6df6;
animation: caret-blink 300ms steps(2, jump-none) infinite;
}
@keyframes caret-blink {
from { opacity: 1; }
to { opacity: 0; }
}
02steps() 타자 효과
어떻게 움직이나
width를 0에서 글자 수만큼의 ch로 steps()를 이용해 한 글자씩 끊어 늘리면 실제 타자기가 찍는 것과 같은 리듬이 나옵니다.
어디에 쓰나
자간과 글자 폭이 완전히 고정폭이 아니라면 마지막 한두 글자가 살짝 밀려 보일 수 있습니다.

<main class="stage">
<p class="type"><span class="type__text">복붙 한 줄이면 끝</span></p>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 한 루프 전체 길이
$easing: steps(10, end); // 글자 수만큼 뚝뚝 끊어 늘어나는 곡선
$color: $subject-ink; // 글자·커서 색
@include stage($stage-yellow);
.type {
margin: 0;
&__text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: .12em solid $color;
font-weight: 700; font-size: clamp(20px, 5.6vw, 34px); line-height: 1.4; font-family: inherit;
color: $color;
animation: type-loop $duration linear infinite;
}
}
@keyframes type-loop {
0%, 6% { width: 0; animation-timing-function: $easing; }
58% { width: var(--full, 10ch); }
88% { width: var(--full, 10ch); }
94%, 100% { width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.type__text { animation: none; width: var(--full, 10ch); border-right-color: transparent; }
}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);
}
.type {
margin: 0;
}
.type__text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: 0.12em solid #17141a;
font-weight: 700;
font-size: clamp(20px, 5.6vw, 34px);
line-height: 1.4;
font-family: inherit;
color: #17141a;
animation: type-loop 2s linear infinite;
}
@keyframes type-loop {
0%, 6% {
width: 0;
animation-timing-function: steps(10, end);
}
58% {
width: var(--full, 10ch);
}
88% {
width: var(--full, 10ch);
}
94%, 100% {
width: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.type__text {
animation: none;
width: var(--full, 10ch);
border-right-color: transparent;
}
}(function () {
var el = document.querySelector('.type__text'), cv = document.createElement('canvas').getContext('2d');
cv.font = getComputedStyle(el).font;
el.style.setProperty('--full', Math.ceil(cv.measureText(el.textContent).width) + 'px');
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./StepsTypewriter.module.scss";
export type StepsTypewriterProps = {
text: string;
/** 한 루프 전체 길이 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function StepsTypewriter({ text, duration = 2000, color = "#17141a" }: StepsTypewriterProps) {
const ref = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const el = ref.current;
const cv = el && document.createElement("canvas").getContext("2d");
if (!el || !cv) return;
cv.font = getComputedStyle(el).font;
el.style.setProperty("--full", Math.ceil(cv.measureText(text).width) + "px");
}, [text]);
const vars = { "--duration": `${duration}ms`, "--color": color, "--easing": `steps(${text.length}, end)` } as CSSProperties;
return (
<p className={styles.type} style={vars}>
<span ref={ref} className={styles.text}>{text}</span>
</p>
);
}02 · steps() 타자 효과 — 측정 누적면적 3.509% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.type__text {
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: .12em solid #17141a;
animation: type-loop 2s linear infinite;
}
@keyframes type-loop {
0%, 6% { width: 0; animation-timing-function: steps(10, end); }
58% { width: 10ch; }
88% { width: 10ch; }
94%, 100% { width: 0; }
}
03다국어 글자 조합 타이핑
css 한글 타이핑 효과가 깨지는 곳
steps()와 ch는 고정폭 글꼴을 전제해서 한글 자모 조합이나 이모지가 섞이면 한 스텝에 반 글자만 드러나는 경우가 생깁니다.
어디에 쓰나
캔버스로 각 글자의 실제 픽셀 폭을 재서 그 경계에서만 clip-path가 멈추게 하면 어떤 언어가 섞여도 글자 중간에서 잘리지 않습니다.

<main class="stage">
<p class="glyph"><span class="glyph__text" id="gtext">안녕하세요 Godrich 🎉</span></p>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // JS 쪽 el.animate() duration:2000 과 맞춘다
$easing: linear; // 글자 폭 기준 offset 을 이미 곡선으로 넣어 linear 로 충분하다
$color: $subject-cream; // 글자 색
@include stage($stage-orange);
.glyph {
margin: 0;
font-weight: 700; font-size: clamp(22px, 6.4vw, 40px); line-height: 1.4; font-family: inherit;
color: $color;
&__text {
display: inline-block;
}
}
@media (prefers-reduced-motion: reduce) {
.glyph__text { clip-path: 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);
}
.glyph {
margin: 0;
font-weight: 700;
font-size: clamp(22px, 6.4vw, 40px);
line-height: 1.4;
font-family: inherit;
color: #fff7e6;
}
.glyph__text {
display: inline-block;
}
@media (prefers-reduced-motion: reduce) {
.glyph__text {
clip-path: none !important;
}
}(function () {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var el = document.getElementById('gtext'), s = el.textContent, cv = document.createElement('canvas').getContext('2d');
cv.font = getComputedStyle(el).font;
var w = 0, cum = [0];
for (var i = 0; i < s.length; i++) { w += cv.measureText(s[i]).width; cum.push(w); }
var kf = cum.map(function (px) { return { clipPath: 'inset(0 ' + (100 - px / w * 70) + '% 0 0)', offset: px / w * .7 }; });
kf.push({ clipPath: 'inset(0 0% 0 0)', offset: .88 }, { clipPath: 'inset(0 100% 0 0)', offset: 1 });
el.animate(kf, { duration: 2000, iterations: Infinity });
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./MultiGlyphTyping.module.scss";
export type MultiGlyphTypingProps = {
text: string;
/** el.animate() duration (기본 2000ms) */
duration?: number;
color?: string;
};
export default function MultiGlyphTyping({ text, duration = 2000, color = "#fff7e6" }: MultiGlyphTypingProps) {
const ref = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const cv = document.createElement("canvas").getContext("2d");
if (!cv) return;
cv.font = getComputedStyle(el).font;
let w = 0;
const cum = [0];
for (let i = 0; i < text.length; i++) { w += cv.measureText(text[i]).width; cum.push(w); }
const kf = cum.map((px) => ({ clipPath: "inset(0 " + (100 - (px / w) * 70) + "% 0 0)", offset: (px / w) * 0.7 }));
kf.push({ clipPath: "inset(0 0% 0 0)", offset: 0.88 }, { clipPath: "inset(0 100% 0 0)", offset: 1 });
const anim = el.animate(kf, { duration, iterations: Infinity });
return () => anim.cancel();
}, [text, duration]);
const vars = { "--color": color } as CSSProperties;
return (
<p className={styles.glyph} style={vars}>
<span ref={ref} className={styles.text}>{text}</span>
</p>
);
}03 · 다국어 글자 조합 타이핑 — 측정 누적면적 2.801% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var w = 0, cum = [0];
for (var i = 0; i < s.length; i++) {
w += ctx.measureText(s[i]).width;
cum.push(w);
}
var kf = cum.map(function (px) {
return { clipPath: 'inset(0 ' + (100 - px / w * 100) + '% 0 0)' };
});
el.animate(kf, { duration: 2000, iterations: Infinity });
04여러 줄 타이핑
어떻게 움직이나
줄마다 따로 keyframes를 두고 각자의 정지 구간과 steps() 구간을 퍼센트로 배치하면, 위 줄이 다 찍히는 시점에 맞춰 다음 줄이 이어서 시작하는 것처럼 보입니다.
어디에 쓰나
세 줄 모두 같은 2초 루프를 공유하기 때문에 별도의 지연 값을 손으로 맞출 필요가 없습니다.

<main class="stage">
<div class="lines">
<p class="lines__row"><span class="lines__text lines__text--1">프론트엔드 개발자</span></p>
<p class="lines__row"><span class="lines__text lines__text--2">CSS 애니메이션을 좋아합니다</span></p>
<p class="lines__row"><span class="lines__text lines__text--3">커피와 타이핑을 즐깁니다</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 세 줄이 도는 전체 루프
$easing: $ease-out; // 다 친 뒤 접히는 구간의 곡선 (타이핑 자체는 줄마다 steps())
$color: $subject-blue; // 커서 색
@include stage($stage-paper);
.lines {
display: grid;
gap: clamp(4px, 1.6vw, 10px);
font-weight: 700; font-size: clamp(15px, 4.4vw, 22px); line-height: 1.5; font-family: inherit;
color: $stage-ink;
&__row { margin: 0; }
&__text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
vertical-align: bottom;
width: 0;
border-right: .1em solid $color;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
&--1 { animation-name: l1-type; }
&--2 { animation-name: l2-type; }
&--3 { animation-name: l3-type; }
}
}
@keyframes l1-type {
0% { width: 0; animation-timing-function: steps(9, end); }
10% { width: var(--full, 9ch); }
88% { width: var(--full, 9ch); }
96%, 100% { width: 0; }
}
@keyframes l2-type {
0%, 10% { width: 0; animation-timing-function: steps(16, end); }
34% { width: var(--full, 16ch); }
88% { width: var(--full, 16ch); }
96%, 100% { width: 0; }
}
@keyframes l3-type {
0%, 34% { width: 0; animation-timing-function: steps(13, end); }
54% { width: var(--full, 13ch); }
88% { width: var(--full, 13ch); }
96%, 100% { width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.lines__text { animation: none; width: var(--full, 16ch); border-right-color: transparent; }
}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);
}
.lines {
display: grid;
gap: clamp(4px, 1.6vw, 10px);
font-weight: 700;
font-size: clamp(15px, 4.4vw, 22px);
line-height: 1.5;
font-family: inherit;
color: #17141a;
}
.lines__row {
margin: 0;
}
.lines__text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
vertical-align: bottom;
width: 0;
border-right: 0.1em solid #2f6df6;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.lines__text--1 {
animation-name: l1-type;
}
.lines__text--2 {
animation-name: l2-type;
}
.lines__text--3 {
animation-name: l3-type;
}
@keyframes l1-type {
0% {
width: 0;
animation-timing-function: steps(9, end);
}
10% {
width: var(--full, 9ch);
}
88% {
width: var(--full, 9ch);
}
96%, 100% {
width: 0;
}
}
@keyframes l2-type {
0%, 10% {
width: 0;
animation-timing-function: steps(16, end);
}
34% {
width: var(--full, 16ch);
}
88% {
width: var(--full, 16ch);
}
96%, 100% {
width: 0;
}
}
@keyframes l3-type {
0%, 34% {
width: 0;
animation-timing-function: steps(13, end);
}
54% {
width: var(--full, 13ch);
}
88% {
width: var(--full, 13ch);
}
96%, 100% {
width: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.lines__text {
animation: none;
width: var(--full, 16ch);
border-right-color: transparent;
}
}(function () {
var cv = document.createElement('canvas').getContext('2d');
document.querySelectorAll('.lines__text').forEach(function (el) {
cv.font = getComputedStyle(el).font;
el.style.setProperty('--full', Math.ceil(cv.measureText(el.textContent).width) + 'px');
});
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./MultiLineTyping.module.scss";
export type MultiLineTypingProps = {
lines: [string, string, string];
/** 세 줄이 도는 전체 루프 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function MultiLineTyping({ lines, duration = 2000, color = "#2f6df6" }: MultiLineTypingProps) {
const r1 = useRef<HTMLSpanElement | null>(null);
const r2 = useRef<HTMLSpanElement | null>(null);
const r3 = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const cv = document.createElement("canvas").getContext("2d");
if (!cv) return;
[r1, r2, r3].forEach((r) => {
const el = r.current;
if (!el) return;
cv.font = getComputedStyle(el).font;
el.style.setProperty("--full", Math.ceil(cv.measureText(el.textContent || "").width) + "px");
});
}, [lines]);
const vars = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
const e = (s: string) => ({ "--easing": `steps(${s.length}, end)` } as CSSProperties);
return (
<div className={styles.lines} style={vars}>
<p className={styles.row}><span ref={r1} className={`${styles.text} ${styles.l1}`} style={e(lines[0])}>{lines[0]}</span></p>
<p className={styles.row}><span ref={r2} className={`${styles.text} ${styles.l2}`} style={e(lines[1])}>{lines[1]}</span></p>
<p className={styles.row}><span ref={r3} className={`${styles.text} ${styles.l3}`} style={e(lines[2])}>{lines[2]}</span></p>
</div>
);
}04 · 여러 줄 타이핑 — 측정 누적면적 8.264% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@keyframes l2-type {
0%, 10% { width: 0; animation-timing-function: steps(16, end); }
34% { width: 16ch; }
88% { width: 16ch; }
96%, 100% { width: 0; }
}
.lines__text--2 { animation: l2-type 2s ease-out infinite; }
05지우고 다시 타이핑
어떻게 움직이나
width가 0에서 끝까지 갔다가 다시 0으로 돌아오는 keyframes 하나에 타이핑과 삭제를 같이 담으면 자바스크립트 없이도 검색창 placeholder 같은 반복 문구를 만들 수 있습니다.
어디에 쓰나
타이핑 구간과 삭제 구간에 각각 다른 지점에서 steps()를 다시 걸어야 두 방향 모두 끊어지는 느낌이 유지됩니다.

<main class="stage">
<p class="search"><span class="search__text">무엇이든 검색해보세요</span></p>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 타이핑+삭제 한 루프
$easing: steps(11, end); // 글자 수만큼 뚝뚝 끊어 늘고 준다
$color: $subject-blue; // 커서 색
@include stage($stage-ink);
.search {
margin: 0;
&__text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: .1em solid $color;
font-weight: 700; font-size: clamp(18px, 5.2vw, 30px); line-height: 1.4; font-family: inherit;
color: $subject-cream;
animation: search-loop $duration linear infinite;
}
}
@keyframes search-loop {
0%, 6% { width: 0; animation-timing-function: $easing; }
46% { width: var(--full, 11ch); }
62% { width: var(--full, 11ch); animation-timing-function: $easing; }
96%, 100% { width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.search__text { animation: none; width: var(--full, 11ch); border-right-color: transparent; }
}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);
}
.search {
margin: 0;
}
.search__text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: 0.1em solid #2f6df6;
font-weight: 700;
font-size: clamp(18px, 5.2vw, 30px);
line-height: 1.4;
font-family: inherit;
color: #fff7e6;
animation: search-loop 2s linear infinite;
}
@keyframes search-loop {
0%, 6% {
width: 0;
animation-timing-function: steps(11, end);
}
46% {
width: var(--full, 11ch);
}
62% {
width: var(--full, 11ch);
animation-timing-function: steps(11, end);
}
96%, 100% {
width: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.search__text {
animation: none;
width: var(--full, 11ch);
border-right-color: transparent;
}
}(function () {
var el = document.querySelector('.search__text'), cv = document.createElement('canvas').getContext('2d');
cv.font = getComputedStyle(el).font;
el.style.setProperty('--full', Math.ceil(cv.measureText(el.textContent).width) + 'px');
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./DeleteRetype.module.scss";
export type DeleteRetypeProps = {
text: string;
/** 타이핑+삭제 한 루프 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function DeleteRetype({ text, duration = 2000, color = "#2f6df6" }: DeleteRetypeProps) {
const ref = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const el = ref.current;
const cv = el && document.createElement("canvas").getContext("2d");
if (!el || !cv) return;
cv.font = getComputedStyle(el).font;
el.style.setProperty("--full", Math.ceil(cv.measureText(text).width) + "px");
}, [text]);
const vars = { "--duration": `${duration}ms`, "--color": color, "--easing": `steps(${text.length}, end)` } as CSSProperties;
return (
<p className={styles.search} style={vars}>
<span ref={ref} className={styles.text}>{text}</span>
</p>
);
}05 · 지우고 다시 타이핑 — 측정 누적면적 3.679% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@keyframes search-loop {
0%, 6% { width: 0; animation-timing-function: steps(11, end); }
46% { width: 11ch; }
62% { width: 11ch; animation-timing-function: steps(11, end); }
96%, 100% { width: 0; }
}
06AI 답변 스트리밍 느낌
어떻게 움직이나
고정 간격 steps() 대신 글자마다 30에서 90ms 사이 무작위 값을 뽑아 누적한 뒤 그 비율을 offset으로 넘기면, 재생될 때마다 조금씩 다른 리듬으로 글자가 나타나 실제 토큰 스트리밍처럼 보입니다.
어디에 쓰나
옆의 작은 원은 opacity와 transform만으로 따로 맥동해 응답이 아직 오고 있다는 신호를 더합니다.

<main class="stage">
<p class="ai"><span class="ai__text" id="atext">네, 요청하신 내용을 정리해드릴게요</span><span class="ai__orb" aria-hidden="true"></span></p>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // JS 쪽 el.animate() duration:2000 과 맞춘다
$easing: linear; // 랜덤 간격을 이미 offset 으로 넣어 linear 로 충분하다
$color: $subject-ink; // 글자 색
@include stage($stage-yellow);
.ai {
margin: 0;
display: flex;
align-items: baseline;
gap: clamp(4px, 1.4vw, 8px);
font-weight: 700; font-size: clamp(16px, 4.6vw, 26px); line-height: 1.5; font-family: inherit;
color: $color;
&__text { display: inline-block; }
&__orb {
width: clamp(6px, 1.8vw, 10px);
height: clamp(6px, 1.8vw, 10px);
border-radius: $r-pill;
background: $subject-blue;
animation: orb-pulse $dur-quick $ease-pop infinite;
}
}
@keyframes orb-pulse {
0%, 100% { transform: scale(1); opacity: .55; }
50% { transform: scale(1.6); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.ai__text { clip-path: none !important; }
.ai__orb { animation: none; opacity: .8; }
}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);
}
.ai {
margin: 0;
display: flex;
align-items: baseline;
gap: clamp(4px, 1.4vw, 8px);
font-weight: 700;
font-size: clamp(16px, 4.6vw, 26px);
line-height: 1.5;
font-family: inherit;
color: #17141a;
}
.ai__text {
display: inline-block;
}
.ai__orb {
width: clamp(6px, 1.8vw, 10px);
height: clamp(6px, 1.8vw, 10px);
border-radius: 999px;
background: #2f6df6;
animation: orb-pulse 220ms cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes orb-pulse {
0%, 100% {
transform: scale(1);
opacity: 0.55;
}
50% {
transform: scale(1.6);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.ai__text {
clip-path: none !important;
}
.ai__orb {
animation: none;
opacity: 0.8;
}
}(function () {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var el = document.getElementById('atext'), s = el.textContent, n = s.length, d = [], sum = 0;
for (var i = 0; i < n; i++) { var r = 30 + Math.random() * 60; d.push(r); sum += r; }
var acc = 0, kf = [{ clipPath: 'inset(0 100% 0 0)', offset: 0 }];
for (var i = 0; i < n; i++) { acc += d[i]; kf.push({ clipPath: 'inset(0 ' + (100 - acc / sum * 78) + '% 0 0)', offset: acc / sum * .78 }); }
kf.push({ clipPath: 'inset(0 0% 0 0)', offset: .9 }, { clipPath: 'inset(0 100% 0 0)', offset: 1 });
el.animate(kf, { duration: 2000, iterations: Infinity });
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./AiStreamingCaret.module.scss";
export type AiStreamingCaretProps = {
text: string;
/** el.animate() duration (기본 2000ms) */
duration?: number;
color?: string;
};
export default function AiStreamingCaret({ text, duration = 2000, color = "#17141a" }: AiStreamingCaretProps) {
const ref = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const n = text.length;
const d: number[] = [];
let sum = 0;
for (let i = 0; i < n; i++) { const r = 30 + Math.random() * 60; d.push(r); sum += r; }
let acc = 0;
const kf = [{ clipPath: "inset(0 100% 0 0)", offset: 0 }];
for (let i = 0; i < n; i++) { acc += d[i]; kf.push({ clipPath: "inset(0 " + (100 - (acc / sum) * 78) + "% 0 0)", offset: (acc / sum) * 0.78 }); }
kf.push({ clipPath: "inset(0 0% 0 0)", offset: 0.9 }, { clipPath: "inset(0 100% 0 0)", offset: 1 });
const anim = el.animate(kf, { duration, iterations: Infinity });
return () => anim.cancel();
}, [text, duration]);
const vars = { "--color": color } as CSSProperties;
return (
<p className={styles.ai} style={vars}>
<span ref={ref} className={styles.text}>{text}</span>
<span className={styles.orb} aria-hidden="true" />
</p>
);
}06 · AI 답변 스트리밍 느낌 — 측정 누적면적 2.514% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var acc = 0, kf = [{ clipPath: 'inset(0 100% 0 0)', offset: 0 }];
for (var i = 0; i < n; i++) {
acc += delays[i];
kf.push({
clipPath: 'inset(0 ' + (100 - acc / sum * 78) + '% 0 0)',
offset: acc / sum * .78
});
}
el.animate(kf, { duration: 2000, iterations: Infinity });
07터미널 프롬프트
어떻게 움직이나
명령어 줄은 steps()로 찍히고, 그 애니메이션이 끝나는 지점 이후 구간에서만 응답 줄의 opacity가 0에서 1로 바뀌도록 같은 keyframes 퍼센트 안에 순서를 담으면 두 단계가 자연스럽게 이어집니다.
어디에 쓰나
두 애니메이션이 같은 2초 루프를 쓰기 때문에 지연 값 없이도 명령어와 응답의 순서가 항상 지켜집니다.

<main class="stage">
<div class="term">
<div class="term__bar"><span class="term__dot"></span><span class="term__dot"></span><span class="term__dot"></span></div>
<p class="term__line">$ <span class="term__cmd">npm run build</span></p>
<p class="term__resp">빌드가 완료됐습니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 명령어 → 응답 두 단계를 담는 전체 루프
$easing: steps(13, end); // 명령어 글자 수만큼 뚝뚝 끊어 찍힌다
$color: $subject-blue; // 커서·응답 색
@include stage($stage-orange);
.term {
box-sizing: border-box; // width 와 padding 을 같이 쓰는데 border-box 가 없으면 padding 이 width 위에 더해져 320px 에서 넘친다 (실측 2026-09-10: 314px)
width: min(300px, 92vw);
border-radius: $r-card;
background: $stage-ink;
box-shadow: $shadow-raised;
padding: clamp(10px, 3vw, 16px);
font: 500 clamp(13px, 3.6vw, 16px)/1.6 "Space Mono", ui-monospace, monospace;
color: $subject-cream;
&__bar { display: flex; gap: 6px; margin: 0 0 clamp(6px, 2vw, 10px); }
&__dot { width: 8px; height: 8px; border-radius: $r-pill; background: rgba($subject-cream, .35); }
&__line { margin: 0; }
&__cmd {
display: inline-block;
overflow: hidden;
white-space: nowrap;
vertical-align: bottom;
width: 0;
border-right: .1em solid $color;
animation: cmd-type $duration linear infinite;
}
&__resp {
margin: clamp(6px, 2vw, 10px) 0 0;
color: $color;
animation: resp-fade $duration ease-out infinite;
}
}
@keyframes cmd-type {
0%, 5% { width: 0; animation-timing-function: $easing; }
45% { width: var(--full, 13ch); }
85% { width: var(--full, 13ch); }
95%, 100% { width: 0; }
}
@keyframes resp-fade {
0%, 44% { opacity: 0; }
50% { opacity: 1; }
82% { opacity: 1; }
90%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.term__cmd { animation: none; width: var(--full, 13ch); border-right-color: transparent; }
.term__resp { animation: 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);
}
.term {
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: clamp(10px, 3vw, 16px);
font: 500 clamp(13px, 3.6vw, 16px)/1.6 "Space Mono", ui-monospace, monospace;
color: #fff7e6;
}
.term__bar {
display: flex;
gap: 6px;
margin: 0 0 clamp(6px, 2vw, 10px);
}
.term__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.35);
}
.term__line {
margin: 0;
}
.term__cmd {
display: inline-block;
overflow: hidden;
white-space: nowrap;
vertical-align: bottom;
width: 0;
border-right: 0.1em solid #2f6df6;
animation: cmd-type 2s linear infinite;
}
.term__resp {
margin: clamp(6px, 2vw, 10px) 0 0;
color: #2f6df6;
animation: resp-fade 2s ease-out infinite;
}
@keyframes cmd-type {
0%, 5% {
width: 0;
animation-timing-function: steps(13, end);
}
45% {
width: var(--full, 13ch);
}
85% {
width: var(--full, 13ch);
}
95%, 100% {
width: 0;
}
}
@keyframes resp-fade {
0%, 44% {
opacity: 0;
}
50% {
opacity: 1;
}
82% {
opacity: 1;
}
90%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.term__cmd {
animation: none;
width: var(--full, 13ch);
border-right-color: transparent;
}
.term__resp {
animation: none;
opacity: 1;
}
}(function () {
var el = document.querySelector('.term__cmd'), cv = document.createElement('canvas').getContext('2d');
cv.font = getComputedStyle(el).font;
el.style.setProperty('--full', Math.ceil(cv.measureText(el.textContent).width) + 'px');
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./TerminalPrompt.module.scss";
export type TerminalPromptProps = {
command: string;
response: string;
/** 명령어→응답 두 단계를 담는 전체 루프 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function TerminalPrompt({ command, response, duration = 2000, color = "#2f6df6" }: TerminalPromptProps) {
const ref = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const el = ref.current;
const cv = el && document.createElement("canvas").getContext("2d");
if (!el || !cv) return;
cv.font = getComputedStyle(el).font;
el.style.setProperty("--full", Math.ceil(cv.measureText(command).width) + "px");
}, [command]);
const vars = { "--duration": `${duration}ms`, "--color": color, "--easing": `steps(${command.length}, end)` } as CSSProperties;
return (
<div className={styles.term} style={vars}>
<div className={styles.bar}><span className={styles.dot} /><span className={styles.dot} /><span className={styles.dot} /></div>
<p className={styles.line}>$ <span ref={ref} className={styles.cmd}>{command}</span></p>
<p className={styles.resp}>{response}</p>
</div>
);
}07 · 터미널 프롬프트 — 측정 누적면적 1.057% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@keyframes resp-fade {
0%, 44% { opacity: 0; }
50% { opacity: 1; }
82% { opacity: 1; }
90%, 100% { opacity: 0; }
}
.term__resp { animation: resp-fade 2s ease-out infinite; }
08SVG 손글씨 효과
어떻게 움직이나
path의 stroke-dasharray를 선 전체 길이보다 넉넉하게 두고 stroke-dashoffset을 그 값에서 0으로 트랜지션하면, 획이 순서대로 그려지는 손글씨 느낌이 납니다.
어디에 쓰나
정확한 길이를 몰라도 되기 때문에 복잡한 곡선을 다시 그릴 때마다 값을 재는 수고가 없습니다.

<main class="stage">
<div class="sig">
<svg class="sig__svg" viewBox="0 0 240 80" fill="none" aria-hidden="true">
<path d="M10 60 C 30 10, 50 70, 70 30 S 110 10, 130 50 S 170 70, 190 20 S 220 40, 230 30" />
</svg>
<p class="sig__label">서명해 주세요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 획을 그리는 한 루프
$easing: $ease-out; // 손으로 긋는 듯 빠르게 시작해 부드럽게 끝난다
$color: $subject-blue; // 선 색
@include stage($stage-paper);
.sig {
display: grid;
justify-items: center;
gap: clamp(6px, 2vw, 12px);
&__svg {
width: min(260px, 82vw);
height: auto;
path {
stroke: $color;
stroke-width: 5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 520;
stroke-dashoffset: 520;
animation: draw $duration $easing infinite;
}
}
&__label {
margin: 0;
font-weight: 700; font-size: clamp(14px, 4vw, 18px); line-height: 1.4; font-family: inherit;
color: $stage-ink;
}
}
@keyframes draw {
0%, 6% { stroke-dashoffset: 520; }
55% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
96%, 100% { stroke-dashoffset: 520; }
}
@media (prefers-reduced-motion: reduce) {
.sig__svg path { animation: none; stroke-dashoffset: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sig {
display: grid;
justify-items: center;
gap: clamp(6px, 2vw, 12px);
}
.sig__svg {
width: min(260px, 82vw);
height: auto;
}
.sig__svg path {
stroke: #2f6df6;
stroke-width: 5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 520;
stroke-dashoffset: 520;
animation: draw 2s ease-out infinite;
}
.sig__label {
margin: 0;
font-weight: 700;
font-size: clamp(14px, 4vw, 18px);
line-height: 1.4;
font-family: inherit;
color: #17141a;
}
@keyframes draw {
0%, 6% {
stroke-dashoffset: 520;
}
55% {
stroke-dashoffset: 0;
}
80% {
stroke-dashoffset: 0;
}
96%, 100% {
stroke-dashoffset: 520;
}
}
@media (prefers-reduced-motion: reduce) {
.sig__svg path {
animation: none;
stroke-dashoffset: 0;
}
}import type { CSSProperties } from "react";
import styles from "./HandwritingSvg.module.scss";
export type HandwritingSvgProps = {
label: string;
/** 획을 그리는 한 루프 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function HandwritingSvg({ label, duration = 2000, color = "#2f6df6" }: HandwritingSvgProps) {
const vars = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<div className={styles.sig} style={vars}>
<svg className={styles.svg} viewBox="0 0 240 80" fill="none" aria-hidden="true">
<path d="M10 60 C 30 10, 50 70, 70 30 S 110 10, 130 50 S 170 70, 190 20 S 220 40, 230 30" />
</svg>
<p className={styles.label}>{label}</p>
</div>
);
}08 · SVG 손글씨 효과 — 측정 누적면적 1.597% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
path {
stroke-dasharray: 520;
stroke-dashoffset: 520;
animation: draw 2s ease-out infinite;
}
@keyframes draw {
0%, 6% { stroke-dashoffset: 520; }
55% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
96%, 100% { stroke-dashoffset: 520; }
}
09단어 교체 타이핑
어떻게 움직이나
같은 자리에 단어 세 개를 겹쳐 두고 각자의 keyframes에 자기 차례의 구간만 내주면, 문장의 나머지는 그대로 둔 채 그 구간에서만 폭 0에서 시작해 타이핑되고 다시 지워집니다.
어디에 쓰나
텍스트 노드를 자바스크립트로 바꿔치기하지 않기 때문에 리액트에서도 동일한 keyframes 세 개만 그대로 옮기면 됩니다.

<main class="stage">
<p class="swap">
<span class="swap__prefix">이건 바로 </span><span class="swap__slot"><span class="swap__word swap__word--1">디자이너</span><span class="swap__word swap__word--2">개발자</span><span class="swap__word swap__word--3">마케터</span></span><span class="swap__suffix"> 를 위한 도구예요</span>
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 단어 세 개가 차례로 도는 전체 루프
$easing: $ease-out; // 문장 색·굵기 톤
$color: $subject-blue; // 바뀌는 단어 색
@include stage($stage-ink);
.swap {
margin: 0;
font-weight: 700; font-size: clamp(17px, 4.8vw, 27px); line-height: 1.5; font-family: inherit;
color: $subject-cream;
&__slot { position: relative; display: inline-flex; vertical-align: bottom; }
&__word {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: .1em solid $color;
color: $color;
&--1 { animation: w1 $duration linear infinite; }
&--2 { position: absolute; left: 0; top: 0; animation: w2 $duration linear infinite; }
&--3 { position: absolute; left: 0; top: 0; animation: w3 $duration linear infinite; }
}
}
@keyframes w1 {
0% { width: 0; animation-timing-function: steps(4, end); }
8% { width: var(--full, 4ch); }
30% { width: var(--full, 4ch); animation-timing-function: steps(4, end); }
38%, 100% { width: 0; }
}
@keyframes w2 {
0%, 40% { width: 0; animation-timing-function: steps(3, end); }
46% { width: var(--full, 4ch); }
68% { width: var(--full, 4ch); animation-timing-function: steps(3, end); }
74%, 100% { width: 0; }
}
@keyframes w3 {
0%, 74% { width: 0; animation-timing-function: steps(3, end); }
80% { width: var(--full, 4ch); }
96% { width: var(--full, 4ch); animation-timing-function: steps(3, end); }
100% { width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.swap__word { animation: none; }
.swap__word--1 { width: var(--full, 4ch); }
.swap__word--2,
.swap__word--3 { width: 0; opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.swap {
margin: 0;
font-weight: 700;
font-size: clamp(17px, 4.8vw, 27px);
line-height: 1.5;
font-family: inherit;
color: #fff7e6;
}
.swap__slot {
position: relative;
display: inline-flex;
vertical-align: bottom;
}
.swap__word {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: 0.1em solid #2f6df6;
color: #2f6df6;
}
.swap__word--1 {
animation: w1 2s linear infinite;
}
.swap__word--2 {
position: absolute;
left: 0;
top: 0;
animation: w2 2s linear infinite;
}
.swap__word--3 {
position: absolute;
left: 0;
top: 0;
animation: w3 2s linear infinite;
}
@keyframes w1 {
0% {
width: 0;
animation-timing-function: steps(4, end);
}
8% {
width: var(--full, 4ch);
}
30% {
width: var(--full, 4ch);
animation-timing-function: steps(4, end);
}
38%, 100% {
width: 0;
}
}
@keyframes w2 {
0%, 40% {
width: 0;
animation-timing-function: steps(3, end);
}
46% {
width: var(--full, 4ch);
}
68% {
width: var(--full, 4ch);
animation-timing-function: steps(3, end);
}
74%, 100% {
width: 0;
}
}
@keyframes w3 {
0%, 74% {
width: 0;
animation-timing-function: steps(3, end);
}
80% {
width: var(--full, 4ch);
}
96% {
width: var(--full, 4ch);
animation-timing-function: steps(3, end);
}
100% {
width: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.swap__word {
animation: none;
}
.swap__word--1 {
width: var(--full, 4ch);
}
.swap__word--2,
.swap__word--3 {
width: 0;
opacity: 0;
}
}(function () {
var cv = document.createElement('canvas').getContext('2d');
document.querySelectorAll('.swap__word').forEach(function (el) {
cv.font = getComputedStyle(el).font;
el.style.setProperty('--full', Math.ceil(cv.measureText(el.textContent).width) + 'px');
});
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./WordSwap.module.scss";
export type WordSwapProps = {
prefix: string;
words: [string, string, string];
suffix: string;
/** 단어 세 개가 차례로 도는 전체 루프 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function WordSwap({ prefix, words, suffix, duration = 2000, color = "#2f6df6" }: WordSwapProps) {
const r1 = useRef<HTMLSpanElement | null>(null);
const r2 = useRef<HTMLSpanElement | null>(null);
const r3 = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const cv = document.createElement("canvas").getContext("2d");
if (!cv) return;
[r1, r2, r3].forEach((r) => {
const el = r.current;
if (!el) return;
cv.font = getComputedStyle(el).font;
el.style.setProperty("--full", Math.ceil(cv.measureText(el.textContent || "").width) + "px");
});
}, [words]);
const vars = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
const e = (s: string) => ({ "--easing": `steps(${s.length}, end)` } as CSSProperties);
return (
<p className={styles.swap} style={vars}>
<span className={styles.prefix}>{prefix} </span>
<span className={styles.slot}>
<span ref={r1} className={`${styles.word} ${styles.w1}`} style={e(words[0])}>{words[0]}</span>
<span ref={r2} className={`${styles.word} ${styles.w2}`} style={e(words[1])}>{words[1]}</span>
<span ref={r3} className={`${styles.word} ${styles.w3}`} style={e(words[2])}>{words[2]}</span>
</span>
<span className={styles.suffix}> {suffix}</span>
</p>
);
}09 · 단어 교체 타이핑 — 측정 누적면적 4.917% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@keyframes w2 {
0%, 40% { width: 0; animation-timing-function: steps(3, end); }
46% { width: 3ch; }
68% { width: 3ch; animation-timing-function: steps(3, end); }
74%, 100% { width: 0; }
}
.swap__word--2 { position: absolute; left: 0; animation: w2 2s linear infinite; }
어디서 깨지는가 — 함정 하나
max-content 로 늘리면
width를 max-content 같은 고유 크기 키워드로 트랜지션하면 언어별로 텍스트 길이가 달라져도 잘리지 않을 거라 기대하기 쉽지만, 실측해 보면 값이 계단식으로 나뉘지 않고 시작 값에서 끝 값으로 한 번에 건너뜁니다.
실측에서 본 것
렌더 측정에서 스물세 프레임 중 단 두 프레임만 바뀐 것으로 잡혔고, 화면으로 봐도 타이핑이 아니라 텍스트가 순간 나타나는 것처럼 보였습니다. width와 max-content 사이의 보간은 interpolate-size 관련 MDN 문서가 다루는 영역이고, 이 값을 켜지 않은 브라우저에서는 이산 애니메이션으로 처리됩니다.
어떻게 고쳤나
아직 지원이 갈리기 때문에(caniuse의 interpolate-size 지원표) 아홉 개 모두 자바스크립트로 실제 렌더 폭을 캔버스에서 재서 CSS 변수에 넣고, 그 변수를 픽셀 길이로 트랜지션하는 방식으로 고쳤습니다. 이렇게 고친 아홉 개 파일의 압축 비밀번호는 erv8c6eb이고, 아래 버튼으로 받을 수 있습니다.
폰트 로드 전에 재면
두 번째로 짚을 것은 03의 clip-path 방식입니다. steps() 대신 캔버스로 잰 실제 글자 폭을 offset으로 직접 넘기기 때문에, 폰트가 아직 로드되지 않은 시점에 측정하면 좁은 폭 기준으로 어긋난 값이 그대로 굳어 버립니다. 폰트 로드가 끝난 뒤에 측정하도록 순서를 맞추는 것이 이 기법의 전제입니다. 07 터미널 프롬프트를 화면 전체 디자인으로 밀고 싶다면 HUD 디자인 랜딩 부품이 그 세계를 통째로 만들어 둔 편입니다.
접근성
동작 줄이기에서
아홉 개 전부 prefers-reduced-motion: reduce에서 트랜지션과 keyframes를 꺼서 완성된 문장이나 획만 즉시 보여주고, 자바스크립트로 만든 03·06의 clip-path 애니메이션도 같은 미디어 쿼리 안에서 el.animate() 호출 자체를 건너뛰도록 처리했습니다.
장식 요소는 어떻게 되나
커서나 원처럼 장식만 담당하는 요소는 애니메이션을 끄는 대신 opacity를 고정값으로 남겨 정보가 사라지지 않게 했습니다. 세부 값 조합은 MDN의 prefers-reduced-motion 문서에 정리돼 있습니다.
@media (prefers-reduced-motion: reduce) {
.type__text { animation: none; width: var(--full, 10ch); border-right-color: transparent; }
}
CSS 텍스트·문자 관련 다른 주제는 CSS 애니메이션 카테고리 안에도 있고, 이 블로그가 데모를 어떻게 만들고 검증하는지는 소개 페이지에 적어 뒀습니다.
FAQ
css 텍스트 타이핑 효과를 CSS만으로 만들 수 있습니까?
네, 01·02·04·05·07·08은 자바스크립트 없이 CSS keyframes만으로 동작합니다. 다만 텍스트 길이가 바뀔 때마다 완성 폭 값도 같이 바꿔야 끝까지 정확히 드러납니다. 타이핑이 아니라 문장이 한 덩어리로 드러나는 등장 방식이 필요하면 CSS 텍스트 애니메이션 — 등장·강조 모음이 그 반대편입니다.
03과 06은 왜 자바스크립트가 필요합니까?
03은 언어마다 다른 실제 글자 폭을 측정해야 하고, 06은 무작위 간격을 미리 계산해 el.animate()에 keyframes로 넘겨야 하기 때문입니다. 두 경우 모두 CSS 값만으로는 얻을 수 없는 수치입니다.
타이핑 속도를 늦추거나 빠르게 하려면 어디를 고칩니까?
각 항목의 SCSS 맨 위에 있는 $duration 변수 하나만 바꾸면 됩니다. steps() 개수는 텍스트 길이에 맞춰져 있어 속도를 바꿔도 끊어지는 리듬 자체는 유지됩니다.









