Open demo

CSS code

HTML
<div><span class="lbl">Typing indicators</span>
<div class="an">
  <div class="an-row">
    <span class="an-av" aria-hidden="true">ID</span>
    <p class="an-b">Which platform does the 18:10 leave from?</p>
  </div>
  <div class="an-row">
    <span class="an-av" aria-hidden="true">ID</span>
    <div class="an-b an-typing" role="status"><span class="sr-only">Ines is typing</span><i></i><i></i><i></i></div>
  </div>
  <p class="an-pill" role="status"><i></i><i></i><i></i>Assistant is writing</p>
</div></div>

<div><span class="lbl">Messages that pop in</span>
<div class="an an-thread is-playing" id="an-thread">
  <p class="an-b" style="--i:0"><span class="sr-only">Ines: </span>Is the 18:10 still on time?</p>
  <p class="an-b out" style="--i:1"><span class="sr-only">You: </span>Two minutes late. Platform 4.</p>
  <p class="an-b out" style="--i:2">I saved you a window seat in coach C.</p>
  <p class="an-b" style="--i:3"><span class="sr-only">Ines: </span>Running now. Hold the door.</p>
  <button class="an-replay" type="button" data-an-replay="an-thread"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>Replay animation</button>
</div></div>

<div><span class="lbl">Help nudge that pops in and bobs</span>
<div class="an an-help">
  <p class="an-nudge"><strong>Need a hand with your booking?</strong>We usually reply in about 5 minutes.</p>
  <button class="an-launch" type="button" aria-label="Open chat"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-4.9A8 8 0 1 1 21 12z"/></svg></button>
</div></div>
CSS
/* Animated speech bubbles: typing indicators, messages that pop in from their tail, and a nudge that pops in and
   bobs twice. Everything is CSS keyframes; under prefers-reduced-motion it all holds still in its final state. */
