20 CSS Loading Animations05 / 20

Light JSMIT licensed

AI Chat Thinking Dots Loading Animation

The most-copied loader of the AI era, done properly. Three dots bounce inside a real assistant bubble using a linear() easing curve — a genuine spring, not a lopsided ease-in-out — while the status line cycles through the phases an agent actually goes through (reading files, searching, drafting). A gradient shimmer sweeps the label text, and the done state swaps the dots for a streamed-answer skeleton.

Published

Live Demo
Try it

The code

<div class="la-05" data-state="loading">
  <div class="la-05__chrome">
    <button class="la-05__btn la-05__theme" type="button" aria-pressed="false" aria-label="Switch to dark theme">
      <svg class="la-05__moon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4 8.4 8.4 0 1 0 20 14.2"/></svg>
      <svg class="la-05__sun" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 3.4v2M12 18.6v2M3.4 12h2M18.6 12h2M6 6l1.4 1.4M16.6 16.6 18 18M18 6l-1.4 1.4M7.4 16.6 6 18"/></svg>
    </button>
    <button class="la-05__btn la-05__done" type="button" aria-pressed="false" aria-label="Show completed state">
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.6 4.4 4.4L19 7.4"/></svg>
    </button>
  </div>

  <div class="la-05__thread">
    <div class="la-05__msg la-05__msg--user">
      <p>Summarise the Q3 revenue deck and flag anything that contradicts the board memo.</p>
    </div>

    <div class="la-05__row">
      <span class="la-05__avatar" aria-hidden="true">
        <svg viewBox="0 0 24 24" focusable="false"><path d="M12 4.5 13.8 9l4.7 1.7-4.7 1.7L12 17l-1.8-4.6L5.5 10.7 10.2 9z"/></svg>
      </span>
      <div class="la-05__bubble" role="status" aria-live="polite" aria-label="Assistant is generating a response">
        <span class="la-05__tail" aria-hidden="true"></span>
        <span class="la-05__dots" aria-hidden="true"><i class="la-05__dot"></i><i class="la-05__dot"></i><i class="la-05__dot"></i></span>
        <p class="la-05__phase" id="la-05-phase">AI is thinking…</p>
        <div class="la-05__answer" aria-hidden="true"><i></i><i></i><i></i></div>
      </div>
    </div>
  </div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');

.la-05 {
  --bg: #f6f7fb;
  --surface: #ffffff;
  --bubble: #ffffff;
  --ink: #0c1017;
  --muted: #7b8494;
  --line: #e5e8f0;
  --acc: #6d4aff;
  --user: #101623;
  --ring: #6d4aff;
  --radius: 1.15rem;
  --shadow: rgba(12,16,23,.14);
  --sans: "Geist",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
  --mono: "Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
  position: relative;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.25rem,5vw,4rem);
  background: radial-gradient(90% 60% at 50% 0%, color-mix(in oklab,var(--acc) 15%,transparent), transparent 60%), var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

.la-05,
.la-05 *,
.la-05 *::before,
.la-05 *::after {
  box-sizing: border-box;
}

@supports (color: oklch(0 0 0)) {
  .la-05 {
    --acc: oklch(58% .23 288);
  }
}

.la-05__thread {
  inline-size: min(27rem,100%);
  display: grid;
  gap: .85rem;
}

.la-05__msg {
  justify-self: end;
  max-inline-size: 80%;
  padding: .8rem 1rem;
  border-radius: var(--radius) var(--radius) .35rem var(--radius);
  background: var(--user);
  color: #fff;
}

.la-05__msg p {
  margin: 0;
  font-size: .92rem;
  line-height: 1.45;
  text-wrap: pretty;
}

.la-05__row {
  display: flex;
  gap: .65rem;
  align-items: flex-end;
}

.la-05__avatar {
  flex: none;
  inline-size: 2rem;
  block-size: 2rem;
  border-radius: 999px;
  display: grid;
  place-items: center;
  background: linear-gradient(140deg, color-mix(in oklab,var(--acc) 85%,white), var(--acc));
  color: #fff;
  box-shadow: 0 8px 16px -10px var(--acc);
}

