9 CSS AI Chat Loading Animations to Copy-Paste
Open ChatGPT or Claude and watch dots, a shimmering word, or a blinking line appear before any reply text does.
01 ai-chat-loading-thinking-dots
02 ai-chat-loading-text-shimmer-status
03 ai-chat-loading-streaming-caret
04 ai-chat-loading-bubble-pop-in
05 ai-chat-loading-scroll-fade-edges
06 ai-chat-loading-tool-step-progress
07 ai-chat-loading-token-fade-words
08 ai-chat-loading-stop-button-morph
09 ai-chat-loading-reduced-motion-badge
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Thinking dots
- 02 Text shimmer status
- 03 Streaming caret blink
- 04 Message bubble pop-in
- 05 Scroll edge fade
- 06 Tool-call step progress
- 07 Word-by-word fade-in
- 08 Send-to-stop button morph
- 09 Reduced-motion static badge
These nine aren't ranked by how flashy they look — they follow the order a real AI chat puts them in front of you. First come the signals shown while a reply is still being written (01–03), then the moment that reply actually lands in the conversation (04–05), then the visible proof that the AI is working through a multi-step task (06–07), and last, the control that lets someone stop it and what's left once motion is turned down (08–09). To show how much of the answer remains as a ratio, the circle progress gauges provide the dial, and dressing the wait itself as a full screen is the neighboring loading screen designs.
01Thinking dots
Where a css loading dots animation fits
In the empty spot where a reply is about to appear, three small dots grow and shrink one after another instead of just sitting still.

<main class="stage">
<div class="td-bubble is-demo">
<span class="td-dot"></span>
<span class="td-dot"></span>
<span class="td-dot"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.2s;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.td-bubble {
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-4 $sp-5;
border-radius: $r-card;
background: rgba(255, 255, 255, .08);
}
.td-dot {
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: $color;
opacity: .35;
}
// 실사용: 로딩 시작하면 JS 가 .is-loading 을 붙인다
@keyframes td-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: .35; }
30% { transform: translateY(-6px); opacity: 1; }
}
.td-bubble.is-loading .td-dot,
.td-bubble.is-demo .td-dot {
animation: td-bounce $duration $easing infinite;
}
.td-bubble.is-demo .td-dot:nth-child(1) { animation-delay: 0ms; }
.td-bubble.is-demo .td-dot:nth-child(2) { animation-delay: 160ms; }
.td-bubble.is-demo .td-dot:nth-child(3) { animation-delay: 320ms; }
@media (prefers-reduced-motion: reduce) {
.td-bubble.is-loading .td-dot,
.td-bubble.is-demo .td-dot {
animation: none !important;
opacity: .8 !important;
transform: none !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.td-bubble {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
}
.td-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #fff7e6;
opacity: 0.35;
}
@keyframes td-bounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.35;
}
30% {
transform: translateY(-6px);
opacity: 1;
}
}
.td-bubble.is-loading .td-dot,
.td-bubble.is-demo .td-dot {
animation: td-bounce 1.2s ease-out infinite;
}
.td-bubble.is-demo .td-dot:nth-child(1) {
animation-delay: 0ms;
}
.td-bubble.is-demo .td-dot:nth-child(2) {
animation-delay: 160ms;
}
.td-bubble.is-demo .td-dot:nth-child(3) {
animation-delay: 320ms;
}
@media (prefers-reduced-motion: reduce) {
.td-bubble.is-loading .td-dot,
.td-bubble.is-demo .td-dot {
animation: none !important;
opacity: 0.8 !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./ThinkingDots.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface ThinkingDotsProps {
duration?: string;
easing?: string;
color?: string;
}
export default function ThinkingDots({
duration = "1.2s",
easing = "ease-out",
color = "#fff7e6",
}: ThinkingDotsProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.bubble} style={style}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</div>
);
}01 · Thinking dots — Measured area 0.225% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
The stagger is close enough to a person tapping out a sentence that a visitor reads it as "still working," not "stuck."
@keyframes td-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: .35; }
30% { transform: translateY(-6px); opacity: 1; }
}
.td-dot { animation: td-bounce 1.2s ease-out infinite; }
.td-dot:nth-child(1) { animation-delay: 0ms; }
.td-dot:nth-child(2) { animation-delay: 160ms; }
.td-dot:nth-child(3) { animation-delay: 320ms; }
02Status text shimmer
What it looks like
For work that takes longer than a couple seconds, some chat apps swap the dots for a short status word and run a bright band across the letters instead.

