Swish UI
All componentsFeedback and forms Free

Typing to message

A typing bubble with bouncing dots grows into the real reply as its text fades in. Tap a quick reply to answer.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

const INTRO = "Your flight to Lisbon on May 14 is confirmed.";
const STEPS = [
  { me: "Can I add a bag?", them: "Sure. One checked bag is 35 EUR, want me to add it?" },
  { me: "Yes, please", them: "Done. The bag is on booking LX4R2." },
  { me: "Thanks!", them: "Anytime, have a great trip." },
];
type Stage = "offer" | "sent" | "typing" | "reply";
// How long each stage lasts before the script moves on. The offer waits for a click, or sends itself.
const WAIT: Record<Stage, number> = { offer: 2600, sent: 550, typing: 1500, reply: 900 };
const TYPING = { w: 58, h: 36 };

function Bubble({ text, typing }: { text: string; typing: boolean }) {
  const body = useRef<HTMLSpanElement>(null);
  // Explicit px size while typing and while growing; null once settled so the bubble sizes itself.
  const [size, setSize] = useState<{ w: number; h: number } | null>(typing ? TYPING : null);

  useLayoutEffect(() => {
    if (typing || !size || !body.current) return;
    // Measure the message laid out at its natural size, then let CSS grow the bubble to it.
    setSize({ w: body.current.offsetWidth + 26, h: body.current.offsetHeight + 18 });
    const t = setTimeout(() => setSize(null), 500);
    return () => clearTimeout(t);
  }, [typing]);

  return (
    <div
      className={styles.bubble}
      data-from="them"
      data-typing={typing || undefined}
      data-sized={size ? "" : undefined}
      style={size ? { width: size.w, height: size.h } : undefined}
      aria-label={typing ? "Maya is typing" : undefined}
    >
      <span className={styles.dots} aria-hidden="true"><i /><i /><i /></span>
      <span ref={body} className={styles.body} aria-hidden={typing || undefined}>{text}</span>
    </div>
  );
}

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();
  const [round, setRound] = useState(0);
  const [step, setStep] = useState(0);
  const [stage, setStage] = useState<Stage>("offer");
  const end = step >= STEPS.length;

  // Reduced motion: start after the first exchange and never auto send.
  useEffect(() => {
    if (reduced) { setStep(1); setStage("offer"); }
  }, [reduced]);

  const send = () => {
    if (end) { setRound((r) => r + 1); setStep(0); setStage("offer"); }
    else setStage("sent");
  };

  useEffect(() => {
    const next = () => {
      if (stage === "offer") send();
      else if (stage === "sent") setStage(reduced ? "reply" : "typing");
      else if (stage === "typing") setStage("reply");
      else { setStep(step + 1); setStage("offer"); }
    };
    if (reduced) {
      // No typing, no waits: a sent message is answered at once, and only clicks send.
      if (stage !== "offer") next();
      return;
    }
    if (!inView) return;
    const t = setTimeout(next, end ? 3200 : WAIT[stage]);
    return () => clearTimeout(t);
  }, [step, stage, inView, reduced]);

  const shown = (i: number, at: Stage[]) => i < step || (i === step && at.includes(stage));
  const typing = stage === "typing" && !end;

  return (
    <div ref={root} className={styles.root}>
      <section className={styles.chat} aria-label="Trip support chat">
        <header className={styles.head}>
          <span className={styles.avatar} aria-hidden="true">MR</span>
          <div className={styles.who}>
            <p className={styles.name}>Maya, trip support</p>
            <p className={styles.state} data-typing={typing || undefined}>{typing ? "typing" : "Online"}</p>
          </div>
        </header>
        <div className={styles.log} role="log" aria-live="polite">
          <div key={round} className={styles.list}>
            <div className={styles.bubble} data-from="them"><span className={styles.body}>{INTRO}</span></div>
            {STEPS.map((s, i) => (
              <div key={i} className={styles.pair}>
                {shown(i, ["sent", "typing", "reply"]) && (
                  <div className={styles.bubble} data-from="me" data-new=""><span className={styles.body}>{s.me}</span></div>
                )}
                {shown(i, ["typing", "reply"]) && <Bubble text={s.them} typing={i === step && stage === "typing"} />}
              </div>
            ))}
          </div>
        </div>
        <footer className={styles.foot}>
          {stage === "offer" && (
            <button key={`${round}-${step}`} type="button" className={styles.chip} onClick={send}>
              {end ? "Start over" : STEPS[step].me}
              <svg viewBox="0 0 16 16" aria-hidden="true">
                {end ? <path d="M3 8a5 5 0 1 0 1.5-3.5M3 2.5v2.5h2.5" /> : <path d="M3 8h9.5M9 4.5 12.5 8 9 11.5" />}
              </svg>
            </button>
          )}
        </footer>
      </section>
    </div>
  );
}

demo.module.css