.la-05__avatar svg {
  inline-size: 1.05rem;
  block-size: 1.05rem;
  fill: currentColor;
}

.la-05__bubble {
  position: relative;
  padding: .9rem 1.1rem;
  border-radius: var(--radius) var(--radius) var(--radius) .35rem;
  background: var(--bubble);
  border: 1px solid var(--line);
  display: grid;
  gap: .55rem;
  justify-items: start;
  box-shadow: 0 20px 40px -28px var(--shadow);
  inline-size: max-content;
  min-inline-size: 11.5rem;
  max-inline-size: min(100%,18rem);
}

.la-05__bubble::after {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(115deg, transparent 35%, color-mix(in oklab,var(--acc) 22%,transparent) 50%, transparent 65%) 0 0/250% 100%;
  animation: la-05-sheen 3.4s linear infinite;
  opacity: .7;
}

@keyframes la-05-sheen {
  to {
    background-position: -250% 0;
  }
}

.la-05__tail {
  position: absolute;
  inset-block-end: -1px;
  inset-inline-start: -.3rem;
  inline-size: .7rem;
  block-size: .7rem;
  background: var(--bubble);
  border-inline-start: 1px solid var(--line);
  border-block-end: 1px solid var(--line);
  border-end-start-radius: .2rem;
  rotate: 45deg;
}

.la-05__dots {
  display: flex;
  gap: .34rem;
  padding-block: .15rem;
}

.la-05__dot {
  inline-size: .5rem;
  block-size: .5rem;
  border-radius: 999px;
  background: var(--acc);
  opacity: .55;
  animation: la-05-bounce 1.25s cubic-bezier(.3,0,.2,1) infinite;
}

.la-05__dot:nth-child(2) {
  animation-delay: .16s;
}

.la-05__dot:nth-child(3) {
  animation-delay: .32s;
}

@keyframes la-05-bounce {
  0%,
    60%,
    100% {
    translate: 0 0;
    opacity: .5;
  }

  30% {
    translate: 0 -.5rem;
    opacity: 1;
  }
}

@supports (animation-timing-function: linear(0,1)) {
  .la-05__dot {
    animation-timing-function: linear(0, .18 12%, .62 26%, 1 42%, .82 58%, 1 74%, .95 88%, 1);
  }
}

.la-05__phase {
  margin: 0;
  font-size: .86rem;
  font-weight: 500;
  letter-spacing: -.01em;
  background: linear-gradient(90deg, var(--muted) 28%, var(--ink) 48%, var(--muted) 68%) 0 0/220% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: la-05-shimmer 2.8s linear infinite;
}

@keyframes la-05-shimmer {
  to {
    background-position: -220% 0;
  }
}

@supports (transition-behavior: allow-discrete) {
  @starting-style {
    .la-05__phase {
      opacity: 0;
    }
  }

  .la-05__phase {
    transition: opacity .3s ease;
  }
}

.la-05__answer {
  display: none;
  gap: .4rem;
  inline-size: 13rem;
}

.la-05__answer i {
  display: block;
  block-size: .5rem;
  border-radius: 999px;
  background: color-mix(in oklab,var(--ink) 12%,transparent);
}

.la-05__answer i:nth-child(2) {
  inline-size: 88%;
}

.la-05__answer i:nth-child(3) {
  inline-size: 62%;
}

.la-05.is-ready .la-05__dots {
  display: none;
}

.la-05.is-ready .la-05__bubble::after {
  animation: none;
  opacity: 0;
}

.la-05.is-ready .la-05__phase {
  animation: none;
  background: none;
  color: var(--ink);
  -webkit-text-fill-color: currentColor;
}

.la-05.is-ready .la-05__answer {
  display: grid;
}

.la-05:has(.la-05__answer:not([hidden])) .la-05__bubble {
  min-inline-size: 14rem;
}

