GODRICH

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.

Auto-plays · click a tile to jump to its section · all nine in one zip

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).

01Thinking dots

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. The stagger is close enough to a person tapping out a sentence that a visitor reads it as "still working," not "stuck."

scale@keyframes1.2s
@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

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. 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.

background-positionbackground-clip: text1.6s
.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

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. 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.

opacitysteps()530ms
.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

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. That small overshoot is what makes an arriving answer feel like it landed with a bit of weight, rather than just blinking into existence.

scaleopacitycubic-bezier
@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

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. The fade itself is a static mask-image that never animates — the only thing actually moving is the message list underneath it, sliding up.

mask-imagetranslateYoverflow: hidden
.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

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. 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.

scaleYstroke-dashoffsetJS 6줄
.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

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. Every <span> shares one keyframe, and only a 90ms animation-delay step between each nth-child is what spreads them apart.

translateYanimation-delaynth-child
@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

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. Both icons sit stacked in the exact same grid cell, so the crossfade is nothing but opacity and scale swapping which one looks solid.

scaleopacity200ms
.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

A small status dot next to a "Generating" label normally pulses on its own, spreading out with box-shadow while it briefly scales up. 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.

box-shadowprefers-reduced-motionscale
@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?

Item 05's scroll fade is the one 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. 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

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.

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

Do these AI chat loading animations 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.

Enter the archive password

The password is inside this article. You will find it as you read.