GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

Thinking dots
Open in a new tab

01 · Thinking dots — Measured area 0.225% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

scale@keyframes1.2s

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.

Text shimmer status
Open in a new tab

02 · Text shimmer status — Measured area 0.287% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

background-positionbackground-clip: text1.6s

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.

Streaming caret blink
Open in a new tab

03 · Streaming caret blink — Measured area 0.272% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

opacitysteps()530ms

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.

Message bubble pop-in
Open in a new tab

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

scaleopacitycubic-bezier

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.

Scroll edge fade
Open in a new tab

05 · Scroll edge fade — Measured area 27.117% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

mask-imagetranslateYoverflow: hidden

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.

Tool-call step progress
Open in a new tab

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

scaleYstroke-dashoffsetJS 6줄

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.

Word-by-word fade-in
Open in a new tab

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

translateYanimation-delaynth-child

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.

Send-to-stop button morph
Open in a new tab

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

scaleopacity200ms

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.

Reduced-motion static badge
Open in a new tab

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

box-shadowprefers-reduced-motionscale

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.

Enter the archive password

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