AI Thinking Indicator: Typing Dots, Shimmer and Streaming Cursor in CSS
A chat interface has a gap in it. The user sends a message, and for a second or several, nothing visible happens. Then text starts to stream in. The indicators you put in that gap tell people the app is alive, and they're easy to get subtly wrong: they shift the layout, they spam screen readers, or they keep animating for people who asked for less motion.
This post covers three patterns that most AI chat UIs use: animated typing dots, a shimmer across a "Thinking…" label, and a blinking cursor at the end of streamed text. Each one has hand-written CSS and HTML you can paste in, then a React example that switches between them while a response streams, and short notes for Webflow, Framer and plain HTML.
The CSS below is a hand-written starter, written for this post. It is not code from any product, and it's meant to be edited.
The rules all three share
Before the patterns, a few things that apply to every indicator:
- Reserve the space. If the indicator appears and disappears, the content around it jumps. Give it a fixed width, in
chunits for text, so the line doesn't reflow. - Use
tabular-numsfor anything that counts. If you show "Thinking… 12s", digits in a proportional font change width as they tick.font-variant-numeric: tabular-numsgives every digit the same width. - Announce the state, not the animation. Put
role="status"andaria-live="polite"on the container, andaria-busy="true"while work is in progress. Hide purely decorative glyphs witharia-hidden="true"so a screen reader doesn't read out dots or block characters. - Honor
prefers-reduced-motion. Every pattern below has a still version for visitors who set that preference. - Inherit color. Using
currentColormeans the indicator works in light and dark mode without a second set of rules.
A small utility class for text that should be heard but not seen:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
Pattern 1: animated typing dots
The classic "someone is typing" indicator: three dots that pulse one after another. This version uses three <i> elements and a staggered animation-delay.
<div class="ai-typing" role="status" aria-live="polite" aria-busy="true">
<span class="ai-typing__dots" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="sr-only">Assistant is thinking</span>
</div>
/* Hand-written starter: typing dots */
.ai-typing {
display: inline-flex;
align-items: center;
height: 1.5em; /* fixed height: no shift when it appears */
padding: 0 0.75em;
border-radius: 999px;
background: #2a2a2a;
color: #b8b8b8;
}
.ai-typing__dots {
display: inline-flex;
gap: 0.3em;
width: 2.4em; /* fixed width: three dots plus gaps */
}
.ai-typing__dots i {
width: 0.5em;
height: 0.5em;
border-radius: 50%;
background: currentColor;
opacity: 0.3;
animation: ai-dot 1.2s ease-in-out infinite;
}
.ai-typing__dots i:nth-child(2) { animation-delay: 0.15s; }
.ai-typing__dots i:nth-child(3) { animation-delay: 0.3s; }
@keyframes ai-dot {
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-0.2em); }
}
@media (prefers-reduced-motion: reduce) {
.ai-typing__dots i {
animation: none;
opacity: 0.6;
}
}
What to notice:
- The animation only changes
opacityandtransform, which don't trigger layout. The dots move without moving anything around them. - The delays are a third of the duration apart, so the pulse travels left to right.
- With reduced motion on, the dots stay visible at a steady opacity. The indicator still says "something is happening" without moving.
- The visible dots are
aria-hidden. The hidden sentence is what gets announced.
Pattern 2: shimmer across a "Thinking…" label
A light sweep over real text reads as "working" without the cartoon feel of bouncing dots. The trick is a gradient bigger than the text, clipped to the glyphs, with background-position sliding across it.
<div class="ai-thinking" role="status" aria-live="polite" aria-busy="true">
<span class="ai-thinking__label">Thinking…</span>
</div>
/* Hand-written starter: shimmer label */
.ai-thinking__label {
display: inline-block;
min-width: 9ch; /* "Thinking…" is 9 characters */
font-variant-numeric: tabular-nums;
color: #8a8a8a; /* fallback if clipping isn't supported */
background-image: linear-gradient(
100deg,
#6b6b6b 0%,
#6b6b6b 40%,
#e8794a 50%, /* ember highlight; use #ffffff for neutral */
#6b6b6b 60%,
#6b6b6b 100%
);
background-size: 250% 100%;
background-position: 100% 0;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: ai-shimmer 2s linear infinite;
}
@keyframes ai-shimmer {
from { background-position: 100% 0; }
to { background-position: -100% 0; }
}
@media (prefers-reduced-motion: reduce) {
.ai-thinking__label {
animation: none;
background-image: none;
-webkit-text-fill-color: currentColor;
color: #8a8a8a;
}
}
What to notice:
- The label is real text, so a screen reader reads "Thinking…" with no
aria-hiddenand no extra hidden copy. -webkit-text-fill-color: transparentis what lets the gradient show through the letters. The reduced-motion block puts the fill back, so the label becomes plain grey text.- If you swap the label for something that counts, such as "Thinking… 12s", keep
tabular-numsand a fixedmin-widthso the line doesn't wobble each second. - A sweep animating
background-positionrepaints the text each frame. For one label this is fine. If you're showing dozens at once, use the dots instead.
Pattern 3: a blinking cursor at the end of streamed text
When tokens arrive one by one, a cursor at the end of the text shows where the next one will land. Put it in an inline element placed right after the text, so it follows the text as it wraps.
<p class="ai-stream" aria-busy="true">
The answer so far, growing as tokens arrive<span class="ai-cursor" aria-hidden="true"></span>
</p>
/* Hand-written starter: streaming cursor */
.ai-cursor {
display: inline-block;
width: 0.55ch;
height: 1.05em;
margin-left: 0.15ch;
vertical-align: text-bottom;
background: #e8794a; /* ember accent; currentColor also works */
animation: ai-blink 1s steps(1, end) infinite;
}
@keyframes ai-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ai-cursor {
animation: none;
opacity: 1;
}
}
What to notice:
- The cursor is a block drawn with
background, not a character in the text. That keeps it out of copied text and out of the accessibility tree, andaria-hidden="true"covers the rest. steps(1, end)makes the blink a hard on/off, like a terminal, rather than a fade.- Its width is
0.55chand it's always in the layout, even during the off half of the blink. Onlyopacitychanges, so the line never reflows as it blinks. - Reduced motion leaves a steady cursor, which still marks the insertion point.
- Don't put
aria-liveon the streaming paragraph itself. A live region that changes on every token can read out every fragment. Announce "Responding…" once when it starts, and the finished answer when it ends, as in the React example below.
React: switch the indicator while a response streams
This component shows the typing dots until the first token arrives, then the streamed text with the cursor, and removes the cursor when the stream ends. It uses the three CSS blocks above.
import { useState } from "react"
type Phase = "idle" | "thinking" | "streaming" | "done"
export function Chat() {
const [phase, setPhase] = useState<Phase>("idle")
const [text, setText] = useState("")
async function send(prompt: string) {
setText("")
setPhase("thinking")
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
})
if (!res.body) {
setPhase("idle")
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
setPhase("streaming") // first chunk: swap dots for text
setText((t) => t + decoder.decode(value, { stream: true }))
}
setPhase("done")
}
return (
<div>
<button type="button" onClick={() => send("Hello")}>
Send
</button>
<div role="status" aria-live="polite" aria-busy={phase === "thinking" || phase === "streaming"}>
{phase === "thinking" && (
<div className="ai-typing">
<span className="ai-typing__dots" aria-hidden="true">
<i />
<i />
<i />
</span>
<span className="sr-only">Assistant is thinking</span>
</div>
)}
{phase === "streaming" && <span className="sr-only">Assistant is responding</span>}
{phase === "done" && <span className="sr-only">Response complete</span>}
</div>
{text && (
<p className="ai-stream">
{text}
{phase === "streaming" && <span className="ai-cursor" aria-hidden="true" />}
</p>
)}
</div>
)
}
A few choices worth keeping:
- The status region stays mounted. Live regions announce changes to their contents. If you mount the whole region at the moment it speaks, some screen readers miss it. Keep the wrapper rendered and change what's inside.
- The streamed text is outside the status region. The region says "thinking", "responding" and "complete". The answer itself is read when the user navigates to it, not token by token.
aria-busyfollows the phase. It's true while a response is pending or streaming, and false when it ends.- The dots reserve their own height (from the CSS), so replacing them with text doesn't jump the page much. If your messages sit in a scrolling list, also keep the list scrolled to the bottom as the text grows.
If your stream is server-sent events or a library's streaming hook, only the reading loop changes. The phase logic stays the same.
Platform notes
- Plain HTML. Put the CSS in your stylesheet or a
<style>block and toggle a class orhiddenattribute from your script. The three snippets need no JavaScript on their own. - React and Next.js. Use the CSS from a global stylesheet or a CSS module. Render the indicators from state as above. If a component renders on the server, the indicator should only appear after the user acts, so there's nothing to mismatch on hydration.
- Webflow. Add the CSS in an Embed element or under Site settings → Custom code, then give a Div Block the classes. Webflow's interactions can show and hide the indicator, but streaming text itself needs your own script in an Embed, since Designer interactions don't know when tokens arrive. Custom code runs on the published site, not in the Designer.
- Framer. Paste the CSS under Site Settings → Custom Code, or inside a Code Component. For anything driven by a streaming response, a code component is the natural home, because it has state. The CSS patterns work inside one unchanged.
Doing it as text instead of CSS
The three patterns above use shapes and gradients. Another route is characters: a braille dot spinning in a cell, a shimmering label, a cursor glyph. Text loaders sit inline with your type, scale with font-size and take the surrounding text color. The costs are the same ones this post covered: fixed-width frames to avoid shift, hidden glyphs for screen readers, and a still frame for reduced motion. Text loaders also rely on the visitor's fonts having the characters, so test on your target systems. The post on unicode loading spinners for React, Framer and any website goes through that approach.
To preview some, try the free unicode spinner generator. It lets you pick a spinner and copy the code without installing anything.
Unicode Spinners Pro
If you'd rather not hand-roll and maintain all of this, Unicode Spinners Pro is a finished set of text loaders. It includes:
- 40 spinners across braille, block, box-drawing and arrow styles.
- AI thinking and streaming loaders: a "Thinking…" label with a light sweeping across it, and a cursor for the end of streamed text.
- Step rows that turn from a spinner into ✓ or ✗ when each step finishes, for agent-style interfaces.
- Progress bars that fill a fraction of a character at a time, with percent and estimated time left, or an indeterminate band.
- Script tag, React and Framer builds, so the same loaders work on a plain site, in a React app and in a Framer project.
- Accessible, no layout shift: screen readers hear "Thinking…" and then the result, not the frames.
It's $9 one-time under a single-project license, meaning one website or project per license. The Unicode Spinners Pro page has a live gallery of every spinner and a playground, so you can see them running before you decide.
If the CSS in this post covers your chat UI, use it. It's lighter than any dependency. Pro is for when you want the larger set, step rows and progress bars without writing and testing each one yourself.
Pricing
Unicode Spinners Pro is $9 one-time for one website or project. It's also part of the All-Access Pass, which bundles the Parallel Studio web components. The free unicode spinner generator costs nothing.
Start with the dots or the cursor, give the container a status role, check it with Reduce Motion on, and listen to it once with a screen reader.