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

9 Typing Indicator CSS Patterns (Chat UI)

One of nine typing indicators slows its avatar pulse as typing runs longer, one redraws a group name tag from remaining time, another recomputes voice bars

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

None of these nine screens is wired to a real messenger service — every sender name, avatar, and identity string on screen is fictional, and there is no live WebSocket connection or server call anywhere; typing, read, and muted states are simulated purely with timers, with a single comment marking exactly where a real real-time event would go in production. Items 01, 02, 03, 04, 05, 06, 07, and 09 run on timers or requestAnimationFrame cycling on a fixed period with zero click, drag, or pointer listeners attached; only 08 (mute toggle) carries a real click listener, replaying the same interpolation from the state at the moment it fires. Every item's data — intervals, periods, thresholds, and the amplitude array — is a fixed value, so the same scene reproduces on every load.

Anatomy of a typing indicator — what nine screens compute

Across the nine screens, the same handful of arithmetic moves keeps resurfacing under a different name — an index becomes a delay, a duration becomes a period, a count becomes a width — which is exactly what a typing indicator animation css pattern needs underneath whatever it looks like on screen.

Why three screens turn elapsed time into a delay, a period, or a height

Item 01 multiplies each dot's order index by a fixed interval to recompute its animation-delay, item 04 multiplies the elapsed tick count by a slowdown constant to recompute the avatar ring's beat period every 150ms, and item 09 runs an elapsed cycle position through an ease-in-out curve to recompute a virtual keyboard's height in pixels — three different outputs from the same habit of turning "how much time has passed" into a number the screen can use.

Why three screens interpolate or measure a width instead of switching instantly

Item 02 computes three dots' combined width and one bubble's width and interpolates between them by elapsed ratio, item 03 measures the "+N" label's real width per count via getBoundingClientRect and interpolates as the concurrent-typer count changes, and item 06 recomputes the ratio of a group chat name's remaining display time every frame and reflects it directly in a label's scaleX. None of the three swaps state with display: none — they all resize toward a target instead.

Why three screens map a threshold, an array index, or a click state onto what's visible

Item 05 measures the time gap between an indicator vanishing and a message arriving and compares it against a 400ms threshold to decide whether to merge two rows into one, item 07 reads a preset array of amplitude values in order on a fixed tick to recompute each waveform bar's height, and item 08 interpolates a card's opacity toward a target value based on which state a click just set. Item 05's fade itself runs on a CSS transition — its two setTimeout calls only flip the endpoint values — while items 04 and 07 update purely on setInterval, with no requestAnimationFrame in either file.

Code per screen

Every one of these nine keeps its typing indicator css and script inline in one file with no external stylesheet or class library to load first — the block below is the same css typing dots animation logic pasted straight out of that item's own index.html, not retyped from memory.

01Three-dot bounce — a basic indicator staggered by index

Once the typing state begins, each dot's bounce start time is recomputed by multiplying its order index by a fixed interval constant, so the three dots ripple in sequence rather than bouncing together. It suits the bubble at the bottom of a 1:1 chat, a customer-support widget.

Three-dot bounce — a basic indicator staggered by index
Open in a new tab

01 · Three-dot bounce — a basic indicator staggered by index — Measured area 0.225% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

animation-delaysetPropertyaria-live

What the numbers say

Cumulative area is 0.23% across 15 of 23 frame deltas at an average intensity of 60.4 — three small dots naturally occupy few pixels, so the smallest cumulative area of the nine reflects the subject, not a shortfall.

var DOT_STAGGER_MS = 120;
var dots = document.querySelectorAll('.dot');
if (!dots.length) return;
dots.forEach(function (dot) {
  var i = Number(dot.dataset.i || 0);
  dot.style.setProperty('--delay', (i * DOT_STAGGER_MS) + 'ms');
});

02Bubble morph — three dots merging into one bubble's size