<main class="stage">
<div class="sh-status is-demo">
<span class="sh-label">Thinking…</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.6s;
$easing: linear;
$color: $subject-ink;
@include stage($stage-yellow);
.sh-status {
padding: $sp-3 $sp-5;
border-radius: $r-pill;
background: rgba(23, 20, 26, .06);
}
.sh-label {
display: inline-block;
font: 600 16px/1 -apple-system, "Segoe UI", sans-serif;
background-image: linear-gradient(
90deg,
rgba($subject-ink, .72) 0%,
rgba($subject-ink, .72) 42%,
$subject-cream 50%,
rgba($subject-ink, .72) 58%,
rgba($subject-ink, .72) 100%
);
background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
color: rgba($subject-ink, .72);
}
// 실사용: 상태 문구가 바뀔 때마다 JS 가 텍스트만 교체한다. 쉬머는 항상 재생
@keyframes sh-sweep {
0% { background-position: 120% 0; }
100% { background-position: -20% 0; }
}
.sh-status.is-demo .sh-label {
animation: sh-sweep $duration $easing infinite;
}
@media (prefers-reduced-motion: reduce) {
.sh-status.is-demo .sh-label {
animation: none !important;
background-position: 0 0 !important;
color: $color !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sh-status {
padding: 12px 20px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.06);
}
.sh-label {
display: inline-block;
font: 600 16px/1 -apple-system, "Segoe UI", sans-serif;
background-image: linear-gradient(90deg, rgba(23, 20, 26, 0.72) 0%, rgba(23, 20, 26, 0.72) 42%, #fff7e6 50%, rgba(23, 20, 26, 0.72) 58%, rgba(23, 20, 26, 0.72) 100%);
background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
color: rgba(23, 20, 26, 0.72);
}
@keyframes sh-sweep {
0% {
background-position: 120% 0;
}
100% {
background-position: -20% 0;
}
}
.sh-status.is-demo .sh-label {
animation: sh-sweep 1.6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.sh-status.is-demo .sh-label {
animation: none !important;
background-position: 0 0 !important;
color: #17141a !important;
}
}import { CSSProperties } from "react";
import styles from "./TextShimmerStatus.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface TextShimmerStatusProps {
duration?: string;
easing?: string;
color?: string;
label?: string;
}
export default function TextShimmerStatus({
duration = "1.6s",
easing = "linear",
color = "#17141a",
label = "Thinking…",
}: TextShimmerStatusProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.status} style={style}>
<span className={styles.label}>{label}</span>
</div>
);
}02 · Text shimmer status — Measured area 0.287% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
Nothing about the text itself changes size or position — only a background-position value moves, so the word stays perfectly still while the light passes over it.
.sh-label {
background-image: linear-gradient(
90deg,
rgba(23, 20, 26, .3) 0%,
rgba(23, 20, 26, .3) 42%,
#17141a 50%,
rgba(23, 20, 26, .3) 58%,
rgba(23, 20, 26, .3) 100%
);
background-size: 220% 100%;
background-clip: text;
color: rgba(23, 20, 26, .3);
animation: sh-sweep 1.6s linear infinite;
}
@keyframes sh-sweep {
0% { background-position: 120% 0; }
100% { background-position: -20% 0; }
}
03Streaming caret blink
When to use it
Once real words start appearing on screen one after another, a short vertical bar sits right after the last letter and blinks, marking the exact spot where the next character is about to land.

<main class="stage">
<p class="cr-line is-demo">The border light rotates once around <span class="cr-tail">the card</span><span class="cr-caret"></span></p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 530ms;
$easing: steps(1);
$color: $subject-cream;
@include stage($stage-orange);
.cr-line {
width: min(300px, 88vw);
margin: 0;
padding: $sp-4 $sp-5;
border-radius: $r-card;
box-sizing: border-box;
background: rgba(255, 255, 255, .12);
color: $subject-ink; // 주황 무대 위 글자 — AA 4.5 (blog-design 글자 대비)
font: 500 14px/1.5 -apple-system, "Segoe UI", sans-serif;
}
.cr-caret {
display: inline-block;
width: $sp-2;
height: 1em;
margin-left: $sp-1;
vertical-align: -0.2em;
background: $subject-ink; // 글자와 같은 색 (주황 무대 위 AA 4.5)
}
// 마지막으로 방금 도착한 단어 — 커서와 같은 박자로 깜빡여 "지금 여기"를 눈에 띄게 한다
.cr-tail { opacity: 1; }
// 실사용: 새 토큰이 도착하는 동안 항상 깜빡인다
@keyframes cr-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: .25; }
}
.cr-line.is-demo .cr-caret,
.cr-line.is-demo .cr-tail {
animation: cr-blink $duration $easing infinite;
}
@media (prefers-reduced-motion: reduce) {
.cr-line.is-demo .cr-caret,
.cr-line.is-demo .cr-tail {
animation: none !important;
opacity: 1 !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);
}
.cr-line {
width: min(300px, 88vw);
margin: 0;
padding: 16px 20px;
border-radius: 16px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.12);
color: #17141a;
font: 500 14px/1.5 -apple-system, "Segoe UI", sans-serif;
}
.cr-caret {
display: inline-block;
width: 8px;
height: 1em;
margin-left: 4px;
vertical-align: -0.2em;
background: #17141a;
}
.cr-tail {
opacity: 1;
}
@keyframes cr-blink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0.25;
}
}
.cr-line.is-demo .cr-caret,
.cr-line.is-demo .cr-tail {
animation: cr-blink 530ms steps(1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.cr-line.is-demo .cr-caret,
.cr-line.is-demo .cr-tail {
animation: none !important;
opacity: 1 !important;
}
}import { CSSProperties } from "react";
import styles from "./StreamingCaret.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface StreamingCaretProps {
duration?: string;
easing?: string;
color?: string;
text?: string;
tail?: string;
}
export default function StreamingCaret({
duration = "530ms",
easing = "steps(1)",
color = "#fff7e6",
text = "The border light rotates once around ",
tail = "the card",
}: StreamingCaretProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<p className={styles.line} style={style}>
{text}
<span className={styles.tail}>{tail}</span>
<span className={styles.caret} />
</p>
);
}03 · Streaming caret blink — Measured area 0.272% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
The bar itself never changes width — only its opacity toggles — because animating the true width of streaming text would force the browser to recalculate the layout on every single frame.
.cr-caret {
display: inline-block;
width: 8px;
height: 1em;
vertical-align: -0.2em;
background: #fff7e6;
animation: cr-blink 530ms steps(1) infinite;
}
@keyframes cr-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: .25; }
}
04Message bubble pop-in
What you see
The instant a full reply is ready, the empty slot where it was going to sit turns into a bubble that overshoots slightly larger than its final size before settling back down.