.an{--an-in:#fff;--an-out:#5b4bdb;--an-ink:#1d1b33;--an-muted:#59577a;--an-dot:#8a87a8;--an-r:18px;
  display:flex;flex-direction:column;gap:14px;padding:22px;border-radius:22px;background:#e7e5f6}
.an .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.an-row{display:flex;align-items:flex-end;gap:10px}
.an-av{flex:none;display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:#ffd8a8;color:#6b3a00;font-size:.74rem;font-weight:700}
.an-b{position:relative;width:fit-content;max-width:80%;margin:0;padding:9px 14px;border-radius:var(--an-r) var(--an-r) var(--an-r) 4px;background:var(--an-in);color:var(--an-ink);
  font-size:.93rem;line-height:1.4;box-shadow:0 2px 6px -2px rgba(29,27,51,.12)}
.an-b.out{align-self:flex-end;border-radius:var(--an-r) var(--an-r) 4px var(--an-r);background:var(--an-out);color:#fff}

/* 1. Typing indicators */
.an-typing{display:flex;align-items:center;gap:5px;padding:13px 16px}
.an-typing i{width:8px;height:8px;border-radius:50%;background:var(--an-dot);animation:an-bounce 1.2s ease-in-out infinite}
.an-typing i:nth-of-type(2){animation-delay:.15s}
.an-typing i:nth-of-type(3){animation-delay:.3s}
@keyframes an-bounce{0%,60%,100%{translate:0 0;opacity:.55}30%{translate:0 -5px;opacity:1}}
.an-pill{display:inline-flex;align-items:center;gap:8px;width:fit-content;padding:7px 14px 7px 10px;border-radius:999px;background:#fff;color:var(--an-muted);font-size:.82rem;font-weight:600}
.an-pill i{width:7px;height:7px;border-radius:50%;background:var(--an-out);animation:an-fade 1.4s ease-in-out infinite}
.an-pill i:nth-of-type(2){animation-delay:.2s}
.an-pill i:nth-of-type(3){animation-delay:.4s}
.an-pill i:last-of-type{margin-right:2px}
@keyframes an-fade{0%,100%{opacity:.25;scale:.8}50%{opacity:1;scale:1}}

/* 2. Messages pop in from the corner their tail sits on, one after another (--i sets the order) */
.an-thread.is-playing .an-b{animation:an-pop .5s cubic-bezier(.34,1.56,.64,1) calc(var(--i) * .55s) both;transform-origin:0 100%}
.an-thread.is-playing .an-b.out{transform-origin:100% 100%}
@keyframes an-pop{from{opacity:0;scale:.5}to{opacity:1;scale:1}}
.an-replay{align-self:flex-start;display:inline-flex;align-items:center;gap:6px;margin-top:4px;padding:7px 14px;border:1.5px solid #c9c5ea;border-radius:999px;background:#fff;
  color:#3a2fa8;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer}
.an-replay:hover{border-color:var(--an-out)}

/* 3. Nudge: pops in after a second, bobs twice, then stays put */
.an-help{align-items:flex-end;min-height:260px;justify-content:flex-end;background:#e7e5f6 radial-gradient(#d5d1ef 1px,transparent 1.4px) 0 0/16px 16px}
.an-nudge{position:relative;max-width:240px;margin:0 6px 16px 0;padding:14px 16px;border-radius:16px 16px 4px 16px;background:#fff;color:var(--an-ink);font-size:.9rem;line-height:1.4;
  box-shadow:0 12px 28px -14px rgba(29,27,51,.45);transform-origin:100% 100%;animation:an-pop .45s cubic-bezier(.34,1.56,.64,1) .8s both,an-bob 1.6s ease-in-out 1.4s 2}
.an-nudge strong{display:block;margin-bottom:2px}
.an-nudge::after{content:"";position:absolute;top:100%;right:18px;border:9px solid transparent;border-top-color:#fff;border-bottom-width:0;border-right-width:0}
@keyframes an-bob{0%,100%{translate:0 0}50%{translate:0 -6px}}
.an-launch{display:grid;place-items:center;width:56px;height:56px;border:0;border-radius:50%;background:var(--an-out);color:#fff;cursor:pointer;box-shadow:0 10px 22px -8px rgba(91,75,219,.7)}
.an-launch:hover{background:#4a3bc6}
:is(.an-replay,.an-launch):focus-visible{outline:2px solid #3a2fa8;outline-offset:3px}

@media (prefers-reduced-motion:reduce){
  .an *,.an *::before,.an *::after{animation:none!important}
  .an-typing i,.an-pill i{opacity:.7}
}
JavaScript
// Replay: drop the class, force a reflow, add it back so the keyframes start again.
document.querySelectorAll('[data-an-replay]').forEach(function (btn) {
  btn.addEventListener('click', function () {
    var t = document.getElementById(btn.dataset.anReplay);
    if (!t) return;
    t.classList.remove('is-playing'); void t.offsetWidth; t.classList.add('is-playing');
  });
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Typing indicators</span>
<div class="an d-flex flex-column gap-3">
  <div class="an-row d-flex align-items-end gap-2">
    <span class="an-av rounded-circle fw-bold" aria-hidden="true">ID</span>
    <p class="mb-0 an-b">Which platform does the 18:10 leave from?</p>
  </div>
  <div class="an-row d-flex align-items-end gap-2">
    <span class="an-av rounded-circle fw-bold" aria-hidden="true">ID</span>
    <div class="an-b an-typing" role="status"><span class="visually-hidden">Ines is typing</span><i></i><i></i><i></i></div>
  </div>
  <p class="an-pill rounded-pill fw-semibold mb-0" role="status"><i></i><i></i><i></i>Assistant is writing</p>
</div></div>

<div><span class="lbl">Messages that pop in</span>
<div class="an an-thread d-flex flex-column gap-3 is-playing" id="an-thread">
  <p class="mb-0 an-b" style="--i:0"><span class="visually-hidden">Ines: </span>Is the 18:10 still on time?</p>
  <p class="mb-0 an-b out" style="--i:1"><span class="visually-hidden">You: </span>Two minutes late. Platform 4.</p>
  <p class="mb-0 an-b out" style="--i:2">I saved you a window seat in coach C.</p>
  <p class="mb-0 an-b" style="--i:3"><span class="visually-hidden">Ines: </span>Running now. Hold the door.</p>
  <button class="btn btn-sm rounded-pill an-replay" type="button" data-an-replay="an-thread"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>Replay animation</button>
</div></div>

<div><span class="lbl">Help nudge that pops in and bobs</span>
<div class="an an-help d-flex flex-column align-items-end justify-content-end">
  <p class="an-nudge"><strong class="d-block">Need a hand with your booking?</strong>We usually reply in about 5 minutes.</p>
  <button class="btn rounded-circle p-0 an-launch" type="button" aria-label="Open chat"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-4.9A8 8 0 1 1 21 12z"/></svg></button>
</div></div>
CSS
/* Animated speech bubbles on Bootstrap (flex utilities, .btn, .rounded-pill, .visually-hidden): typing indicators, messages that pop in from their tail, and a nudge that pops in and
   bobs twice. Everything is CSS keyframes; under prefers-reduced-motion it all holds still in its final state. */
.an{--an-in:#fff;--an-out:#5b4bdb;--an-ink:#1d1b33;--an-muted:#59577a;--an-dot:#8a87a8;--an-r:18px;
  display:flex;flex-direction:column;gap:14px;padding:22px;border-radius:22px;background:#e7e5f6}
.an-row{display:flex;align-items:flex-end;gap:10px}
.an-av{flex:none;display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:#ffd8a8;color:#6b3a00;font-size:.74rem;font-weight:700}
.an-b{position:relative;width:fit-content;max-width:80%;margin:0;padding:9px 14px;border-radius:var(--an-r) var(--an-r) var(--an-r) 4px;background:var(--an-in);color:var(--an-ink);
  font-size:.93rem;line-height:1.4;box-shadow:0 2px 6px -2px rgba(29,27,51,.12)}
.an-b.out{align-self:flex-end;border-radius:var(--an-r) var(--an-r) 4px var(--an-r);background:var(--an-out);color:#fff}

/* 1. Typing indicators */
.an-typing{display:flex;align-items:center;gap:5px;padding:13px 16px}
.an-typing i{width:8px;height:8px;border-radius:50%;background:var(--an-dot);animation:an-bounce 1.2s ease-in-out infinite}
.an-typing i:nth-of-type(2){animation-delay:.15s}
.an-typing i:nth-of-type(3){animation-delay:.3s}
@keyframes an-bounce{0%,60%,100%{translate:0 0;opacity:.55}30%{translate:0 -5px;opacity:1}}
.an-pill{display:inline-flex;align-items:center;gap:8px;width:fit-content;padding:7px 14px 7px 10px;border-radius:999px;background:#fff;color:var(--an-muted);font-size:.82rem;font-weight:600}
.an-pill i{width:7px;height:7px;border-radius:50%;background:var(--an-out);animation:an-fade 1.4s ease-in-out infinite}
.an-pill i:nth-of-type(2){animation-delay:.2s}
.an-pill i:nth-of-type(3){animation-delay:.4s}
.an-pill i:last-of-type{margin-right:2px}
@keyframes an-fade{0%,100%{opacity:.25;scale:.8}50%{opacity:1;scale:1}}

/* 2. Messages pop in from the corner their tail sits on, one after another (--i sets the order) */
.an-thread.is-playing .an-b{animation:an-pop .5s cubic-bezier(.34,1.56,.64,1) calc(var(--i) * .55s) both;transform-origin:0 100%}
.an-thread.is-playing .an-b.out{transform-origin:100% 100%}
@keyframes an-pop{from{opacity:0;scale:.5}to{opacity:1;scale:1}}
.an-replay{align-self:flex-start;display:inline-flex;align-items:center;gap:6px;margin-top:4px;padding:7px 14px;border:1.5px solid #c9c5ea;border-radius:999px;background:#fff;
  color:#3a2fa8;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer}
.an-replay:hover,.an-replay:active{border-color:var(--an-out);background:#fff;color:#3a2fa8}

/* 3. Nudge: pops in after a second, bobs twice, then stays put */
.an-help{align-items:flex-end;min-height:260px;justify-content:flex-end;background:#e7e5f6 radial-gradient(#d5d1ef 1px,transparent 1.4px) 0 0/16px 16px}
.an-nudge{position:relative;max-width:240px;margin:0 6px 16px 0;padding:14px 16px;border-radius:16px 16px 4px 16px;background:#fff;color:var(--an-ink);font-size:.9rem;line-height:1.4;
  box-shadow:0 12px 28px -14px rgba(29,27,51,.45);transform-origin:100% 100%;animation:an-pop .45s cubic-bezier(.34,1.56,.64,1) .8s both,an-bob 1.6s ease-in-out 1.4s 2}
.an-nudge strong{display:block;margin-bottom:2px}
.an-nudge::after{content:"";position:absolute;top:100%;right:18px;border:9px solid transparent;border-top-color:#fff;border-bottom-width:0;border-right-width:0}
@keyframes an-bob{0%,100%{translate:0 0}50%{translate:0 -6px}}
.an-launch{display:grid;place-items:center;width:56px;height:56px;border:0;border-radius:50%;background:var(--an-out);color:#fff;cursor:pointer;box-shadow:0 10px 22px -8px rgba(91,75,219,.7)}
.an-launch:hover,.an-launch:active{background:#4a3bc6;color:#fff}
:is(.an-replay,.an-launch):focus-visible{outline:2px solid #3a2fa8;outline-offset:3px}

@media (prefers-reduced-motion:reduce){
  .an *,.an *::before,.an *::after{animation:none!important}
  .an-typing i,.an-pill i{opacity:.7}
}
JavaScript
// Replay: drop the class, force a reflow, add it back so the keyframes start again.
document.querySelectorAll('[data-an-replay]').forEach(function (btn) {
  btn.addEventListener('click', function () {
    var t = document.getElementById(btn.dataset.anReplay);
    if (!t) return;
    t.classList.remove('is-playing'); void t.offsetWidth; t.classList.add('is-playing');
  });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-an-muted uppercase">Typing indicators</span>
<div class="flex flex-col gap-3.5 rounded-[22px] bg-an-panel p-[22px]">
  <div class="flex items-end gap-2.5">
    <span class="grid size-[34px] flex-none place-items-center rounded-full bg-[#ffd8a8] text-[.74rem] font-bold text-[#6b3a00]" aria-hidden="true">ID</span>
    <p class="relative m-0 w-fit max-w-[80%] rounded-[18px_18px_18px_4px] bg-white px-3.5 py-[9px] text-[.93rem] leading-[1.4] text-an-ink shadow-[0_2px_6px_-2px_rgba(29,27,51,.12)]">Which platform does the 18:10 leave from?</p>
  </div>
  <div class="flex items-end gap-2.5">
    <span class="grid size-[34px] flex-none place-items-center rounded-full bg-[#ffd8a8] text-[.74rem] font-bold text-[#6b3a00]" aria-hidden="true">ID</span>
    <div class="relative m-0 w-fit max-w-[80%] rounded-[18px_18px_18px_4px] bg-white px-3.5 py-[9px] text-[.93rem] leading-[1.4] text-an-ink shadow-[0_2px_6px_-2px_rgba(29,27,51,.12)] flex items-center gap-[5px] px-4! py-[13px]!" role="status"><span class="sr-only">Ines is typing</span><i class="size-2 rounded-full bg-[#8a87a8] animate-an-bounce motion-reduce:animate-none motion-reduce:opacity-70"></i><i class="size-2 rounded-full bg-[#8a87a8] animate-an-bounce motion-reduce:animate-none motion-reduce:opacity-70 [animation-delay:.15s]"></i><i class="size-2 rounded-full bg-[#8a87a8] animate-an-bounce motion-reduce:animate-none motion-reduce:opacity-70 [animation-delay:.3s]"></i></div>
  </div>
  <p class="m-0 inline-flex w-fit items-center gap-2 rounded-full bg-white py-[7px] pr-3.5 pl-2.5 text-[.82rem] font-semibold text-an-muted" role="status"><i class="size-[7px] rounded-full bg-an-out animate-an-fade motion-reduce:animate-none motion-reduce:opacity-70"></i><i class="size-[7px] rounded-full bg-an-out animate-an-fade motion-reduce:animate-none motion-reduce:opacity-70 [animation-delay:.2s]"></i><i class="size-[7px] rounded-full bg-an-out animate-an-fade motion-reduce:animate-none motion-reduce:opacity-70 mr-0.5 [animation-delay:.4s]"></i>Assistant is writing</p>
</div></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-an-muted uppercase">Messages that pop in</span>
<div class="group is-playing flex flex-col gap-3.5 rounded-[22px] bg-an-panel p-[22px]" id="an-thread">
  <p class="relative m-0 w-fit max-w-[80%] rounded-[18px_18px_18px_4px] bg-white px-3.5 py-[9px] text-[.93rem] leading-[1.4] text-an-ink shadow-[0_2px_6px_-2px_rgba(29,27,51,.12)] origin-bottom-left group-[.is-playing]:animate-an-pop group-[.is-playing]:[animation-delay:calc(var(--i)*.55s)] motion-reduce:animate-none! [--i:0]"><span class="sr-only">Ines: </span>Is the 18:10 still on time?</p>
  <p class="relative m-0 w-fit max-w-[80%] self-end rounded-[18px_18px_4px_18px] bg-an-out px-3.5 py-[9px] text-[.93rem] leading-[1.4] text-white shadow-[0_2px_6px_-2px_rgba(29,27,51,.12)] origin-bottom-right group-[.is-playing]:animate-an-pop group-[.is-playing]:[animation-delay:calc(var(--i)*.55s)] motion-reduce:animate-none! [--i:1]"><span class="sr-only">You: </span>Two minutes late. Platform 4.</p>
  <p class="relative m-0 w-fit max-w-[80%] self-end rounded-[18px_18px_4px_18px] bg-an-out px-3.5 py-[9px] text-[.93rem] leading-[1.4] text-white shadow-[0_2px_6px_-2px_rgba(29,27,51,.12)] origin-bottom-right group-[.is-playing]:animate-an-pop group-[.is-playing]:[animation-delay:calc(var(--i)*.55s)] motion-reduce:animate-none! [--i:2]">I saved you a window seat in coach C.</p>
  <p class="relative m-0 w-fit max-w-[80%] rounded-[18px_18px_18px_4px] bg-white px-3.5 py-[9px] text-[.93rem] leading-[1.4] text-an-ink shadow-[0_2px_6px_-2px_rgba(29,27,51,.12)] origin-bottom-left group-[.is-playing]:animate-an-pop group-[.is-playing]:[animation-delay:calc(var(--i)*.55s)] motion-reduce:animate-none! [--i:3]"><span class="sr-only">Ines: </span>Running now. Hold the door.</p>
  <button class="mt-1 inline-flex cursor-pointer items-center gap-1.5 self-start rounded-full border-[1.5px] border-[#c9c5ea] bg-white px-3.5 py-[7px] text-[.82rem] font-semibold text-[#3a2fa8] hover:border-an-out focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#3a2fa8]" type="button" data-an-replay="an-thread"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>Replay animation</button>
</div></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-an-muted uppercase">Help nudge that pops in and bobs</span>
<div class="flex flex-col gap-3.5 rounded-[22px] bg-an-panel p-[22px] min-h-[260px] items-end justify-end bg-[radial-gradient(#d5d1ef_1px,transparent_1.4px)] bg-size-[16px_16px]">
  <p class="an-nudge relative m-0 mr-1.5 mb-4 max-w-[240px] origin-bottom-right animate-an-nudge rounded-[16px_16px_4px_16px] bg-white px-4 py-3.5 text-[.9rem] leading-[1.4] text-an-ink shadow-[0_12px_28px_-14px_rgba(29,27,51,.45)] motion-reduce:animate-none"><strong class="mb-0.5 block">Need a hand with your booking?</strong>We usually reply in about 5 minutes.</p>
  <button class="grid size-14 cursor-pointer place-items-center rounded-full bg-an-out text-white shadow-[0_10px_22px_-8px_rgba(91,75,219,.7)] hover:bg-[#4a3bc6] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#3a2fa8]" type="button" aria-label="Open chat"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-4.9A8 8 0 1 1 21 12z"/></svg></button>
</div></div>
CSS
/* Keyframes as theme animations: animate-an-bounce, animate-an-fade, animate-an-pop (with group-[.is-playing]:[animation-delay:calc(var(--i)*.55s)]), animate-an-nudge.
   Every animated element also carries motion-reduce:animate-none. */
@theme {
  --color-an-out:#5b4bdb; --color-an-ink:#1d1b33; --color-an-muted:#59577a; --color-an-panel:#e7e5f6;
  --animate-an-bounce:an-bounce 1.2s ease-in-out infinite;
  --animate-an-fade:an-fade 1.4s ease-in-out infinite;
  --animate-an-pop:an-pop .5s cubic-bezier(.34,1.56,.64,1) both;
  --animate-an-nudge:an-pop .45s cubic-bezier(.34,1.56,.64,1) .8s both,an-bob 1.6s ease-in-out 1.4s 3;
  @keyframes an-bounce{0%,60%,100%{translate:0 0;opacity:.55}30%{translate:0 -5px;opacity:1}}
  @keyframes an-fade{0%,100%{opacity:.25;scale:.8}50%{opacity:1;scale:1}}
  @keyframes an-pop{from{opacity:0;scale:.5}to{opacity:1;scale:1}}
  @keyframes an-bob{0%,100%{translate:0 0}50%{translate:0 -6px}}
}
/* The nudge's tail is a pseudo-element triangle */
.an-nudge::after{content:"";position:absolute;top:100%;right:18px;border:9px solid transparent;border-top-color:#fff;border-bottom-width:0;border-right-width:0}
JavaScript
// Replay: drop the class, force a reflow, add it back so the keyframes start again.
document.querySelectorAll('[data-an-replay]').forEach(function (btn) {
  btn.addEventListener('click', function () {
    var t = document.getElementById(btn.dataset.anReplay);
    if (!t) return;
    t.classList.remove('is-playing'); void t.offsetWidth; t.classList.add('is-playing');
  });
});

About this set

Three kinds of movement a speech bubble needs, all CSS keyframes. Typing indicators shows a bubble with three dots that bounce in turn beside the sender’s avatar, and a pill whose dots pulse beside the words Assistant is writing; both are role=”status” regions with readable text. Messages that pop in plays a four-message conversation in which each bubble scales up from the corner its tail sits on, with a slight overshoot, one after another; a –i custom property on each bubble sets its place in the order. A Replay animation button restarts the sequence, which is the only JavaScript in the set: about six lines that remove a class, force a reflow and add it back. Without JavaScript the messages still pop in once on load. Help nudge that pops in and bobs shows a support launcher whose help bubble appears after a second, bobs twice and then stays still, so it stops moving within five seconds. Under prefers-reduced-motion every animation is switched off and each bubble is shown in its final state.

What’s included

  • Bouncing and pulsing typing indicators with role="status"
  • Messages that pop in from their tail corner in sequence
  • Replay button with about six lines of vanilla JavaScript
  • Nudge bubble that bobs twice and stops
  • Everything holds still under prefers-reduced-motion

Accessibility

Both typing indicators are role="status" regions with text, Ines is typing or Assistant is writing, so the dots are not the only cue. Messages start with visually hidden sender names, the replay and chat buttons have names and a 2px focus outline, and under prefers-reduced-motion all keyframes are removed and every bubble is visible.

Customise it

Set --an-out, --an-in and --an-dot on .an for colours, change the .55s step in the pop-in delay to speed the sequence up, and the iteration count of an-bob for the nudge (keep the total under five seconds). In Tailwind the animations are the --animate-an-* theme values; Bootstrap uses the same CSS with .btn and .rounded-pill.