When the typing state ends, the combined width of the three dots interpolates toward a single bubble's width with ease-out cubic easing, and the message content takes over exactly when the interpolation finishes. It suits a messenger app's live conversation screen, human-to-human chat (not a bot).

Bubble morph — three dots merging into one bubble's size
Open in a new tab

02 · Bubble morph — three dots merging into one bubble's size — Measured area 4.594% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

setPropertyrequestAnimationFrameprefers-reduced-motion

What the numbers say

Cumulative area is 4.59% across 13 of 23 frame deltas, the highest average intensity of the nine at 191.1 — the width interpolation moves a meaningful block of pixels on every tick it's active rather than a thin outline.

var DOT_W = 56;
var MSG_W = 168;
var CYCLE_MS = 2000;
var MORPH_START = 0.10 * CYCLE_MS;
var MORPH_MS = 640;
var REVERT_AT = 0.62 * CYCLE_MS;

function easeOut(p) { return 1 - Math.pow(1 - p, 3); }

function apply(t) {
  var inMorph = t >= MORPH_START && t < REVERT_AT;
  var p = Math.min(Math.max((t - MORPH_START) / MORPH_MS, 0), 1);
  var eased = easeOut(p);
  var w = inMorph ? DOT_W + (MSG_W - DOT_W) * eased : DOT_W;
  bubble.style.setProperty('--w', w.toFixed(1));
  var done = inMorph && p >= 1;
  dots.style.opacity = done ? '0' : '1';
  msg.style.opacity = done ? '1' : '0';
}

03Multi-user overlap — a row computing name-tag width from concurrent typers

When two or more people are typing at once, the row keeps one name and counts the rest instead of listing every person, recomputing the "+N" badge's width from a real measured value each time the count changes. It suits the top of a group chat room, co-editor indicators in collaborative docs.

Multi-user overlap — a row computing name-tag width from concurrent typers
Open in a new tab

03 · Multi-user overlap — a row computing name-tag width from concurrent typers — Measured area 0.337% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRectsetPropertyrequestAnimationFrame

What the numbers say

Cumulative area is 0.34% across 6 of 23 frame deltas at an average intensity of 105.7 — the fewest moved frames of the nine, since the badge only redraws on the three count changes in its cycle rather than every tick.

var COUNTS = [2, 3, 4];
var HOLD_MS = 620;
var MORPH_MS = 300;

function widthFor(n) {
  measure.textContent = '+' + n;
  return measure.getBoundingClientRect().width;
}

var widths = COUNTS.map(widthFor);
var CYCLE_MS = HOLD_MS * COUNTS.length;

function apply(t) {
  var phase = Math.floor(t / HOLD_MS) % COUNTS.length;
  var next = (phase + 1) % COUNTS.length;
  var phaseT = t % HOLD_MS;
  var p = Math.min(phaseT / MORPH_MS, 1);
  var w = widths[phase] + (widths[next] - widths[phase]) * p;
  badge.style.setProperty('--bw', w.toFixed(1));
  badge.textContent = '+' + COUNTS[phase];
}

04Avatar pulse sync — a ring recomputing its beat from typing duration

The longer typing continues, the avatar ring's beat period slows in proportion to elapsed duration, and once typing stops the period eases back to its original value on the next cycle. It suits a video-call chat sidebar, participant lists in real-time collaboration tools.

Avatar pulse sync — a ring recomputing its beat from typing duration
Open in a new tab

04 · Avatar pulse sync — a ring recomputing its beat from typing duration — Measured area 2.16% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

setPropertyclassList.togglesetInterval

What the numbers say

Cumulative area is 2.16% across 19 of 23 frame deltas, the lowest average intensity of the nine at 55.9 — the ring's pulse touches a thin outline rather than a filled area, so a low intensity per moved frame is structural.

var BASE_PERIOD_MS = 900;
var PULSE_SLOWDOWN_MS_PER_SEC = 40;
var TICK_MS = 150;
var MAX_TICKS = 8;

var tick = 0;
ring.style.setProperty('--period', BASE_PERIOD_MS + 'ms');

