CSS タイピング アニメーション 9選 — 一行でコピペOK
css タイピング アニメーションは、文字が一文字ずつ入力されていくように見せる、CSSだけで動く小さな部品です。この記事では9種類を選び、steps()とwidth、clip-pathという仕組みだけで実装しました。グリッドの9つはiframeの中で最初から動いていて、クリックでは止められません。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 カーソル点滅
- 02 steps()でタイプ
- 03 多言語グリフ合成タイピング
- 04 複数行タイピング
- 05 消してから再入力
- 06 AI回答のストリーミング風
- 07 ターミナルプロンプト風
- 08 SVG手書き効果
- 09 単語入れ替えタイピング
並び順は効果の派手さではなく、文字を区切って見せる仕組みの難易度です。まずopacityだけでカーソルを点滅させる最も単純な形から始め(01)、widthとsteps()で文字を1文字ずつ伸ばす核となる書き方に進み(02)、その仕組みが等幅フォント前提で壊れる場面をJSのピクセル計測で補い(03)、複数行への拡張(04)と、1つのkeyframesで入力と削除をまとめる書き方(05)を重ねます。06と07はJSでリズムや段階を足す発展形、08はwidthではなくSVGのstroke-dashoffsetという別の仕組み、09は実際の場面への転用として、文の一部だけを入れ替える書き方で締めくくります。 ポインター側を飾りたくなったら「カーソル エフェクト css」9選が隣にあり、タイピング以外の文の動き全般はCSS 文字 アニメーション 9選に集まっています。
01カーソル点滅
どう動くか
入力が終わった位置に文字幅のカーソルバーを置き、opacityをsteps(2, jump-none)で切り替えて本物のカーソルのように点滅させます。
どこで使うか
フォームのプレースホルダー強調や、チャット入力欄の待機状態に向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.line__caret {
display: inline-block;
width: clamp(8px, 2.6vw, 14px);
height: 1.3em;
margin-left: clamp(2px, 1vw, 6px);
background: $color;
animation: caret-blink .3s steps(2, jump-none) infinite;
}
@keyframes caret-blink {
from { opacity: 1; }
to { opacity: 0; }
}
02steps()でタイプ
どう動くか
widthを0から文字数(ch)まで、steps()を使って1文字ずつ区切って伸ばし、タイプライターが打つように見せます。
どこで使うか
ヒーローの見出しやLPのキャッチコピーに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.type__text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: .12em solid $color;
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; }
}
03多言語グリフ合成タイピング
どう動くか
steps()+chは等幅フォント前提のため、ハングルの合成字や絵文字が混じると文字が途中で切れます。JSで各文字の実際のピクセル幅を測り、その境界までclip-pathで区切ることで、日英絵文字が混在するサービス名でも崩れずに表示します。
どこで使うか
MDNのElement.animate()対応表によると、この仕組みが使うWeb Animations APIはChrome・Firefox・Safari・Edgeの最新版で動きます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var el = document.getElementById('gtext'), s = el.textContent;
var 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 };
});
el.animate(kf, { duration: 2000, iterations: Infinity });
04複数行タイピング
css タイピング アニメーション 複数 行をそろえる
各行に独立したsteps()キーフレームを持たせ、各キーフレームがwidthを0のまま保つ開始区間(0%・10%・34%)で待たせることで、上の行が打ち終わってから次の行が続けて打たれます。
どこで使うか
コードエディターのイントロや、複数行の自己紹介カードに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.lines__text {
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: .1em solid $color;
animation-duration: 2s;
animation-iteration-count: infinite;
}
@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; }
}
05消してから再入力
どう動くか
widthが0→100%→0を行き来する1つのkeyframesに入力と削除をまとめ、一文を書いて少し止まり、また消す動作をJSなしで繰り返します。
どこで使うか
空のチャットの案内文や、検索窓のプレースホルダーアニメーションに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.search__text {
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: .1em solid $color;
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); }
96%, 100% { width: 0; }
}
06AI回答のストリーミング風
どう動くか
一定間隔のsteps()の代わりに30〜90msのランダムな間隔で文字を追加するJSループを使い、AIの回答がトークン単位で不規則に流れる感じを再現します。
どこで使うか
AIチャットボットの回答欄や、プロンプト結果のローディング演出に向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var el = document.getElementById('atext'), s = el.textContent;
var 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 });
}
el.animate(kf, { duration: 2000, iterations: Infinity });
07ターミナルプロンプト風
どう動くか
$プロンプトの後ろでコマンドがsteps()で打たれ、そのアニメーションの時間だけ遅れてレスポンス行がopacityでフェードインする、2段階のシーケンスで疑似ターミナルを演出します。
どこで使うか
開発ツールの紹介セクションや、CLI製品のヒーローデモに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.term__cmd {
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: .1em solid $color;
animation: cmd-type 2s linear infinite;
}
.term__resp {
color: $color;
animation: resp-fade 2s ease-out infinite;
}
@keyframes resp-fade {
0%, 44% { opacity: 0; }
50% { opacity: 1; }
82% { opacity: 1; }
90%, 100% { opacity: 0; }
}
08SVG手書き効果
どう動くか
SVG 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.sig__svg path {
stroke: $color;
stroke-width: 5;
stroke-linecap: round;
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単語入れ替えタイピング
どう動くか
同じ位置に重ねた3つの単語がwidth 0で待機し、自分の番の区間だけsteps()でタイピング・削除されることで、文の残りはそのままに1単語だけが変わって見えます。
どこで使うか
役職や名詞が入れ替わるヒーローコピーや、複数機能の紹介スローガンに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.swap__word--1 { animation: w1 2s linear infinite; }
.swap__word--2 {
position: absolute;
left: 0;
top: 0;
animation: w2 2s linear infinite;
}
@keyframes w2 {
0%, 40% { width: 0; animation-timing-function: steps(3, end); }
46% { width: var(--full, 4ch); }
68% { width: var(--full, 4ch); }
74%, 100% { width: 0; }
}
どこで崩れるか — 落とし穴
9つともwidthやopacity、stroke-dashoffsetといった数値をkeyframesやJSのアニメーションで少しずつ動かしていて、文字そのものを直接書き換えるコードは使っていません。
max-content でジャンプする
1つ目の落とし穴は02を組み立てているときに踏んだもので、widthを0からmax-contentへトランジションすると、steps()を付けても文字が一段でジャンプしてしまい、1文字ずつ伸びているようには見えませんでした。JSのcanvasで実際の描画幅をピクセル単位で測り、--fullというカスタムプロパティにpx値で入れてからkeyframesのwidthに使ったことで、steps()どおりに1文字ずつ止まりながら伸びる動きになりました。
3行のタイミング合わせ
2つ目は04の複数行、つまり css タイピング アニメーション 改行 の扱いを組み立てたときのことです。3行それぞれのkeyframeを0%から並べただけで試すと、上から順ではなく3行が同時に打たれて見えました。1行目が終わる10%を2行目のsteps()の開始点に、2行目が終わる34%を3行目の開始点にそろえたことで直りました。この修正込みの9ファイルを開くにはerv8c6ebという文字列が必要です。下のカードからzipを取ってください。
幅が変わって横にがたつく
3つ目は09の単語入れ替えを作ったときのことです。3つの単語を普通にposition: staticのまま並べて試すと、単語が入れ替わるたびに幅が変わって後ろの文の位置が横にがたつきました。2番目と3番目の単語だけposition: absoluteで同じ場所に重ね、1番目の単語だけが実際の文の幅を持つようにしたことで、入れ替わっても文全体の位置は動かなくなりました。 07のターミナル風表示を画面全体のデザインへ広げたい場合は、HUD デザインのLPパーツ9選がその世界を丸ごと作ってあります。
アクセシビリティ
動きを減らす設定では
9つとも prefers-reduced-motion: reduce で入力や削除の動きが止まりますが、要素そのものは消えません。カーソル点滅(01)はopacity 1のまま静止し、steps()タイプ(02)と消してから再入力(05)、複数行(04)、ターミナルのコマンド行(07)はカーソルが消えて文字列全体がそのまま表示されます。
JS を使う2つ
多言語合成(03)とAIストリーミング(06)はJS側でmatchMediaを先に見てアニメーション自体を起動せず、文字は最初から全部表示されます。SVG手書き(08)は線が最初から描き終わった状態で止まり、単語入れ替え(09)は1番目の単語だけが残り、2番目と3番目は幅0のまま隠れます。
@media (prefers-reduced-motion: reduce) {
.line__caret, .type__text, .lines__text,
.search__text, .term__cmd, .term__resp,
.sig__svg path, .swap__word { animation: none !important; }
.type__text, .search__text,
.term__cmd { border-right-color: transparent; }
}
タイピング以外のCSSアニメーションはこちらのカテゴリー一覧に、このサイトの成り立ちは紹介ページにまとめています。
FAQ
9つを同じページに複数置いても動きは重なりませんか?
重なりません。それぞれのアニメーションは自分自身のクラス名やidにしか結び付いていないため、01のカーソルと09の単語入れ替えを隣に並べても、片方のanimationがもう片方の要素に影響することはありません。04の複数行のようにsteps()を使う項目でも、animation-timing-functionと--fullは要素ごとに独立しているので、同時設置に問題はありません。 一方、文字がひとつずつではなく文のかたまりで現れる側のやり方は、css テキスト アニメーション 9選に集めてあります。
Reactで タイピング 風 アニメーション css を再現するには何を使いますか?
zipのreact/フォルダの各コンポーネントは、これまでと同じクラス名をspan要素に付けているだけで、タイプする速さと区切りはCSSのanimationと@keyframesが担います。03と06だけはuseEffectでcanvasやWeb Animations APIを呼び、文字の実際の幅やランダムな間隔を計算してからelement.animate()に渡します。
日本語やハングルが混じっても文字は崩れませんか?
02や04、05のようにsteps()とch単位だけで組む場合は、等幅フォントを前提にしているため合成字や絵文字が混じると途中で切れることがあります。03のようにJSで実際のピクセル幅を測ってclip-pathで区切る書き方に変えると、日本語・ハングル・絵文字が混ざった文章でも文字の途中で切れずに表示できます。









