Skip to content
All Taste components

Animated app UI

Chat with typing indicator

A short conversation that plays out: messages pop in, the other person types, and their reply lands in its place.

incoming · outgoing · attachment · typing dots · reply pop · seen receipt

MI
Meera IyerKestrel Studio · online
Today

Hi! Did the revised quote go out?

Sending it now — I added the two extra pages.

Quote-1042.pdf · 84 KB

Got it. Approved — invoice us whenever you’re ready.

Seen 10:42
"use client";
import { useEffect, useState } from "react";
import { FileText, Send } from "lucide-react";
import { cn } from "@/lib/utils";

/* Add once to globals.css:
@keyframes msg-in { from { opacity: 0; transform: translateY(8px) scale(.9); } }
@keyframes typing-dot { 0%, 60%, 100% { transform: none; opacity: .5; } 30% { transform: translateY(-4px); opacity: 1; } }
*/

type Msg = { from: "me" | "them"; text: string; file?: string };
const script: { msg: Msg; at: number }[] = [
  { msg: { from: "them", text: "Hi! Did the revised quote go out?" }, at: 100 },
  { msg: { from: "me", text: "Sending it now — I added the two extra pages." }, at: 500 },
  { msg: { from: "me", text: "Quote-1042.pdf · 84 KB", file: "Quote-1042.pdf" }, at: 800 },
  { msg: { from: "them", text: "Got it. Approved — invoice us whenever you’re ready." }, at: 2600 },
];
const TYPING_FROM = 1150;

export function ChatTyping() {
  const [shown, setShown] = useState(0);
  const [typing, setTyping] = useState(false);

  // Replays the script once; in a real app, messages come from your data instead.
  useEffect(() => {
    const timers = script.map((s, i) => setTimeout(() => setShown(i + 1), s.at));
    timers.push(setTimeout(() => setTyping(true), TYPING_FROM));
    timers.push(setTimeout(() => setTyping(false), script[script.length - 1].at));
    return () => timers.forEach(clearTimeout);
  }, []);

  return (
    <section aria-label="Conversation with Meera Iyer" className="mx-auto grid max-w-[380px] overflow-hidden rounded-lg border border-border bg-bg font-sans">
      <header className="flex items-center gap-2.5 border-b border-border px-3.5 py-3">
        <span className="relative grid size-8 place-items-center rounded-full bg-primary-soft text-[11px] font-medium text-primary-soft-fg">
          MI
          <span className="absolute bottom-0 right-0 size-2.5 rounded-full border-2 border-bg bg-success" />
        </span>
        <div className="min-w-0 flex-1">
          <p className="text-[15px] font-medium text-fg">Meera Iyer</p>
          <p className="text-xs text-fg-subtle">Kestrel Studio · online</p>
        </div>
      </header>

      <div aria-live="polite" className="grid min-h-[300px] content-end gap-2 bg-surface px-3.5 py-4">
        {script.slice(0, shown).map(({ msg }, i) => (
          <p
            key={i}
            className={cn(
              "flex max-w-[82%] items-center gap-2 rounded-lg px-3 py-2 text-[13.5px] leading-snug animate-[msg-in_.5s_cubic-bezier(.34,1.56,.64,1)_both] motion-reduce:animate-none",
              msg.from === "me"
                ? "origin-bottom-right justify-self-end rounded-br-sm bg-primary text-primary-fg"
                : "origin-bottom-left justify-self-start rounded-bl-sm border border-border bg-bg text-fg",
            )}
          >
            {msg.file && <FileText className="size-4 shrink-0" aria-hidden />}
            {msg.text}
          </p>
        ))}
        {typing && (
          <span aria-label="Meera is typing" className="inline-flex origin-bottom-left gap-1 justify-self-start rounded-lg rounded-bl-sm border border-border bg-bg px-3.5 py-3 animate-pop motion-reduce:animate-none">
            {[0, 1, 2].map((i) => (
              <span key={i} className="size-1.5 rounded-full bg-fg-subtle animate-[typing-dot_.9s_ease-in-out_infinite] motion-reduce:animate-none" style={{ animationDelay: i * 120 + "ms" }} />
            ))}
          </span>
        )}
      </div>

      <form className="flex gap-2 border-t border-border px-3 py-2.5" onSubmit={(e) => e.preventDefault()}>
        <input aria-label="Message" placeholder="Write a message…" className="h-9 min-w-0 flex-1 rounded-full border border-border-strong bg-bg px-3.5 text-sm text-fg outline-none placeholder:text-fg-subtle focus:border-primary focus:ring-2 focus:ring-ring" />
        <button type="submit" aria-label="Send" className="grid size-9 place-items-center rounded-full bg-primary text-primary-fg transition hover:bg-primary-hover active:scale-95">
          <Send className="size-4" aria-hidden />
        </button>
      </form>
    </section>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.