function step() {
  tick = (tick + 1) % (MAX_TICKS + 1);
  var typing = tick > 0;
  var period = BASE_PERIOD_MS + tick * PULSE_SLOWDOWN_MS_PER_SEC;
  ring.style.setProperty('--period', period + 'ms');
  ring.classList.toggle('is-typing', typing);
  status.textContent = typing ? 'Typing' : 'Idle';
}

05Read-receipt merge — a label computing its merge moment from a time gap

The gap between when the typing indicator vanishes and the message arrives is measured in milliseconds, and once that gap lands under a set threshold the indicator and the read-receipt label merge into one slot instead of showing separately. It suits the bottom of a 1:1 chat's message list, a conversation with notification badges.

Read-receipt merge — a label computing its merge moment from a time gap
Open in a new tab

05 · Read-receipt merge — a label computing its merge moment from a time gap — Measured area 3.026% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

setTimeoutclassList.togglearia-live

What the numbers say

Cumulative area is 3.03% across 9 of 23 frame deltas at an average intensity of 78.3 — the fixed 180ms arrival delay always lands under the 400ms merge threshold here, so this demo's cycle always resolves as merged.

var MERGE_GAP_MS = 400;
var CYCLE_MS = 2000;
var VANISH_AT = 900;
var ARRIVE_DELAY_MS = 180;
var timer1 = null, timer2 = null;

function runCycle() {
  dots.style.opacity = '1';
  msgRow.style.opacity = '0';
  receipt.classList.remove('is-merged');
  timer1 = setTimeout(function () {
    dots.style.opacity = '0';
    var vanishAt = Date.now();
    timer2 = setTimeout(function () {
      var arrivedAt = Date.now();
      var gap = arrivedAt - vanishAt;
      var merged = gap < MERGE_GAP_MS;
      msgRow.style.opacity = '1';
      receipt.classList.toggle('is-merged', merged);
      live.textContent = merged ? 'Merged indicator' : 'Separate indicator';
    }, ARRIVE_DELAY_MS);
  }, VANISH_AT);
}

06Group chat cycle — a label redrawing width from remaining time as typers change

When the typer in a group chat changes, the remaining display time for the previous name counts down every frame, the label width shrinks by that same ratio via scaleX, and the next name takes over once the ratio reaches zero. It suits community group chat rooms, support screens with several agents.

Group chat cycle — a label redrawing width from remaining time as typers change
Open in a new tab