.root {
  container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 14px;
  box-sizing: border-box; background: var(--sand);
}
.chat {
  width: min(400px, 100%); height: 100%; max-height: 400px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto;
  border-radius: 18px; border: 1px solid var(--line); background: var(--white); overflow: hidden;
  box-shadow: 0 18px 40px -28px color-mix(in srgb, var(--ink) 45%, transparent);
}
.head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); }
.avatar {
  flex: none; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%;
  background: var(--accent-soft); color: var(--ink); font-size: 11px; font-weight: 700;
}
.who { min-width: 0; }
.who p { margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.name { font-size: 13px; font-weight: 650; }
.state { font-size: 11px; font-weight: 600; color: var(--muted); }
.state[data-typing] { color: var(--accent-text); }

/* The log keeps the newest messages pinned to the bottom; older ones fade out under the header. */
.log {
  container-type: inline-size; display: flex; flex-direction: column; justify-content: flex-end; min-height: 0; overflow: hidden;
  padding: 10px 12px 4px; mask-image: linear-gradient(to bottom, transparent, #000 28px);
}
.list { display: flex; flex-direction: column; gap: 6px; animation: fade .4s var(--out); }
@keyframes fade { from { opacity: 0; } }
.pair { display: contents; }

.bubble {
  position: relative; flex: none; max-width: 80%; padding: 8px 13px; box-sizing: border-box; border-radius: 16px;
  font-size: 13px; line-height: 1.35; transition: width .45s var(--out), height .45s var(--out);
}
.bubble[data-from="them"] { align-self: flex-start; background: var(--surface); color: var(--ink); border-bottom-left-radius: 5px; }
.bubble[data-from="me"] { align-self: flex-end; background: var(--accent-text); color: var(--white); border-bottom-right-radius: 5px; }
.bubble[data-new], .bubble[data-typing] { animation: pop .35s var(--out); }
.bubble[data-from="me"][data-new] { transform-origin: 100% 100%; }
.bubble[data-typing] { transform-origin: 0 100%; }
@keyframes pop { from { opacity: 0; scale: .7; } }

.body { display: block; transition: opacity .3s .15s; }
/* While sized from JS the text sits at its natural width so it can be measured, and the bubble clips it. */
.bubble[data-sized] { overflow: hidden; padding: 0; }
.bubble[data-sized] .body { position: absolute; left: 13px; top: 8px; width: max-content; max-width: calc(80cqi - 26px); }
.bubble[data-typing] .body { opacity: 0; transition: none; }

.dots { position: absolute; left: 14px; top: 50%; display: flex; gap: 4px; translate: 0 -50%; opacity: 0; transition: opacity .15s; pointer-events: none; }
.dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
.bubble[data-typing] .dots { opacity: 1; }
.bubble[data-typing] .dots i { animation: hop 1s var(--out) infinite; }
.dots i:nth-child(2) { animation-delay: .15s !important; }
.dots i:nth-child(3) { animation-delay: .3s !important; }
@keyframes hop { 0%, 60%, 100% { translate: 0 0; opacity: .5; } 30% { translate: 0 -4px; opacity: 1; } }

.foot { display: flex; justify-content: flex-end; min-height: 52px; padding: 8px 12px 10px; box-sizing: border-box; }
.chip {
  display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px 0 14px; border-radius: 999px;
  border: 1px solid var(--accent-soft); background: var(--accent-pale); color: var(--accent-text);
  font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; animation: pop .35s var(--out);
  transform-origin: 100% 50%; transition: transform .18s var(--out), background-color .2s;
}
.chip:hover { background: var(--accent-soft); }
.chip:active { transform: scale(.95); }
.chip:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.chip svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }

@container (max-height: 270px) {
  .root { padding: 10px; }
  .head { padding: 7px 12px; }
  .avatar { width: 26px; height: 26px; font-size: 10px; }
  .foot { min-height: 46px; padding-block: 5px 7px; }
}
@media (prefers-reduced-motion: reduce) {
  .list, .bubble[data-new], .bubble[data-typing], .chip { animation: none; }
  .bubble, .body { transition: none; }
}

Use it for

  • Support, booking and AI chat widgets
  • Hero demos that show a conversation unfolding
  • Quick reply flows where the next answer should feel live

Avoid

  • Replies that arrive instantly, where a fake wait only slows people down
  • Long threads, where every message growing in becomes noise

How it works

A step index and a stage (offer, sent, typing, reply) drive the whole thread, and one timeout per stage moves the script on unless a quick reply chip is clicked first. The reply bubble starts at a fixed typing size; when typing ends a layout effect measures the message laid out at its natural width and sets that as the bubble's width and height, so a CSS transition grows the bubble while the dots fade and the text fades in. Once grown the explicit size is dropped so the bubble sizes itself again.

Accessibility

The thread is a polite log, the typing bubble is labelled as typing and each quick reply is a real button. The script pauses off screen. Reduced motion skips the typing wait and the growth: replies appear at once and only clicks send.

Found on