9 CSS Typing Animation, One Line, Copy-Paste
A css typing animation is a text effect that types itself onto the screen letter by letter, built from a width transition, a steps() function, and a blinking
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Blinking caret
- 02 steps() typewriter
- 03 Multi-glyph composed typing
- 04 Multi-line typing
- 05 Delete and retype
- 06 AI streaming response feel
- 07 Terminal prompt style
- 08 SVG handwriting effect
- 09 Word swap typing
Every demo above sits in a small, non-scrollable iframe that loops on its own — there is nothing to click, so treat each one as something to watch rather than press. The nine below move from the plainest of these css text animation effects to the most composed. 01 and 02 are the two building blocks almost everything else here is made of: a caret that blinks and a line that grows in fixed steps. 03 fixes the one thing 02 quietly gets wrong once real text is involved. 04 and 05 stretch the same idea across several lines and into a full type-then-delete loop. 06 and 07 are the idea-form pair, dressing the mechanism up as an AI reply and a terminal session. 08 swaps the whole technique for an SVG stroke, and 09 closes the set by making only one word in a sentence change at a time. Decorating the pointer instead of the sentence is the neighboring custom cursor CSS effects set, and every other way a sentence can move short of typing is what the CSS text animation effects gather.
01Blinking caret
How it works
A caret the exact width of one character sits right after a line of already-typed text and blinks on a hard opacity step, rather than fading in and out — real text cursors snap; they don't dim.
Where it fits
Drop it into a form placeholder that needs a little more attention, or a chat input box that is simply waiting for someone to start typing.

<main class="stage">
<p class="line"><span class="line__text">How can I help?</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 · Blinking caret — Measured area 0.316% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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 $duration $easing infinite;
}
@keyframes caret-blink {
from { opacity: 1; }
to { opacity: 0; }
}
02steps() typewriter
The css typewriter animation core
A span's width runs from 0 up to the length of its own text using steps(), so the box grows one fixed increment at a time instead of sliding smoothly — that's the difference between a typewriter and a wipe transition.
Where it fits
It is the shape most hero headlines and landing-page taglines reach for first, since one property carries the entire effect.

<main class="stage">
<p class="type"><span class="type__text">One line, copy-paste done</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() typewriter — Measured area 3.509% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
.type__text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: .12em solid $color;
animation: type-loop $duration 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; }
}
03Multi-glyph composed typing
How it works
steps() sized in ch assumes every character takes up the same slot, which breaks the moment Hangul, other CJK glyphs, or an emoji sit next to plain Latin letters — the box either clips mid-glyph or leaves a gap.
Where it fits
A short script measures each character's real pixel width on a canvas and drives a clip-path through those exact boundaries instead, so a mixed-script product name types cleanly.

<main class="stage">
<p class="glyph"><span class="glyph__text" id="gtext">Hello 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 · Multi-glyph composed typing — Measured area 2.801% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
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 });
04Multi-line typing
How it works
Each line owns its own steps() keyframe, and the wait comes from how long that keyframe holds width at 0 before starting (0%, 10%, 34%), so line two only starts once line one has fully typed out.
Where it fits
That queueing is what makes a code-editor intro or a stacked bio card read as one continuous typist rather than three lines racing each other.

<main class="stage">
<div class="lines">
<p class="lines__row"><span class="lines__text lines__text--1">Frontend developer</span></p>
<p class="lines__row"><span class="lines__text lines__text--2">I love CSS animation</span></p>
<p class="lines__row"><span class="lines__text lines__text--3">I enjoy coffee and typing</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 · Multi-line typing — Measured area 8.264% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
@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; }
}
05Delete and retype
How it works
One keyframes block moves width from 0 up to full and back down to 0 again, with a hold in between, so typing and deleting a whole sentence live inside a single animation and never touch JavaScript.
Where it fits
It fits an empty chat room's hint text or a search bar that cycles through a few example queries while nobody is typing yet.

<main class="stage">
<p class="search"><span class="search__text">Search for anything</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 · Delete and retype — Measured area 3.679% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
.search__text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: .1em solid $color;
animation: search-loop $duration 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; }
}
06AI streaming response feel
How it works
Instead of one fixed steps() interval, a loop assigns every character a random 30-90ms gap before it appears, then bakes those uneven offsets into a clip-path keyframe list — the result reads like tokens arriving from a model rather than a metronome typing.
Where it fits
A small pulsing dot sits beside the text as a stand-in for the "still thinking" indicator most chat UIs already show.

<main class="stage">
<p class="ai"><span class="ai__text" id="atext">Sure, let me summarize what you asked</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 streaming response feel — Measured area 2.514% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
var el = document.getElementById('atext'), s = el.textContent, n = s.length;
var 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 });
07Terminal prompt style
How it works
A command types out after a $ prompt using the same steps() mechanism as item 02, and once that finishes a response line underneath fades in on plain opacity — two stages chained by timing alone, no JavaScript watching for the first animation to end.
Where it fits
Drop it into a CLI product's hero section or a dev-tool feature block that wants to show, not describe, what running the tool feels like.