06 · Group chat cycle — a label redrawing width from remaining time as typers change — Measured area 0.329% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrametransform-originscaleX(

What the numbers say

Cumulative area is 0.33% across all 23 of 23 frame deltas at an average intensity of 156.8 — the label redraws on literally every sampled frame since the countdown never pauses between the three names.

var NAMES = ['A', 'B', 'C'];
var HOLD_MS = 640;
var CYCLE_MS = HOLD_MS * NAMES.length;

function apply(t) {
  var idx = Math.floor(t / HOLD_MS) % NAMES.length;
  var phaseT = t % HOLD_MS;
  var remain = 1 - phaseT / HOLD_MS;
  who.textContent = NAMES[idx];
  fill.style.transform = 'scaleX(' + remain.toFixed(3) + ')';
}

07Voice-input waveform — bars redrawn from an amplitude array

While a voice message is being recorded, a preset array of 16 amplitude values is read in order on a fixed 80ms tick to recompute each bar's height via scaleY, looping back to the first value once the array ends. It suits a voice-message recording screen, a podcast app's live display.

Voice-input waveform — bars redrawn from an amplitude array
Open in a new tab

07 · Voice-input waveform — bars redrawn from an amplitude array — Measured area 1.029% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

setIntervaltransform-originscaleY(

What the numbers say

Cumulative area is 1.03% across all 23 of 23 frame deltas, the second-highest average intensity of the nine at 187.9 — every bar redraws on every tick, so the deltas are both constant and strong.

var AMPLITUDE_TICK_MS = 80;
var AMPLITUDES = [.25, .55, .82, .38, .68, .92, .30, .60, .78, .42, .58, .88, .34, .64, .48, .70];

var idx = 0;
function tick() {
  bars.forEach(function (bar, i) {
    var v = AMPLITUDES[(idx + i) % AMPLITUDES.length];
    bar.style.transform = 'scaleY(' + v.toFixed(2) + ')';
  });
  idx = (idx + 1) % AMPLITUDES.length;
}

08Mute toggle — a switch interpolating opacity by its state value

Once the indicator is switched off, the card's opacity interpolates down toward a set off-value and fades, and switching back on retraces the same curve in reverse. It suits a chat app with a notification-minimizing setting, a do-not-disturb screen.

Mute toggle — a switch interpolating opacity by its state value
Open in a new tab

08 · Mute toggle — a switch interpolating opacity by its state value — Measured area 6.372% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

addEventListeneraria-pressedopacity

What the numbers say

Cumulative area is 6.37% across 11 of 23 frame deltas at an average intensity of 80.5 — of the nine, only this one attaches a real click listener, and the click fires the exact same opacity interpolation the auto-cycle otherwise drives on a timer.

var OFF_OPACITY = 0.3;
var AUTO_CYCLE_MS = 700;
var muted = false;

function apply() {
  card.style.opacity = muted ? String(OFF_OPACITY) : '1';
  badge.hidden = !muted;
  btn.setAttribute('aria-pressed', String(muted));
  live.textContent = muted ? 'Indicator off' : 'Indicator on';
}

btn.addEventListener('click', function () {
  muted = !muted;
  apply();
});

09Keyboard-bar hint — an indicator repositioned by a virtual keyboard height value

On a mobile screen, once the virtual keyboard is open, the indicator's bottom position is recomputed every frame from the keyboard's height value via translateY, so it stays pinned right above the keyboard bar as it opens and closes. It suits mobile chat apps, responsive web chat widgets.

Keyboard-bar hint — an indicator repositioned by a virtual keyboard height value
Open in a new tab

09 · Keyboard-bar hint — an indicator repositioned by a virtual keyboard height value — Measured area 11.546% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateY(requestAnimationFrametransform

What the numbers say

Cumulative area is 11.55% across 12 of 23 frame deltas at an average intensity of 106.9, the widest cumulative area of the nine — a full keyboard-height panel sliding into place covers far more pixels than a dot, a badge, or a bar ever could.

var KB_H = 64;
var CYCLE_MS = 2000;
var OPEN_START = 0.10 * CYCLE_MS;
var OPEN_MS = 360;
var CLOSE_START = 0.62 * CYCLE_MS;
var CLOSE_MS = 360;

function ease(p) {
  return p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
}

function heightAt(t) {
  if (t < OPEN_START) return 0;
  if (t < OPEN_START + OPEN_MS) {
    return KB_H * ease((t - OPEN_START) / OPEN_MS);
  }
  if (t < CLOSE_START) return KB_H;
  if (t < CLOSE_START + CLOSE_MS) {
    return KB_H * (1 - ease((t - CLOSE_START) / CLOSE_MS));
  }
  return 0;
}

Where values reshape the screen — elapsed time, participant count, amplitude

Why the avatar pulse needs elapsed duration multiplied by a constant instead of a fixed period

Item 04's period is BASE_PERIOD_MS + tick * PULSE_SLOWDOWN_MS_PER_SEC, recomputed on every 150ms tick up to MAX_TICKS (8), so the ring visibly slows the longer typing runs instead of pulsing at one unchanging rate that carries no sense of duration at all.

Why the multi-user badge measures rendered width instead of hard-coding one per count

Item 03's widthFor writes each candidate label into a hidden measure element and reads back getBoundingClientRect().width, because a hard-coded width per count would drift out of sync the moment the font, locale, or digit count changes underneath it.

Why the waveform reads a preset amplitude array instead of generating tones live

Item 07 has no audio API call anywhere in its script — AMPLITUDES is a fixed array of 16 values read by index on a plain setInterval, which is exactly what keeps the same waveform shape reproducing on every page load instead of drawing a new one each time.

Where it breaks — overlapping multi-user state, reconnects, low-end devices

What decides whether the read-receipt label merges or stays separate

Item 05 only merges the indicator and the read-receipt label when gap < MERGE_GAP_MS (400ms) — with ARRIVE_DELAY_MS fixed at 180ms in this build, the demo's own cycle always lands under that threshold, but the same comparison would just as validly resolve to "separate" the moment a slower arrival pushed the gap past 400ms.

What reduced motion leaves on screen for the eight items with no click listener

Every one of the nine checks prefers-reduced-motion: reduce — in CSS for 01 and 05, in a matchMedia guard before the loop starts for the rest — and stops only the repeating cycle; the computed value at that instant, whether it's a delay, an interpolated width, a merge decision, or a keyboard height, still paints once rather than disappearing.

Where this set differs from nine AI chat bubbles that loop a thinking animation while a bot drafts its reply is the actor: those nine represent a bot's wait state, while this set of nine represents a human typing to another human, with no bot or automated reply anywhere in scope. The same gap-versus-threshold habit in item 05 also turns up, differently applied, in a file-upload bar that recomputes its own fill width from bytes transferred so far, where the input driving the recomputation is a byte count instead of a time gap between two timestamps — the full nine-screen implementation behind this set, both vanilla and React, sits behind yb2bq6qt in this post's zip.

Three variants and accessibility

Variant What changes What it trades off
Dot-delay interval Item 01's DOT_STAGGER_MS from 120ms to 260ms At 120ms the three dots bounce almost together, so the wave reads as bunched up; at 260ms the gap between dots widens into a more leisurely ripple, but the whole cycle stretches out and can feel sluggish
Avatar pulse slowdown coefficient Item 04's PULSE_SLOWDOWN_MS_PER_SEC from 40ms to 120ms At 40ms the beat barely slows even after long typing, so duration doesn't land; at 120ms the beat visibly stretches within a few seconds, clearly signaling "still typing"
Amplitude update interval Item 07's AMPLITUDE_TICK_MS from 80ms to 220ms At 80ms the bars flow smoothly like a real waveform at the cost of more frequent computation; at 220ms the bars move in visible steps, suiting a low-end-device impression but reading less like a waveform

This zip ships the 120ms, 40ms, and 80ms versions.

What reduced motion and screen readers both get from the same computed values

Beyond the matchMedia guard, state text like "Typing" or "Idle" is announced through role="status" and aria-live="polite" on the relevant container, and item 08's toggle keeps its aria-pressed attribute in sync with the muted state on every click — the same values driving the animation also drive what gets announced.

FAQ

Do any of these nine screens connect to a real messenger service?

No — none of the nine makes a real WebSocket connection, a server call, or uses a real key anywhere; every sender name is fictional, and a single comment in each file marks exactly where a real real-time event would be wired in production, whether that's for a typing indicator in messenger apps or any other real-time chat surface.

Which of the nine respond to an actual click, rather than just autoplaying?

Only 08 (mute toggle) carries a real click listener; 01, 02, 03, 04, 05, 06, 07, and 09 run purely on timers or requestAnimationFrame cycling on a fixed period with zero click, drag, or pointer listeners attached, so their states cycle on their own the moment the page loads.

Is the amplitude pattern in the voice waveform read from a real microphone?

No — AMPLITUDES in item 07 is a fixed array of 16 preset values read by index on a plain interval; there is no getUserMedia call or audio analyser anywhere in the file, so the same 16-value pattern repeats identically on every playback rather than reacting to real sound, the same as a css typing dots animation reproduces the same delay pattern on every load.

Sources: MDN — animation-delay · MDN — CSS custom properties · MDN — aria-live · MDN — prefers-reduced-motion

Enter the archive password

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