<main class="stage">
<div class="bp-bubble is-demo">Here's the summary you asked for.</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue-deep; // 크림 글자가 얹히므로 진한 파랑
@include stage($stage-paper);
.bp-bubble {
max-width: min(280px, 84vw);
padding: $sp-4 $sp-5;
border-radius: $r-card;
box-sizing: border-box;
background: $color;
color: $subject-cream;
font: 500 14px/1.5 -apple-system, "Segoe UI", sans-serif;
box-shadow: $shadow-press;
}
// 실사용: 답변이 도착하는 순간 한 번만 재생 (transitionend 뒤 그대로 유지)
@keyframes bp-pop {
0% { transform: scale(.82); opacity: 0; }
15% { transform: scale(1.05); opacity: 1; }
25% { transform: scale(1); opacity: 1; }
70% { transform: scale(1); opacity: 1; }
85% { transform: scale(.9); opacity: 0; }
100% { transform: scale(.82); opacity: 0; }
}
.bp-bubble.is-demo {
animation: bp-pop $duration $easing infinite;
}
@media (prefers-reduced-motion: reduce) {
.bp-bubble.is-demo {
animation: none !important;
transform: none !important;
opacity: 1 !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp-bubble {
max-width: min(280px, 84vw);
padding: 16px 20px;
border-radius: 16px;
box-sizing: border-box;
background: #0a4fe8;
color: #fff7e6;
font: 500 14px/1.5 -apple-system, "Segoe UI", sans-serif;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@keyframes bp-pop {
0% {
transform: scale(0.82);
opacity: 0;
}
15% {
transform: scale(1.05);
opacity: 1;
}
25% {
transform: scale(1);
opacity: 1;
}
70% {
transform: scale(1);
opacity: 1;
}
85% {
transform: scale(0.9);
opacity: 0;
}
100% {
transform: scale(0.82);
opacity: 0;
}
}
.bp-bubble.is-demo {
animation: bp-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.bp-bubble.is-demo {
animation: none !important;
transform: none !important;
opacity: 1 !important;
}
}import { CSSProperties } from "react";
import styles from "./BubblePopIn.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface BubblePopInProps {
duration?: string;
easing?: string;
color?: string;
text?: string;
}
export default function BubblePopIn({
duration = "2s",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#0a4fe8",
text = "Here's the summary you asked for.",
}: BubblePopInProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.bubble} style={style}>
{text}
</div>
);
}04 · Message bubble pop-in — Measured area 13.303% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
That small overshoot is what makes an arriving answer feel like it landed with a bit of weight, rather than just blinking into existence.
@keyframes bp-pop {
0% { transform: scale(.82); opacity: 0; }
15% { transform: scale(1.05); opacity: 1; }
25% { transform: scale(1); opacity: 1; }
70% { transform: scale(1); opacity: 1; }
85% { transform: scale(.9); opacity: 0; }
100% { transform: scale(.82); opacity: 0; }
}
.bp-bubble {
animation: bp-pop 2s cubic-bezier(.2, 1.4, .4, 1) infinite;
}
05Scroll edge fade
Where it fits
Once enough messages pile up that the conversation needs to scroll, the very top and bottom of that pane fade out instead of cutting a message off mid-line.