.la-05__chrome {
  position: absolute;
  inset-block-start: clamp(.75rem,2.5vw,1.5rem);
  inset-inline-end: clamp(.75rem,2.5vw,1.5rem);
  z-index: 20;
  display: flex;
  gap: .5rem;
}

.la-05__btn {
  inline-size: 2.75rem;
  block-size: 2.75rem;
  display: grid;
  place-items: center;
  cursor: pointer;
  padding: 0;
  border-radius: 999px;
  border: 1px solid color-mix(in oklab,var(--ink) 16%,transparent);
  color: var(--ink);
  background: color-mix(in oklab,var(--surface) 72%,transparent);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  transition: background .25s ease, transform .25s cubic-bezier(.16,1,.3,1);
}

.la-05__btn:hover {
  background: var(--surface);
  transform: translateY(-1px);
}

.la-05__btn:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 3px;
}

.la-05__btn svg {
  grid-area: 1/1;
  inline-size: 1.15rem;
  block-size: 1.15rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.la-05__sun {
  display: none;
}

.la-05__theme[aria-pressed="true"] .la-05__sun {
  display: block;
}

.la-05__theme[aria-pressed="true"] .la-05__moon {
  display: none;
}

.la-05__done[aria-pressed="true"] {
  background: var(--acc);
  border-color: transparent;
  color: #fff;
}

@media (prefers-color-scheme: dark) {
  .la-05:not([data-theme="light"]) {
    --bg: #080a10;
    --surface: #12151e;
    --bubble: #151a25;
    --ink: #eef1f8;
    --muted: #8d97a8;
    --line: #242c3a;
    --acc: #9d86ff;
    --user: #232c3d;
    --ring: #9d86ff;
    --shadow: rgba(0,0,0,.7);
  }
}

.la-05[data-theme="dark"] {
  --bg: #080a10;
  --surface: #12151e;
  --bubble: #151a25;
  --ink: #eef1f8;
  --muted: #8d97a8;
  --line: #242c3a;
  --acc: #9d86ff;
  --user: #232c3d;
  --ring: #9d86ff;
  --shadow: rgba(0,0,0,.7);
}

@media (prefers-reduced-motion: reduce) {
  .la-05 * {
    animation: none !important;
    transition: none !important;
  }

  .la-05__dot {
    opacity: 1;
  }

  .la-05__dot:nth-child(2) {
    opacity: .6;
  }

  .la-05__dot:nth-child(3) {
    opacity: .35;
  }

  .la-05__phase {
    background: none;
    color: var(--ink);
    -webkit-text-fill-color: currentColor;
  }
}

@media (forced-colors: active) {
  .la-05__bubble,
    .la-05__msg {
    border: 1px solid CanvasText;
  }

  .la-05__dot {
    background: Highlight;
  }

  .la-05__btn {
    border: 1px solid ButtonText;
    background: ButtonFace;
    color: ButtonText;
  }
}
(() => {
  const root = document.querySelector('.la-05');
  if (!root) return;
  const theme = root.querySelector('.la-05__theme');
  const done = root.querySelector('.la-05__done');
  const phase = root.querySelector('#la-05-phase');
  const PHASES = ['AI is thinking…', 'Reading Q3-revenue.pdf', 'Cross-checking board memo', 'Drafting the summary'];
  let i = 0;
  const timer = setInterval(() => { if (!root.classList.contains('is-ready')) phase.textContent = PHASES[++i % PHASES.length]; }, 1900);
  const dark = () => (root.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) === 'dark';
  const sync = () => {
    theme.setAttribute('aria-pressed', String(dark()));
    theme.setAttribute('aria-label', dark() ? 'Switch to light theme' : 'Switch to dark theme');
  };
  theme.addEventListener('click', () => { root.dataset.theme = dark() ? 'light' : 'dark'; sync(); });
  matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (!root.dataset.theme) sync(); });
  done.addEventListener('click', () => {
    const ready = root.classList.toggle('is-ready');
    root.dataset.state = ready ? 'ready' : 'loading';
    done.setAttribute('aria-pressed', String(ready));
    done.setAttribute('aria-label', ready ? 'Return to loading state' : 'Show completed state');
    phase.textContent = ready ? 'Answer ready — 3 sources checked' : PHASES[0];
    if (ready) clearInterval(timer);
  });
  sync();
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Loading Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: AI Chat Thinking Dots Loading Animation
Source: https://codefronts.com/motion/css-loading-animations/ai-chat-thinking-dots-loading-animation/

The most-copied loader of the AI era, done properly. Three dots bounce inside a real assistant bubble using a linear() easing curve — a genuine spring, not a lopsided ease-in-out — while the status line cycles through the phases an agent actually goes through (reading files, searching, drafting). A gradient shimmer sweeps the label text, and the done state swaps the dots for a streamed-answer skeleton.
## HTML
```html
<div class="la-05" data-state="loading">
  <div class="la-05__chrome">
    <button class="la-05__btn la-05__theme" type="button" aria-pressed="false" aria-label="Switch to dark theme">
      <svg class="la-05__moon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4 8.4 8.4 0 1 0 20 14.2"/></svg>
      <svg class="la-05__sun" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 3.4v2M12 18.6v2M3.4 12h2M18.6 12h2M6 6l1.4 1.4M16.6 16.6 18 18M18 6l-1.4 1.4M7.4 16.6 6 18"/></svg>
    </button>
    <button class="la-05__btn la-05__done" type="button" aria-pressed="false" aria-label="Show completed state">
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.6 4.4 4.4L19 7.4"/></svg>
    </button>
  </div>

  <div class="la-05__thread">
    <div class="la-05__msg la-05__msg--user">
      <p>Summarise the Q3 revenue deck and flag anything that contradicts the board memo.</p>
    </div>

    <div class="la-05__row">
      <span class="la-05__avatar" aria-hidden="true">
        <svg viewBox="0 0 24 24" focusable="false"><path d="M12 4.5 13.8 9l4.7 1.7-4.7 1.7L12 17l-1.8-4.6L5.5 10.7 10.2 9z"/></svg>
      </span>
      <div class="la-05__bubble" role="status" aria-live="polite" aria-label="Assistant is generating a response">
        <span class="la-05__tail" aria-hidden="true"></span>
        <span class="la-05__dots" aria-hidden="true"><i class="la-05__dot"></i><i class="la-05__dot"></i><i class="la-05__dot"></i></span>
        <p class="la-05__phase" id="la-05-phase">AI is thinking…</p>
        <div class="la-05__answer" aria-hidden="true"><i></i><i></i><i></i></div>
      </div>
    </div>
  </div>
</div>
```
## CSS
```css
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');

.la-05 {
  --bg: #f6f7fb;
  --surface: #ffffff;
  --bubble: #ffffff;
  --ink: #0c1017;
  --muted: #7b8494;
  --line: #e5e8f0;
  --acc: #6d4aff;
  --user: #101623;
  --ring: #6d4aff;
  --radius: 1.15rem;
  --shadow: rgba(12,16,23,.14);
  --sans: "Geist",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
  --mono: "Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
  position: relative;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.25rem,5vw,4rem);
  background: radial-gradient(90% 60% at 50% 0%, color-mix(in oklab,var(--acc) 15%,transparent), transparent 60%), var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

.la-05,
.la-05 *,
.la-05 *::before,
.la-05 *::after {
  box-sizing: border-box;
}

@supports (color: oklch(0 0 0)) {
  .la-05 {
    --acc: oklch(58% .23 288);
  }
}

.la-05__thread {
  inline-size: min(27rem,100%);
  display: grid;
  gap: .85rem;
}

.la-05__msg {
  justify-self: end;
  max-inline-size: 80%;
  padding: .8rem 1rem;
  border-radius: var(--radius) var(--radius) .35rem var(--radius);
  background: var(--user);
  color: #fff;
}

.la-05__msg p {
  margin: 0;
  font-size: .92rem;
  line-height: 1.45;
  text-wrap: pretty;
}

.la-05__row {
  display: flex;
  gap: .65rem;
  align-items: flex-end;
}

.la-05__avatar {
  flex: none;
  inline-size: 2rem;
  block-size: 2rem;
  border-radius: 999px;
  display: grid;
  place-items: center;
  background: linear-gradient(140deg, color-mix(in oklab,var(--acc) 85%,white), var(--acc));
  color: #fff;
  box-shadow: 0 8px 16px -10px var(--acc);
}

.la-05__avatar svg {
  inline-size: 1.05rem;
  block-size: 1.05rem;
  fill: currentColor;
}

.la-05__bubble {
  position: relative;
  padding: .9rem 1.1rem;
  border-radius: var(--radius) var(--radius) var(--radius) .35rem;
  background: var(--bubble);
  border: 1px solid var(--line);
  display: grid;
  gap: .55rem;
  justify-items: start;
  box-shadow: 0 20px 40px -28px var(--shadow);
  inline-size: max-content;
  min-inline-size: 11.5rem;
  max-inline-size: min(100%,18rem);
}

.la-05__bubble::after {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(115deg, transparent 35%, color-mix(in oklab,var(--acc) 22%,transparent) 50%, transparent 65%) 0 0/250% 100%;
  animation: la-05-sheen 3.4s linear infinite;
  opacity: .7;
}

@keyframes la-05-sheen {
  to {
    background-position: -250% 0;
  }
}

.la-05__tail {
  position: absolute;
  inset-block-end: -1px;
  inset-inline-start: -.3rem;
  inline-size: .7rem;
  block-size: .7rem;
  background: var(--bubble);
  border-inline-start: 1px solid var(--line);
  border-block-end: 1px solid var(--line);
  border-end-start-radius: .2rem;
  rotate: 45deg;
}

.la-05__dots {
  display: flex;
  gap: .34rem;
  padding-block: .15rem;
}

.la-05__dot {
  inline-size: .5rem;
  block-size: .5rem;
  border-radius: 999px;
  background: var(--acc);
  opacity: .55;
  animation: la-05-bounce 1.25s cubic-bezier(.3,0,.2,1) infinite;
}

.la-05__dot:nth-child(2) {
  animation-delay: .16s;
}

.la-05__dot:nth-child(3) {
  animation-delay: .32s;
}

@keyframes la-05-bounce {
  0%,
    60%,
    100% {
    translate: 0 0;
    opacity: .5;
  }

  30% {
    translate: 0 -.5rem;
    opacity: 1;
  }
}

@supports (animation-timing-function: linear(0,1)) {
  .la-05__dot {
    animation-timing-function: linear(0, .18 12%, .62 26%, 1 42%, .82 58%, 1 74%, .95 88%, 1);
  }
}

.la-05__phase {
  margin: 0;
  font-size: .86rem;
  font-weight: 500;
  letter-spacing: -.01em;
  background: linear-gradient(90deg, var(--muted) 28%, var(--ink) 48%, var(--muted) 68%) 0 0/220% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: la-05-shimmer 2.8s linear infinite;
}

@keyframes la-05-shimmer {
  to {
    background-position: -220% 0;
  }
}

@supports (transition-behavior: allow-discrete) {
  @starting-style {
    .la-05__phase {
      opacity: 0;
    }
  }

  .la-05__phase {
    transition: opacity .3s ease;
  }
}

.la-05__answer {
  display: none;
  gap: .4rem;
  inline-size: 13rem;
}

.la-05__answer i {
  display: block;
  block-size: .5rem;
  border-radius: 999px;
  background: color-mix(in oklab,var(--ink) 12%,transparent);
}

.la-05__answer i:nth-child(2) {
  inline-size: 88%;
}

.la-05__answer i:nth-child(3) {
  inline-size: 62%;
}

.la-05.is-ready .la-05__dots {
  display: none;
}

.la-05.is-ready .la-05__bubble::after {
  animation: none;
  opacity: 0;
}

.la-05.is-ready .la-05__phase {
  animation: none;
  background: none;
  color: var(--ink);
  -webkit-text-fill-color: currentColor;
}

.la-05.is-ready .la-05__answer {
  display: grid;
}

.la-05:has(.la-05__answer:not([hidden])) .la-05__bubble {
  min-inline-size: 14rem;
}

.la-05__chrome {
  position: absolute;
  inset-block-start: clamp(.75rem,2.5vw,1.5rem);
  inset-inline-end: clamp(.75rem,2.5vw,1.5rem);
  z-index: 20;
  display: flex;
  gap: .5rem;
}

.la-05__btn {
  inline-size: 2.75rem;
  block-size: 2.75rem;
  display: grid;
  place-items: center;
  cursor: pointer;
  padding: 0;
  border-radius: 999px;
  border: 1px solid color-mix(in oklab,var(--ink) 16%,transparent);
  color: var(--ink);
  background: color-mix(in oklab,var(--surface) 72%,transparent);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  transition: background .25s ease, transform .25s cubic-bezier(.16,1,.3,1);
}

.la-05__btn:hover {
  background: var(--surface);
  transform: translateY(-1px);
}

.la-05__btn:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 3px;
}

.la-05__btn svg {
  grid-area: 1/1;
  inline-size: 1.15rem;
  block-size: 1.15rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.la-05__sun {
  display: none;
}

.la-05__theme[aria-pressed="true"] .la-05__sun {
  display: block;
}

.la-05__theme[aria-pressed="true"] .la-05__moon {
  display: none;
}

.la-05__done[aria-pressed="true"] {
  background: var(--acc);
  border-color: transparent;
  color: #fff;
}

@media (prefers-color-scheme: dark) {
  .la-05:not([data-theme="light"]) {
    --bg: #080a10;
    --surface: #12151e;
    --bubble: #151a25;
    --ink: #eef1f8;
    --muted: #8d97a8;
    --line: #242c3a;
    --acc: #9d86ff;
    --user: #232c3d;
    --ring: #9d86ff;
    --shadow: rgba(0,0,0,.7);
  }
}

.la-05[data-theme="dark"] {
  --bg: #080a10;
  --surface: #12151e;
  --bubble: #151a25;
  --ink: #eef1f8;
  --muted: #8d97a8;
  --line: #242c3a;
  --acc: #9d86ff;
  --user: #232c3d;
  --ring: #9d86ff;
  --shadow: rgba(0,0,0,.7);
}

@media (prefers-reduced-motion: reduce) {
  .la-05 * {
    animation: none !important;
    transition: none !important;
  }

  .la-05__dot {
    opacity: 1;
  }

  .la-05__dot:nth-child(2) {
    opacity: .6;
  }

  .la-05__dot:nth-child(3) {
    opacity: .35;
  }

  .la-05__phase {
    background: none;
    color: var(--ink);
    -webkit-text-fill-color: currentColor;
  }
}

@media (forced-colors: active) {
  .la-05__bubble,
    .la-05__msg {
    border: 1px solid CanvasText;
  }

  .la-05__dot {
    background: Highlight;
  }

  .la-05__btn {
    border: 1px solid ButtonText;
    background: ButtonFace;
    color: ButtonText;
  }
}
```

## JavaScript
```js
(() => {
  const root = document.querySelector('.la-05');
  if (!root) return;
  const theme = root.querySelector('.la-05__theme');
  const done = root.querySelector('.la-05__done');
  const phase = root.querySelector('#la-05-phase');
  const PHASES = ['AI is thinking…', 'Reading Q3-revenue.pdf', 'Cross-checking board memo', 'Drafting the summary'];
  let i = 0;
  const timer = setInterval(() => { if (!root.classList.contains('is-ready')) phase.textContent = PHASES[++i % PHASES.length]; }, 1900);
  const dark = () => (root.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) === 'dark';
  const sync = () => {
    theme.setAttribute('aria-pressed', String(dark()));
    theme.setAttribute('aria-label', dark() ? 'Switch to light theme' : 'Switch to dark theme');
  };
  theme.addEventListener('click', () => { root.dataset.theme = dark() ? 'light' : 'dark'; sync(); });
  matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (!root.dataset.theme) sync(); });
  done.addEventListener('click', () => {
    const ready = root.classList.toggle('is-ready');
    root.dataset.state = ready ? 'ready' : 'loading';
    done.setAttribute('aria-pressed', String(ready));
    done.setAttribute('aria-label', ready ? 'Return to loading state' : 'Show completed state');
    phase.textContent = ready ? 'Answer ready — 3 sources checked' : PHASES[0];
    if (ready) clearInterval(timer);
  });
  sync();
})();
```

How this works

Bounce quality is entirely the easing curve. ease-in-out gives a floaty, symmetrical wobble; a spring rises fast and settles slowly. linear() lets you sample a real spring as a piecewise curve, with a cubic fallback declared first:

.la-05__dot{ animation-timing-function: cubic-bezier(.3,0,.2,1); }
@supports (animation-timing-function: linear(0,1)){
  .la-05__dot{ animation-timing-function: linear(0, .18 12%, .62 26%, 1 42%, .82 58%, 1 74%, .94 88%, 1); }
}

The dots themselves are one rule plus two delays — the amount most "typing indicator" snippets get wrong by animating top (layout) instead of translate (compositor):

.la-05__dot{ animation:la-05-bounce 1.25s infinite; }
.la-05__dot:nth-child(2){ animation-delay:.16s }
.la-05__dot:nth-child(3){ animation-delay:.32s }
@keyframes la-05-bounce{ 0%,60%,100%{ translate:0 0; opacity:.55 } 30%{ translate:0 -.5rem; opacity:1 } }

The label shimmer clips a moving gradient to the glyphs. Keep the gradient wide and the movement slow, or it reads as a glitch:

.la-05__phase{
  background:linear-gradient(90deg,var(--muted) 30%,var(--ink) 50%,var(--muted) 70%) 0 0/200% 100%;
  -webkit-background-clip:text; background-clip:text; color:transparent;
  animation:la-05-shimmer 2.6s linear infinite;
}

JavaScript does exactly one job: swap the phase text on a timer inside the polite live region, so an assistive-tech user hears the same progress a sighted user reads.

Make it yours

  • Edit the PHASES array to your product's real steps; keep each under ~28 characters so the bubble never reflows.
  • Set the interval to match your median latency — cycling faster than ~1.6s per phase reads as fake.
  • Turn off the rotator entirely (delete the setInterval) and the demo is 100% CSS with a single static "AI is thinking…" line.
  • Change --bubble and --radius to match your chat surface; the tail is a rotated square, so it inherits the bubble colour automatically.
  • For a user-side indicator (the other party is typing), mirror the layout by setting justify-items:end and flipping the tail's inset-inline.

Gotchas — read before shipping

  • Never announce a spinner as "loading" on repeat — screen readers will interrupt the user constantly. Rotate meaningful phase text inside one polite region, as here, and keep the interval ≥ 1.5s.
  • background-clip: text needs the -webkit- prefix and a transparent color; without the fallback, the label disappears in older Safari.
  • Animating top/margin for a bounce triggers layout on every frame. Use translate.
  • Three dots alone fail WCAG 1.1.1 — pair them with visible text and an accessible name, which is why the phase line is not decorative.
  • linear() with too few stops looks worse than a good cubic-bezier. Sample 6-8 points minimum.

Browser support

ChromeSafariFirefoxEdge
117+17.5+129+117+

Floor set by :has(), oklch(), color-mix(), backdrop-filter, @starting-style, transition-behavior, text-wrap as this demo is written. The core technique itself goes back further — Chrome 113+.

linear() easing is Chrome 113 / Safari 17.2 / Firefox 112, gated behind @supports with a cubic-bezier fallback. background-clip: text is universal with the -webkit- prefix. @starting-style is Chrome 117 / Safari 17.4 / Firefox 129 and is enhancement only; the rotator is plain ES2024.

Techniques used in this demo

Search CodeFronts

Loading…