<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">Build complete</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 · Terminal prompt style — Measured area 1.057% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
.term__cmd {
display: inline-block;
overflow: hidden;
white-space: nowrap;
width: 0;
border-right: .1em solid $color;
animation: cmd-type $duration linear infinite;
}
.term__resp {
animation: resp-fade $duration 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; }
}
08SVG handwriting effect
How it works
An SVG path sets stroke-dasharray to its own total length and transitions stroke-dashoffset down from that length to zero, which uncovers the line stroke by stroke exactly the way a pen would draw it — nothing here is a typed character at all.
Where it fits
It reads best as a signature-style reveal on an invitation or a hand-lettered wordmark, somewhere a straight typewriter cursor would feel out of place.

<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">Please sign here</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 handwriting effect — Measured area 1.597% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
.sig__svg path {
stroke: $color;
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 520;
stroke-dashoffset: 520;
animation: draw $duration $easing infinite;
}
@keyframes draw {
0%, 6% { stroke-dashoffset: 520; }
55% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
96%, 100% { stroke-dashoffset: 520; }
}
09Word swap typing
How it works
Three words sit stacked in the exact same spot at width: 0, and each one only gets its own slice of the loop's timeline in which to type itself in with steps() and clear back out again, so the rest of the sentence around it never moves.
Where it fits
It suits a hero line that swaps a single role or noun — "built for designers / developers / marketers" — without touching any of the surrounding words.

<main class="stage">
<p class="swap">
<span class="swap__prefix">Built just for </span><span class="swap__slot"><span class="swap__word swap__word--1">Designers</span><span class="swap__word swap__word--2">Developers</span><span class="swap__word swap__word--3">Marketers</span></span><span class="swap__suffix"> — try it</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 · Word swap typing — Measured area 4.917% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
.swap__word--1 { animation: w1 $duration linear infinite; }
.swap__word--2 { position: absolute; left: 0; top: 0; animation: w2 $duration linear infinite; }
.swap__word--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; }
}
Where it breaks — the trap
Five of these nine grow width toward the exact length of a piece of text, and the laziest way to write that end value is the keyword max-content instead of a hard number. Try it and the animation stops looking like typing at all.
Why it jumps
The instant either end of a width transition is a keyword rather than a length, the browser treats the whole property as discretely animatable. As a result, even a steps() timing function can't carry it smoothly — the box jumps straight from empty to full in one frame partway through the loop instead of climbing character by character.
The fix in all five
Each of those five sidesteps this the same way. A tiny script measures the element's own rendered text with a canvas context's measureText() and writes the result into a --full custom property in real pixels, so the keyframes always animate toward an actual number. Grab all nine from the archive, already built and ready to drop in, plus the react/ versions, rather than retyping nine keyframe blocks and a canvas helper by hand — the zip below opens with the eight characters you'll find sitting in this very sentence, erv8c6eb, typed exactly as shown and nowhere else on the page. Pushing 07's terminal prompt into a full-screen design is the move the HUD interface kit makes.
Accessibility
What each one settles on
None of the nine keep moving once prefers-reduced-motion: reduce is set, but each settles on a different resting state rather than simply vanishing. 01's caret stops blinking and stays visible, since a static cursor still marks where the text ends. 02, 05, 07's command line, and 09's first word all lock their width to the full --full value and drop the caret border to transparent. The complete sentence just sits there instead of stalling half-typed. 04 does the same for all three lines at once.
The scripted two
03 and 06 skip their JavaScript animation entirely behind a matchMedia check and fall back to a plain clip-path: none, so the full string renders immediately with no script running at all. 07's response line and 08's signature path both land on fully visible with no animation left. 09's second and third words stay at width: 0 with opacity: 0, so only the first of the three ever shows. MDN documents the prefers-reduced-motion media feature and the operating-system toggles that flip it on.
@media (prefers-reduced-motion: reduce) {
.type__text,
.search__text,
.term__cmd,
.swap__word--1 {
animation: none;
width: var(--full);
border-right-color: transparent;
}
}
Every other pattern from this site's animation notes lives under the CSS category hub, and what the zip actually contains — and what it doesn't — is explained on the about page.
FAQ
How do I make a css typing animation loop instead of running once?
Wrap the typing keyframes in animation-iteration-count: infinite and give the keyframe list a hold at the end — a percentage range where width stays at its full value — before it drops back to zero and starts over. Items 02, 05, and 07 above all use that same hold-then-reset shape, just with different percentages depending on how long the pause should feel. When the sentence should arrive whole instead of character by character, that reveal side of the family lives in the CSS text animation effects for reveals.
Can a css typing animation paragraph run across multiple lines?
Yes, but not as one animation. Give each line its own element and its own steps() keyframe sized to that line's own character count, then hold width at 0 through the span of time the lines above it need. That's exactly what item 04 does. Treating a whole paragraph as a single box breaks the moment any line wraps, since steps() only knows how to count characters along one straight measurement.
Do I need JavaScript for a css typing animation, or is plain CSS enough?
Plain CSS carries seven of the nine patterns here — items 01, 02, 04, 05, 07, 08, and 09 are pure steps() or stroke-dashoffset transitions with no script involved. JavaScript only earns its place where the text itself is unpredictable: measuring real character widths for mixed-script strings in item 03, or generating the randomized per-character timing behind item 06's streamed-reply feel.