<main class="stage">
<div class="sf-viewport">
<div class="sf-track is-demo">
<div class="sf-msg">Here's the summary you asked for.</div>
<div class="sf-msg">Let me check the docs first.</div>
<div class="sf-msg">Found three matching results.</div>
<div class="sf-msg">Comparing the two approaches now.</div>
<div class="sf-msg">Here's the summary you asked for.</div>
<div class="sf-msg">Let me check the docs first.</div>
<div class="sf-msg">Found three matching results.</div>
<div class="sf-msg">Comparing the two approaches now.</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 6s;
$easing: linear;
$color: $subject-ink;
@include stage($stage-ink);
.sf-viewport {
width: min(300px, 88vw);
height: 170px;
overflow: hidden;
box-sizing: border-box;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
// 가장자리 페이드 — 이 마스크는 고정. 움직이는 건 안의 트랙뿐
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 $sp-8, #000 calc(100% - #{$sp-8}), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, #000 $sp-8, #000 calc(100% - #{$sp-8}), transparent 100%);
padding: $sp-4 0;
}
.sf-track {
display: flex;
flex-direction: column;
}
.sf-msg {
padding: $sp-2 $sp-4;
margin: 0 $sp-4 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .06);
color: $color;
font: 500 12px/1.4 -apple-system, "Segoe UI", sans-serif;
}
// 실사용: 새 메시지가 올 때마다 scrollTop 을 내린다. 데모는 같은 목록 두 벌을 이어 -50% 루프로 흉내낸다
@keyframes sf-scroll {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
.sf-track.is-demo {
animation: sf-scroll $duration $easing infinite;
}
@media (prefers-reduced-motion: reduce) {
.sf-track.is-demo {
animation: none !important;
transform: none !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sf-viewport {
width: min(300px, 88vw);
height: 170px;
overflow: hidden;
box-sizing: border-box;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 32px, #000 calc(100% - 32px), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, #000 32px, #000 calc(100% - 32px), transparent 100%);
padding: 16px 0;
}
.sf-track {
display: flex;
flex-direction: column;
}
.sf-msg {
padding: 8px 16px;
margin: 0 16px 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font: 500 12px/1.4 -apple-system, "Segoe UI", sans-serif;
}
@keyframes sf-scroll {
from {
transform: translateY(0);
}
to {
transform: translateY(-50%);
}
}
.sf-track.is-demo {
animation: sf-scroll 6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.sf-track.is-demo {
animation: none !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./ScrollFadeEdges.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface ScrollFadeEdgesProps {
duration?: string;
easing?: string;
color?: string;
messages?: string[];
}
const DEFAULT_MESSAGES = [
"Here's the summary you asked for.",
"Let me check the docs first.",
"Found three matching results.",
"Comparing the two approaches now.",
];
export default function ScrollFadeEdges({
duration = "6s",
easing = "linear",
color = "#17141a",
messages = DEFAULT_MESSAGES,
}: ScrollFadeEdgesProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
const doubled = [...messages, ...messages];
return (
<div className={styles.viewport} style={style}>
<div className={styles.track}>
{doubled.map((m, i) => (
<div className={styles.msg} key={i}>{m}</div>
))}
</div>
</div>
);
}05 · Scroll edge fade — Measured area 27.117% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
The fade itself is a static mask-image that never animates — the only thing actually moving is the message list underneath it, sliding up.
.sf-viewport {
overflow: hidden;
mask-image: linear-gradient(
to bottom,
transparent 0, #000 32px,
#000 calc(100% - 32px), transparent 100%
);
}
.sf-track {
animation: sf-scroll 6s linear infinite;
}
@keyframes sf-scroll {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
06Tool-call step progress
What moves
When an AI works through several steps in order — searching, reading a page, writing an answer — those steps stack vertically, and a connecting line fills downward behind each one as it finishes.

<main class="stage">
<div class="ts-list is-demo">
<div class="ts-step">
<span class="ts-icon ts-icon--1"><svg viewBox="0 0 16 16"><path d="M3 8.5l3 3 7-7"/></svg></span>
<span class="ts-label">Searching web</span>
</div>
<span class="ts-line ts-line--1"></span>
<div class="ts-step">
<span class="ts-icon ts-icon--2"><svg viewBox="0 0 16 16"><path d="M3 8.5l3 3 7-7"/></svg></span>
<span class="ts-label">Reading page</span>
</div>
<span class="ts-line ts-line--2"></span>
<div class="ts-step">
<span class="ts-icon ts-icon--3"><svg viewBox="0 0 16 16"><path d="M3 8.5l3 3 7-7"/></svg></span>
<span class="ts-label">Writing answer</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2s;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.ts-list {
display: grid;
grid-template-columns: $sp-5 1fr;
column-gap: $sp-3;
width: min(260px, 84vw);
}
.ts-step { display: contents; }
.ts-icon {
grid-column: 1;
justify-self: center;
width: $sp-5;
height: $sp-5;
border-radius: $r-pill;
display: grid;
place-items: center;
background: rgba(23, 20, 26, .08);
color: $color;
svg { width: 12px; height: 12px; }
path {
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 16;
stroke-dashoffset: 16;
}
}
.ts-icon--1 path { stroke-dashoffset: 0; } // 이미 끝난 첫 단계
.ts-label {
grid-column: 2;
align-self: center;
padding: $sp-2 0;
color: $color;
font: 500 12px/1.2 -apple-system, "Segoe UI", sans-serif;
}
.ts-line {
grid-column: 1;
justify-self: center;
width: 2px;
height: $sp-6;
background: rgba(23, 20, 26, .18);
transform-origin: top; // 규칙8: 세로 연결선 위변 채움
transform: scaleY(0);
}
// 실사용: 도구 이벤트마다 JS 가 .is-done 을 붙인다(위 스크립트). 데모 루프는 같은 순서를 keyframes 로 반복한다
@keyframes ts-line-fill { 0%, 5% { transform: scaleY(0); } 25%, 100% { transform: scaleY(1); } }
@keyframes ts-check-2 { 0%, 25% { stroke-dashoffset: 16; } 40%, 100% { stroke-dashoffset: 0; } }
@keyframes ts-line-fill2 { 0%, 40% { transform: scaleY(0); } 60%, 100% { transform: scaleY(1); } }
@keyframes ts-check-3 { 0%, 60% { stroke-dashoffset: 16; } 75%, 100% { stroke-dashoffset: 0; } }
.ts-list.is-demo .ts-line--1 { animation: ts-line-fill $duration $easing infinite; }
.ts-list.is-demo .ts-icon--2 path { animation: ts-check-2 $duration $easing infinite; }
.ts-list.is-demo .ts-line--2 { animation: ts-line-fill2 $duration $easing infinite; }
.ts-list.is-demo .ts-icon--3 path { animation: ts-check-3 $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.ts-list.is-demo .ts-line--1,
.ts-list.is-demo .ts-line--2 { animation: none !important; transform: scaleY(1) !important; }
.ts-list.is-demo .ts-icon--2 path,
.ts-list.is-demo .ts-icon--3 path { animation: none !important; stroke-dashoffset: 0 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ts-list {
display: grid;
grid-template-columns: 20px 1fr;
column-gap: 12px;
width: min(260px, 84vw);
}
.ts-step {
display: contents;
}
.ts-icon {
grid-column: 1;
justify-self: center;
width: 20px;
height: 20px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
}
.ts-icon svg {
width: 12px;
height: 12px;
}
.ts-icon path {
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 16;
stroke-dashoffset: 16;
}
.ts-icon--1 path {
stroke-dashoffset: 0;
}
.ts-label {
grid-column: 2;
align-self: center;
padding: 8px 0;
color: #17141a;
font: 500 12px/1.2 -apple-system, "Segoe UI", sans-serif;
}
.ts-line {
grid-column: 1;
justify-self: center;
width: 2px;
height: 24px;
background: rgba(23, 20, 26, 0.18);
transform-origin: top;
transform: scaleY(0);
}
@keyframes ts-line-fill {
0%, 5% {
transform: scaleY(0);
}
25%, 100% {
transform: scaleY(1);
}
}
@keyframes ts-check-2 {
0%, 25% {
stroke-dashoffset: 16;
}
40%, 100% {
stroke-dashoffset: 0;
}
}
@keyframes ts-line-fill2 {
0%, 40% {
transform: scaleY(0);
}
60%, 100% {
transform: scaleY(1);
}
}
@keyframes ts-check-3 {
0%, 60% {
stroke-dashoffset: 16;
}
75%, 100% {
stroke-dashoffset: 0;
}
}
.ts-list.is-demo .ts-line--1 {
animation: ts-line-fill 2s ease-out infinite;
}
.ts-list.is-demo .ts-icon--2 path {
animation: ts-check-2 2s ease-out infinite;
}
.ts-list.is-demo .ts-line--2 {
animation: ts-line-fill2 2s ease-out infinite;
}
.ts-list.is-demo .ts-icon--3 path {
animation: ts-check-3 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.ts-list.is-demo .ts-line--1,
.ts-list.is-demo .ts-line--2 {
animation: none !important;
transform: scaleY(1) !important;
}
.ts-list.is-demo .ts-icon--2 path,
.ts-list.is-demo .ts-icon--3 path {
animation: none !important;
stroke-dashoffset: 0 !important;
}
}// 실사용: 도구 호출 이벤트가 올 때마다 부른다 (이 데모의 자동 루프에는 연결하지 않는다)
function tsAdvance(list, n) {
var steps = list.querySelectorAll('.ts-step');
var lines = list.querySelectorAll('.ts-line');
for (var i = 0; i < n; i++) steps[i].classList.add('is-done');
for (var i = 0; i < n - 1; i++) lines[i].classList.add('is-done');
}import { CSSProperties } from "react";
import styles from "./ToolStepProgress.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface ToolStepProgressProps {
duration?: string;
easing?: string;
color?: string;
steps?: string[];
}
const Check = () => (
<svg viewBox="0 0 16 16">
<path d="M3 8.5l3 3 7-7" />
</svg>
);
export default function ToolStepProgress({
duration = "2s",
easing = "ease-out",
color = "#17141a",
steps = ["Searching web", "Reading page", "Writing answer"],
}: ToolStepProgressProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.list} style={style}>
<div className={styles.step}>
<span className={`${styles.icon} ${styles.icon1}`}><Check /></span>
<span className={styles.label}>{steps[0]}</span>
</div>
<span className={`${styles.line} ${styles.line1}`} />
<div className={styles.step}>
<span className={`${styles.icon} ${styles.icon2}`}><Check /></span>
<span className={styles.label}>{steps[1]}</span>
</div>
<span className={`${styles.line} ${styles.line2}`} />
<div className={styles.step}>
<span className={`${styles.icon} ${styles.icon3}`}><Check /></span>
<span className={styles.label}>{steps[2]}</span>
</div>
</div>
);
}06 · Tool-call step progress — Measured area 0.112% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
The line doesn't grow by animating height; it starts squashed to nothing with transform-origin: top and stretches with scaleY, so it never pushes the label below it out of place while it's mid-fill.
.ts-line {
height: 24px;
transform-origin: top;
transform: scaleY(0);
animation: ts-line-fill 2s ease-out infinite;
}
@keyframes ts-line-fill {
0%, 5% { transform: scaleY(0); }
25%, 100% { transform: scaleY(1); }
}
.ts-icon path {
stroke-dasharray: 16;
stroke-dashoffset: 16;
animation: ts-check 2s ease-out infinite;
}
@keyframes ts-check {
0%, 25% { stroke-dashoffset: 16; }
40%, 100% { stroke-dashoffset: 0; }
}
A real chat interface doesn't loop this on its own — a short script marks each step done the moment its matching tool event actually arrives:
function tsAdvance(list, n) {
var steps = list.querySelectorAll('.ts-step');
var lines = list.querySelectorAll('.ts-line');
for (var i = 0; i < n; i++) steps[i].classList.add('is-done');
for (var i = 0; i < n - 1; i++) lines[i].classList.add('is-done');
}
07Word-by-word fade-in
The moment it fires
Rather than a finished sentence appearing all at once, each word rises slightly and fades in right after the one before it, echoing how a streaming reply actually lands one token at a time.

<main class="stage">
<p class="wf-line is-demo"><span>Here's</span> <span>a</span> <span>quick</span> <span>summary</span> <span>of</span> <span>what</span> <span>changed</span> <span>today.</span></p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2s;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.wf-line {
width: min(300px, 88vw);
margin: 0;
padding: $sp-4 $sp-5;
border-radius: $r-card;
box-sizing: border-box;
background: rgba(255, 255, 255, .14);
color: $color;
font: 500 15px/1.6 -apple-system, "Segoe UI", sans-serif;
}
.wf-line span {
display: inline-block;
}
// 실사용: 토큰이 도착할 때마다 새 <span> 을 붙인다. 데모는 같은 문장을 순서대로 반복한다
@keyframes wf-word {
0%, 100% { opacity: 0; transform: translateY(6px); }
15%, 78% { opacity: 1; transform: translateY(0); }
92% { opacity: 0; transform: translateY(6px); }
}
.wf-line.is-demo span {
animation: wf-word $duration $easing infinite;
}
.wf-line.is-demo span:nth-child(1) { animation-delay: 0ms; }
.wf-line.is-demo span:nth-child(2) { animation-delay: 90ms; }
.wf-line.is-demo span:nth-child(3) { animation-delay: 180ms; }
.wf-line.is-demo span:nth-child(4) { animation-delay: 270ms; }
.wf-line.is-demo span:nth-child(5) { animation-delay: 360ms; }
.wf-line.is-demo span:nth-child(6) { animation-delay: 450ms; }
.wf-line.is-demo span:nth-child(7) { animation-delay: 540ms; }
.wf-line.is-demo span:nth-child(8) { animation-delay: 630ms; }
@media (prefers-reduced-motion: reduce) {
.wf-line.is-demo span {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wf-line {
width: min(300px, 88vw);
margin: 0;
padding: 16px 20px;
border-radius: 16px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.14);
color: #17141a;
font: 500 15px/1.6 -apple-system, "Segoe UI", sans-serif;
}
.wf-line span {
display: inline-block;
}
@keyframes wf-word {
0%, 100% {
opacity: 0;
transform: translateY(6px);
}
15%, 78% {
opacity: 1;
transform: translateY(0);
}
92% {
opacity: 0;
transform: translateY(6px);
}
}
.wf-line.is-demo span {
animation: wf-word 2s ease-out infinite;
}
.wf-line.is-demo span:nth-child(1) {
animation-delay: 0ms;
}
.wf-line.is-demo span:nth-child(2) {
animation-delay: 90ms;
}
.wf-line.is-demo span:nth-child(3) {
animation-delay: 180ms;
}
.wf-line.is-demo span:nth-child(4) {
animation-delay: 270ms;
}
.wf-line.is-demo span:nth-child(5) {
animation-delay: 360ms;
}
.wf-line.is-demo span:nth-child(6) {
animation-delay: 450ms;
}
.wf-line.is-demo span:nth-child(7) {
animation-delay: 540ms;
}
.wf-line.is-demo span:nth-child(8) {
animation-delay: 630ms;
}
@media (prefers-reduced-motion: reduce) {
.wf-line.is-demo span {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./TokenFadeWords.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface TokenFadeWordsProps {
duration?: string;
easing?: string;
color?: string;
words?: string[];
}
const DEFAULT_WORDS = ["Here's", "a", "quick", "summary", "of", "what", "changed", "today."];
export default function TokenFadeWords({
duration = "2s",
easing = "ease-out",
color = "#17141a",
words = DEFAULT_WORDS,
}: TokenFadeWordsProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<p className={styles.line} style={style}>
{words.map((w, i) => (
<span key={i} className={styles.word} style={{ animationDelay: `${i * 90}ms` }}>
{w}{" "}
</span>
))}
</p>
);
}07 · Word-by-word fade-in — Measured area 2.202% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
Every <span> shares one keyframe, and only a 90ms animation-delay step between each nth-child is what spreads them apart.
@keyframes wf-word {
0%, 100% { opacity: 0; transform: translateY(6px); }
15%, 78% { opacity: 1; transform: translateY(0); }
92% { opacity: 0; transform: translateY(6px); }
}
.wf-line span {
animation: wf-word 2s ease-out infinite;
}
.wf-line span:nth-child(1) { animation-delay: 0ms; }
.wf-line span:nth-child(2) { animation-delay: 90ms; }
.wf-line span:nth-child(3) { animation-delay: 180ms; }
08Send-to-stop button morph
What the user notices
Tap send while an answer is generating and the paper-plane icon shrinks away as a stop square grows in its place, without the button itself changing size or shape.

<main class="stage">
<button class="sm-btn is-demo" type="button" aria-label="Send message">
<svg class="sm-icon sm-icon--send" viewBox="0 0 24 24"><path d="M3 11l18-8-8 18-2-8-8-2z"/></svg>
<span class="sm-icon sm-icon--stop"></span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.sm-btn {
display: grid;
place-items: center;
width: $sp-12;
height: $sp-12;
border-radius: $r-pill;
border: none;
padding: 0;
background: $color;
box-shadow: $shadow-press;
}
.sm-icon {
grid-column: 1;
grid-row: 1;
}
.sm-icon--send {
width: $sp-5;
height: $sp-5;
path { fill: $subject-cream; }
}
.sm-icon--stop {
width: $sp-4;
height: $sp-4;
border-radius: $r-xs;
background: $subject-cream;
opacity: 0;
transform: scale(.5);
}
// 실사용: 전송을 누르면 한 번만 재생하고 정지 상태로 멈춘다. 데모는 오가며 반복 재생한다
@keyframes sm-send {
0%, 12% { opacity: 1; transform: scale(1); }
30%, 62% { opacity: 0; transform: scale(.5); }
80%, 100% { opacity: 1; transform: scale(1); }
}
@keyframes sm-stop {
0%, 12% { opacity: 0; transform: scale(.5); }
30%, 62% { opacity: 1; transform: scale(1); }
80%, 100% { opacity: 0; transform: scale(.5); }
}
.sm-btn.is-demo .sm-icon--send { animation: sm-send $duration $easing infinite; }
.sm-btn.is-demo .sm-icon--stop { animation: sm-stop $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.sm-btn.is-demo .sm-icon--send {
animation: none !important; opacity: 1 !important; transform: scale(1) !important;
}
.sm-btn.is-demo .sm-icon--stop {
animation: none !important; opacity: 0 !important; transform: scale(.5) !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm-btn {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 999px;
border: none;
padding: 0;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.sm-icon {
grid-column: 1;
grid-row: 1;
}
.sm-icon--send {
width: 20px;
height: 20px;
}
.sm-icon--send path {
fill: #fff7e6;
}
.sm-icon--stop {
width: 16px;
height: 16px;
border-radius: 4px;
background: #fff7e6;
opacity: 0;
transform: scale(0.5);
}
@keyframes sm-send {
0%, 12% {
opacity: 1;
transform: scale(1);
}
30%, 62% {
opacity: 0;
transform: scale(0.5);
}
80%, 100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes sm-stop {
0%, 12% {
opacity: 0;
transform: scale(0.5);
}
30%, 62% {
opacity: 1;
transform: scale(1);
}
80%, 100% {
opacity: 0;
transform: scale(0.5);
}
}
.sm-btn.is-demo .sm-icon--send {
animation: sm-send 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.sm-btn.is-demo .sm-icon--stop {
animation: sm-stop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.sm-btn.is-demo .sm-icon--send {
animation: none !important;
opacity: 1 !important;
transform: scale(1) !important;
}
.sm-btn.is-demo .sm-icon--stop {
animation: none !important;
opacity: 0 !important;
transform: scale(0.5) !important;
}
}import { CSSProperties } from "react";
import styles from "./StopButtonMorph.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface StopButtonMorphProps {
duration?: string;
easing?: string;
color?: string;
}
export default function StopButtonMorph({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: StopButtonMorphProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<button className={styles.btn} style={style} type="button" aria-label="Send message">
<svg className={`${styles.icon} ${styles.send}`} viewBox="0 0 24 24">
<path d="M3 11l18-8-8 18-2-8-8-2z" />
</svg>
<span className={`${styles.icon} ${styles.stop}`} />
</button>
);
}08 · Send-to-stop button morph — Measured area 0.176% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
Both icons sit stacked in the exact same grid cell, so the crossfade is nothing but opacity and scale swapping which one looks solid.
.sm-icon { grid-column: 1; grid-row: 1; }
@keyframes sm-send {
0%, 12% { opacity: 1; transform: scale(1); }
30%, 62% { opacity: 0; transform: scale(.5); }
80%, 100% { opacity: 1; transform: scale(1); }
}
@keyframes sm-stop {
0%, 12% { opacity: 0; transform: scale(.5); }
30%, 62% { opacity: 1; transform: scale(1); }
80%, 100% { opacity: 0; transform: scale(.5); }
}
.sm-icon--send { animation: sm-send 2s cubic-bezier(.2, .8, .2, 1) infinite; }
.sm-icon--stop { animation: sm-stop 2s cubic-bezier(.2, .8, .2, 1) infinite; }
09Reduced-motion static badge
Where this shows up
A small status dot next to a "Generating" label normally pulses on its own, spreading out with box-shadow while it briefly scales up.

<main class="stage">
<div class="rb-badge is-demo">
<span class="rb-dot"></span>
<span class="rb-text">Generating</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.6s;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.rb-badge {
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-4;
border-radius: $r-pill;
background: rgba(255, 255, 255, .08);
color: $subject-cream;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
}
.rb-dot {
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: $color;
}
// 기본: box-shadow 로 번지는 맥박. prefers-reduced-motion: reduce 에서는 애니메이션을 끄고
// 점 자체를 고정 크기로 두어(정적 뱃지) 같은 자리에서 움직임 없이 "생성 중" 임을 전달한다
@keyframes rb-pulse {
0% { box-shadow: 0 0 0 0 rgba(47, 109, 246, .55); transform: scale(1); }
70% { box-shadow: 0 0 0 8px rgba(47, 109, 246, 0); transform: scale(1.15); }
100% { box-shadow: 0 0 0 0 rgba(47, 109, 246, 0); transform: scale(1); }
}
.rb-badge.is-demo .rb-dot {
animation: rb-pulse $duration $easing infinite;
}
@media (prefers-reduced-motion: reduce) {
.rb-badge.is-demo .rb-dot {
animation: none !important;
box-shadow: none !important;
transform: none !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rb-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #fff7e6;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
}
.rb-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #2f6df6;
}
@keyframes rb-pulse {
0% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.55);
transform: scale(1);
}
70% {
box-shadow: 0 0 0 8px rgba(47, 109, 246, 0);
transform: scale(1.15);
}
100% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0);
transform: scale(1);
}
}
.rb-badge.is-demo .rb-dot {
animation: rb-pulse 1.6s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.rb-badge.is-demo .rb-dot {
animation: none !important;
box-shadow: none !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./ReducedMotionBadge.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface ReducedMotionBadgeProps {
duration?: string;
easing?: string;
color?: string;
label?: string;
}
export default function ReducedMotionBadge({
duration = "1.6s",
easing = "ease-out",
color = "#2f6df6",
label = "Generating",
}: ReducedMotionBadgeProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.badge} style={style}>
<span className={styles.dot} />
<span className={styles.text}>{label}</span>
</div>
);
}09 · Reduced-motion static badge — Measured area 0.278% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
Turn on reduced motion at the system level and nothing gets swapped for a different badge — it's the very same dot and the very same label, just with the pulse switched off, so it sits still instead of breathing.
@keyframes rb-pulse {
0% { box-shadow: 0 0 0 0 rgba(47, 109, 246, .55); transform: scale(1); }
70% { box-shadow: 0 0 0 8px rgba(47, 109, 246, 0); transform: scale(1.15); }
100% { box-shadow: 0 0 0 0 rgba(47, 109, 246, 0); transform: scale(1); }
}
.rb-dot {
animation: rb-pulse 1.6s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.rb-dot {
animation: none !important;
box-shadow: none !important;
transform: none !important;
}
}
Where do these AI chat loading animations break?
What goes wrong
Item 05's scroll fade is the one chat loading animation css snippet that trips up a copy-paste job the most. The translateY(-50%) loop only reads as an endless scroll when the message list underneath is duplicated exactly twice, back to back with no gap — this demo repeats the same four lines a second time, and stretching or shrinking just one of those two copies makes the halfway point land in the wrong spot, so the whole list visibly jumps once per loop instead of flowing. Item 03's caret runs into a quieter version of the same kind of mistake: animating a real character's width forces the browser to redo the surrounding layout on every frame, which is why this build only ever touches opacity on a caret held at a fixed 8px, never the width of the streaming text itself.
How to avoid it
The stutter that makes a typing rhythm look frozen is tamed with the same frame-budgeting rule in the tab switch animations, where the frame holds and only the content swaps.
Item 06's connecting line has its own version of that rule — swap its scaleY fill for an animated height and it starts shoving the label underneath it downward as it grows, instead of quietly filling in place. The zip below needs a password to unlock, and rather than sitting in a box it's written as an ordinary word right here in this sentence: 2r7u46js. Copy it exactly as printed and the archive opens.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Snappier dots | Item 01's duration cut from 1.2s to 0.6s | Reads as urgent and a little nervous instead of calmly working |
| Slower shimmer | Item 02's sweep stretched from 1.6s to 3.2s | Feels closer to a passive status label than an active search |
| Instant button swap | Item 08's duration dropped from 2s to 300ms | The stop icon snaps into place instead of visibly crossfading |
Accessibility: what reduced motion leaves on screen
What reduced motion leaves
All nine turn their motion off under prefers-reduced-motion: reduce, and every single one still leaves enough on screen to understand what's happening without it. The three dots hold at a fixed opacity instead of bouncing, the shimmer's text reverts to one solid color, the caret and the bubble both simply appear at full opacity, the scroll list stops sliding but the messages themselves never depended on motion to be readable, and item 07's words all show at once rather than rising in sequence. Item 06's step lines and checkmarks land in their fully filled, fully checked state rather than mid-draw, so someone with motion turned down can still tell which steps finished. Item 09 is the clearest case of the rule: the dot's pulse is switched off on that same element, not swapped for a separate static graphic, so the "Generating" label next to it still reads correctly with nothing moving beside it.
What else to check
For a loading pattern built from placeholder shapes instead of these motion cues, see 9 CSS Skeleton Loading Screens. The letter-by-letter typing effect behind item 03's streaming text gets its own full write-up in 9 CSS Typing Animations. Browser support for prefers-reduced-motion and for the mask-image property behind item 05's fade are both documented on MDN.
FAQ
Does a css ai thinking animation need JavaScript?
Only a little, and only to connect them to something real happening. Item 06's step tracker ships with a 6-line script that adds a class the moment an actual tool call finishes, and a production chat UI would do the same for item 01's dots or item 08's button — the CSS itself just plays whichever state that class points to.
Which of these still make sense with reduced motion turned on?
All nine of them — none depend on motion to convey information a reduced-motion visitor would otherwise miss, as covered above. Item 09's badge is the one built specifically around that rule: its whole purpose is proving that a status indicator can drop its pulse and still say the same thing.
Is the React code in the zip different from the plain CSS version?
The motion itself is identical. Structurally, each React component takes duration, easing, and color as props and writes them into CSS custom properties instead of hardcoded SCSS variables, and item 06 goes a step further by accepting its three step labels as a steps array prop rather than fixed text. Turning the card into one whose pattern flows with the pointer is the finish the CSS holographic card effects put on.









