# Chat bubbles

> Four message threads — support with typing indicator, group chat with reactions, file and voice attachments, and delivery receipts — where every new message springs in.

- Collection: Examples
- Tier: free
- Docs: https://geturui.com/docs/chat-bubbles
- Install: `npx shadcn@latest add @geturui/chat-bubbles`
- Dependencies: motion, lucide-react

## Variants

### Support thread

Support conversation with grouped bubbles, quick-reply chips and a typing indicator before each answer.

Install: `npx shadcn@latest add @geturui/chat-bubbles-support-thread`

```tsx
import { SupportThread } from "@/components/examples/chat-bubbles/support-thread";

export function Help() {
  return <SupportThread agentName="Noor" quickReplies={[{ label: "Reset my password", reply: "I've sent a reset link to your email." }]} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| agentName / agentRole | `string` | — | Header identity. |
| messages | `SupportMessage[]` | — | { id, from: "agent" | "user", text, time } initial thread. |
| quickReplies | `QuickReply[]` | — | { label, reply } chips; each is used once. |
| fallbackReply | `string` | — | Agent answer for typed messages. |
| typingDelay | `number` | 1400 | Milliseconds the typing indicator shows. |
| onSend | `(text: string) => void` | — | Called for every user message. |
| className | `string` | — | Extra classes on the root element. |

### Group reactions

Group chat with grouped senders, toggleable reaction pills whose counts roll and an inline reaction picker.

Install: `npx shadcn@latest add @geturui/chat-bubbles-group-reactions`

```tsx
import { GroupChat } from "@/components/examples/chat-bubbles/group-reactions";

export function Channel({ messages }) {
  return <GroupChat channel="launch-week" messages={messages} onReact={(id, r, on) => react(id, r, on)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| channel / members | `string / number` | — | Header label and member count. |
| messages | `GroupMessage[]` | — | { id, author, time, text, reactions?, mine? } per message. |
| onReact | `(messageId, reaction, active) => void` | — | Called when a reaction is added or removed. |
| className | `string` | — | Extra classes on the root element. |

### Attachments

File message with a ring-progress download and a voice note with a seekable waveform and speed control.

Install: `npx shadcn@latest add @geturui/chat-bubbles-attachments`

```tsx
import { AttachmentMessages } from "@/components/examples/chat-bubbles/attachments";

export function Shared() {
  return <AttachmentMessages file={{ name: "brief.pdf", size: "1.1 MB", pages: 6 }} voiceSeconds={21} onDownload={download} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| sender / note | `string` | — | Sender name and the text above the file. |
| file | `{ name: string; size: string; pages?: number }` | — | Attachment details. |
| voiceSeconds | `number` | 34 | Voice note length. |
| downloadMs | `number` | 1600 | Simulated download time; wire onDownload to the real request. |
| onDownload | `() => void` | — | Called when the download starts. |
| className | `string` | — | Extra classes on the root element. |

### Delivery states

Direct-message thread where sent messages animate through sending, sent, delivered and read, with retry on failure.

Install: `npx shadcn@latest add @geturui/chat-bubbles-delivery-states`

```tsx
import { DeliveryStates } from "@/components/examples/chat-bubbles/delivery-states";

export function Dm() {
  return <DeliveryStates contact="Theo Lind" onSend={(text) => api.send(text)} onRetry={(id) => api.retry(id)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| contact | `string` | "Theo Lind" | Who the thread is with. |
| messages | `DeliveryMessage[]` | — | { id, mine, text, time, status? } where status is sending, sent, delivered, read or failed. |
| autoRead | `boolean` | true | Walk new messages all the way to read. |
| onSend / onRetry | `(text: string) => void / (id: string) => void` | — | Send and retry handlers. |
| className | `string` | — | Extra classes on the root element. |

## Accessibility

- Threads are live regions (role=log) so new messages and the typing indicator are announced politely.
- Reactions, playback and retry are real buttons with aria-pressed or descriptive labels; the waveform is a keyboard slider.
- Message entrances and bouncing dots become simple fades with reduced motion.

## Source

### components/examples/chat-bubbles/support-thread.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUp } from "lucide-react";
import { cn } from "@/lib/utils";

export interface SupportMessage {
  id: string;
  from: "agent" | "user";
  text: string;
  time: string;
}

export interface QuickReply {
  label: string;
  /** What the agent answers after a short typing pause. */
  reply: string;
}

export interface SupportThreadProps {
  agentName?: string;
  agentRole?: string;
  messages?: SupportMessage[];
  quickReplies?: QuickReply[];
  /** Agent answer for free-typed messages. */
  fallbackReply?: string;
  /** How long the typing indicator shows, in ms. */
  typingDelay?: number;
  onSend?: (text: string) => void;
  className?: string;
}

const DEFAULT_MESSAGES: SupportMessage[] = [
  { id: "m1", from: "agent", text: "Hi Sam, I'm Noor from the GetUrUI team. I can see your Pro trial ends on Friday.", time: "9:41" },
  { id: "m2", from: "agent", text: "Anything I can help with before then?", time: "9:41" },
  { id: "m3", from: "user", text: "Yes, I want to keep the components my team already installed.", time: "9:43" },
];

const DEFAULT_REPLIES: QuickReply[] = [
  { label: "Upgrade my plan", reply: "Done in a click: open Billing and choose Pro. Everything installed stays exactly where it is." },
  { label: "Download an invoice", reply: "Invoices live under Billing, then History. Each one has a PDF button on the right." },
  { label: "Add a teammate", reply: "Go to Team, then Invite. Seats are added to your next invoice, prorated by the day." },
];

function TypingDots({ reduce }: { reduce: boolean }) {
  return (
    <span className="inline-flex items-center gap-1 px-1 py-1.5" aria-hidden>
      {[0, 1, 2].map((i) => (
        <motion.span
          key={i}
          className="size-1.5 rounded-full bg-ink-subtle"
          animate={reduce ? { opacity: [0.3, 1, 0.3] } : { y: [0, -4, 0], opacity: [0.5, 1, 0.5] }}
          transition={{ duration: 0.9, repeat: Infinity, delay: i * 0.15, ease: "easeInOut" }}
        />
      ))}
    </span>
  );
}

/** Support conversation with grouped bubbles, quick-reply chips and an agent typing indicator before each answer. */
export function SupportThread({
  agentName = "Noor",
  agentRole = "Customer support",
  messages = DEFAULT_MESSAGES,
  quickReplies = DEFAULT_REPLIES,
  fallbackReply = "Thanks, I'm looking into it now and will reply right here.",
  typingDelay = 1400,
  onSend,
  className,
}: SupportThreadProps) {
  const reduce = useReducedMotion() ?? false;
  const [list, setList] = React.useState(messages);
  const [chips, setChips] = React.useState(quickReplies);
  const [typing, setTyping] = React.useState(false);
  const [draft, setDraft] = React.useState("");
  const scroller = React.useRef<HTMLDivElement>(null);
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);
  const seq = React.useRef(0);
  const inputId = React.useId();

  React.useEffect(() => {
    const pending = timers.current;
    return () => pending.forEach(clearTimeout);
  }, []);
  React.useEffect(() => {
    scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: reduce ? "auto" : "smooth" });
  }, [list, typing, reduce]);

  function send(text: string, reply: string) {
    const clean = text.trim();
    if (!clean) return;
    seq.current += 1;
    const n = seq.current;
    setList((l) => [...l, { id: `u${n}`, from: "user", text: clean, time: "Now" }]);
    onSend?.(clean);
    setTyping(true);
    timers.current.push(
      setTimeout(() => {
        setTyping(false);
        setList((l) => [...l, { id: `a${n}`, from: "agent", text: reply, time: "Now" }]);
      }, typingDelay),
    );
  }

  return (
    <section aria-label={`Chat with ${agentName}`} className={cn("flex w-full max-w-[400px] flex-col overflow-hidden rounded-xl border border-border bg-surface-raised shadow-sm", className)}>
      <header className="flex items-center gap-3 border-b border-border px-4 py-3">
        <span className="relative grid size-9 place-items-center rounded-full bg-ink font-display text-[13px] font-semibold text-bg" aria-hidden>
          {agentName.slice(0, 1)}
          <span className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-surface-raised bg-success" />
        </span>
        <div className="min-w-0 flex-1">
          <p className="text-[14px] font-semibold text-ink">{agentName}</p>
          <p className="text-xs text-ink-subtle">
            {agentRole} · <span className="text-success">Online</span>
          </p>
        </div>
        <span className="rounded-full bg-surface-sunken px-2 py-0.5 text-[11px] text-ink-muted">Replies in ~2 min</span>
      </header>

      <div ref={scroller} role="log" aria-live="polite" className="flex h-[280px] flex-col gap-1 overflow-y-auto px-4 py-4">
        {list.map((m, i) => {
          const mine = m.from === "user";
          const lastOfGroup = list[i + 1]?.from !== m.from;
          return (
            <motion.div
              key={m.id}
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.96 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              transition={{ type: "spring", stiffness: 380, damping: 30 }}
              style={{ originX: mine ? 1 : 0 }}
              className={cn("flex flex-col", mine ? "items-end" : "items-start", lastOfGroup && "mb-2")}
            >
              <p
                className={cn(
                  "max-w-[82%] px-3.5 py-2 text-[13.5px] leading-5",
                  mine ? "rounded-lg bg-ink text-bg" : "rounded-lg border border-border bg-surface text-ink",
                  lastOfGroup && (mine ? "rounded-br-xs" : "rounded-bl-xs"),
                )}
              >
                <span className="sr-only">{mine ? "You" : agentName}: </span>
                {m.text}
              </p>
              {lastOfGroup && <span className="mt-1 px-1 text-[11px] text-ink-subtle">{m.time}</span>}
            </motion.div>
          );
        })}
        <AnimatePresence>
          {typing && (
            <motion.div
              key="typing"
              initial={{ opacity: 0, y: reduce ? 0 : 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, scale: reduce ? 1 : 0.9 }}
              className="w-fit rounded-lg rounded-bl-xs border border-border bg-surface px-3 py-1.5"
            >
              <TypingDots reduce={reduce} />
              <span className="sr-only">{agentName} is typing</span>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <div className="border-t border-border p-3">
        {chips.length > 0 && (
          <div className="mb-2.5 flex flex-wrap gap-1.5">
            <AnimatePresence initial={false}>
              {chips.map((c) => (
                <motion.button
                  key={c.label}
                  type="button"
                  layout={!reduce}
                  exit={{ opacity: 0, scale: 0.8 }}
                  whileTap={reduce ? undefined : { scale: 0.95 }}
                  disabled={typing}
                  onClick={() => {
                    setChips((all) => all.filter((x) => x.label !== c.label));
                    send(c.label, c.reply);
                  }}
                  className="rounded-full border border-border-strong px-3 py-1 text-[12.5px] font-medium text-ink outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)] disabled:opacity-50"
                >
                  {c.label}
                </motion.button>
              ))}
            </AnimatePresence>
          </div>
        )}
        <form
          onSubmit={(e) => {
            e.preventDefault();
            send(draft, fallbackReply);
            setDraft("");
          }}
          className="flex items-center gap-2 rounded-full border border-border-strong bg-surface py-1 pl-4 pr-1 focus-within:shadow-[var(--focus-ring)]"
        >
          <label htmlFor={inputId} className="sr-only">
            Message {agentName}
          </label>
          <input
            id={inputId}
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            placeholder={`Message ${agentName}`}
            autoComplete="off"
            className="min-w-0 flex-1 bg-transparent text-[13.5px] text-ink outline-none placeholder:text-ink-subtle"
          />
          <motion.button
            type="submit"
            aria-label="Send message"
            disabled={!draft.trim() || typing}
            whileTap={reduce ? undefined : { scale: 0.9 }}
            className="grid size-8 shrink-0 place-items-center rounded-full bg-volt text-on-volt outline-none transition-colors disabled:bg-surface-sunken disabled:text-ink-subtle"
          >
            <ArrowUp aria-hidden className="size-4" strokeWidth={2.5} />
          </motion.button>
        </form>
      </div>
    </section>
  );
}

```

### components/examples/chat-bubbles/group-reactions.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Heart, Laugh, SmilePlus, Sparkles, ThumbsUp, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";

export type ReactionKey = "agree" | "love" | "brilliant" | "funny";

export interface GroupMessage {
  id: string;
  author: string;
  time: string;
  text: string;
  /** Count from other people per reaction. */
  reactions?: Partial<Record<ReactionKey, number>>;
  /** Reactions the current user has added. */
  mine?: ReactionKey[];
}

export interface GroupChatProps {
  channel?: string;
  members?: number;
  messages?: GroupMessage[];
  onReact?: (messageId: string, reaction: ReactionKey, active: boolean) => void;
  className?: string;
}

const REACTIONS: { key: ReactionKey; label: string; Icon: LucideIcon }[] = [
  { key: "agree", label: "Agree", Icon: ThumbsUp },
  { key: "love", label: "Love", Icon: Heart },
  { key: "brilliant", label: "Brilliant", Icon: Sparkles },
  { key: "funny", label: "Funny", Icon: Laugh },
];

const DEFAULT_MESSAGES: GroupMessage[] = [
  { id: "g1", author: "Priya Nair", time: "10:02", text: "Launch checklist is green. Docs, changelog and the pricing page are live on staging.", reactions: { agree: 3, love: 1 } },
  { id: "g2", author: "Priya Nair", time: "10:02", text: "Can someone give the hero animation one last look on a slow phone?" },
  { id: "g3", author: "Jonah Reyes", time: "10:05", text: "Checked on a four-year-old Android. Smooth, and it falls back to a fade with reduced motion on.", reactions: { brilliant: 2 }, mine: ["agree"] },
  { id: "g4", author: "Theo Lind", time: "10:07", text: "Then we ship at 11. I'll post in the announcements channel.", reactions: { love: 4, funny: 1 } },
];

const TONES = ["bg-ink text-bg", "bg-surface-sunken text-ink border border-border", "bg-ember-soft text-ember-ink"];
const initialsOf = (n: string) => n.split(/\s+/).map((p) => p[0]).slice(0, 2).join("").toUpperCase();
const spring = { type: "spring", stiffness: 420, damping: 30 } as const;

/** Group chat with grouped senders, toggleable reaction pills whose counts roll, and an inline reaction picker per message. */
export function GroupChat({ channel = "launch-week", members = 8, messages = DEFAULT_MESSAGES, onReact, className }: GroupChatProps) {
  const reduce = useReducedMotion() ?? false;
  const [mine, setMine] = React.useState<Record<string, ReactionKey[]>>(() => Object.fromEntries(messages.map((m) => [m.id, m.mine ?? []])));
  const [picker, setPicker] = React.useState<string | null>(null);
  const authors = Array.from(new Set(messages.map((m) => m.author)));

  function toggle(messageId: string, key: ReactionKey) {
    const has = mine[messageId]?.includes(key) ?? false;
    setMine((s) => ({ ...s, [messageId]: has ? s[messageId].filter((k) => k !== key) : [...(s[messageId] ?? []), key] }));
    onReact?.(messageId, key, !has);
  }

  return (
    <section aria-label={`#${channel}`} className={cn("w-full max-w-[460px] rounded-xl border border-border bg-surface-raised shadow-sm", className)}>
      <header className="flex items-center justify-between border-b border-border px-4 py-3">
        <p className="font-display text-[15px] font-semibold text-ink">
          <span className="text-ink-subtle">#</span>
          {channel}
        </p>
        <p className="text-xs text-ink-subtle">{members} members</p>
      </header>
      <ol className="flex flex-col px-2 py-3">
        {messages.map((m, i) => {
          const first = messages[i - 1]?.author !== m.author;
          const active = mine[m.id] ?? [];
          const shown = REACTIONS.filter((r) => (m.reactions?.[r.key] ?? 0) + (active.includes(r.key) ? 1 : 0) > 0);
          const tone = TONES[authors.indexOf(m.author) % TONES.length];
          return (
            <li
              key={m.id}
              onKeyDown={(e) => {
                if (e.key !== "Escape" || picker !== m.id) return;
                setPicker(null);
                e.currentTarget.querySelector<HTMLButtonElement>("[data-add-reaction]")?.focus();
              }}
              className={cn("group relative flex gap-3 rounded-lg px-2 py-1 transition-colors hover:bg-surface-hover", first && i > 0 && "mt-2")}
            >
              <span aria-hidden className={cn("mt-0.5 grid size-8 shrink-0 place-items-center rounded-full font-display text-[11px] font-semibold", first ? tone : "invisible")}>
                {initialsOf(m.author)}
              </span>
              <div className="min-w-0 flex-1">
                {first && (
                  <p className="flex items-baseline gap-2">
                    <span className="text-[13.5px] font-semibold text-ink">{m.author}</span>
                    <span className="text-[11px] text-ink-subtle">{m.time}</span>
                  </p>
                )}
                <p className="text-[13.5px] leading-5 text-ink-muted">{m.text}</p>
                {shown.length > 0 && (
                  <div className="mt-1.5 flex flex-wrap gap-1">
                    {shown.map(({ key, label, Icon }) => {
                      const on = active.includes(key);
                      const n = (m.reactions?.[key] ?? 0) + (on ? 1 : 0);
                      return (
                        <motion.button
                          key={key}
                          type="button"
                          layout={!reduce}
                          initial={reduce ? false : { scale: 0.6, opacity: 0 }}
                          animate={{ scale: 1, opacity: 1 }}
                          whileTap={reduce ? undefined : { scale: 0.9 }}
                          transition={spring}
                          aria-pressed={on}
                          aria-label={`${label}, ${n} ${n === 1 ? "reaction" : "reactions"}`}
                          onClick={() => toggle(m.id, key)}
                          className={cn(
                            "inline-flex h-6 items-center gap-1 rounded-full border px-2 text-[11.5px] font-medium outline-none transition-colors focus-visible:shadow-[var(--focus-ring)]",
                            on ? "border-volt-ink/40 bg-volt-soft text-ink" : "border-border bg-surface text-ink-muted hover:border-border-strong",
                          )}
                        >
                          <Icon aria-hidden className={cn("size-3", on && "text-volt-ink")} strokeWidth={2.4} />
                          <span className="relative inline-flex overflow-hidden tabular-nums">
                            <AnimatePresence mode="popLayout" initial={false}>
                              <motion.span key={n} initial={{ y: reduce ? 0 : 10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: reduce ? 0 : -10, opacity: 0 }} transition={spring}>
                                {n}
                              </motion.span>
                            </AnimatePresence>
                          </span>
                        </motion.button>
                      );
                    })}
                  </div>
                )}
              </div>

              <div className="absolute -top-3 right-2 z-10 flex items-center">
                <AnimatePresence>
                  {picker === m.id && (
                    <motion.div
                      role="group"
                      aria-label="Pick a reaction"
                      initial={{ opacity: 0, scale: reduce ? 1 : 0.85, x: reduce ? 0 : 8 }}
                      animate={{ opacity: 1, scale: 1, x: 0 }}
                      exit={{ opacity: 0, scale: reduce ? 1 : 0.85 }}
                      transition={spring}
                      className="mr-1 flex origin-right gap-0.5 rounded-full border border-border bg-surface-raised p-1 shadow-md"
                    >
                      {REACTIONS.map(({ key, label, Icon }, j) => (
                        <button
                          key={key}
                          type="button"
                          autoFocus={j === 0}
                          aria-label={label}
                          aria-pressed={active.includes(key)}
                          onClick={() => {
                            toggle(m.id, key);
                            setPicker(null);
                          }}
                          className="grid size-7 place-items-center rounded-full text-ink-muted outline-none transition-colors hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)] aria-pressed:text-volt-ink"
                        >
                          <Icon aria-hidden className="size-3.5" />
                        </button>
                      ))}
                    </motion.div>
                  )}
                </AnimatePresence>
                <button
                  type="button"
                  data-add-reaction
                  aria-label="Add reaction"
                  aria-expanded={picker === m.id}
                  onClick={() => setPicker((p) => (p === m.id ? null : m.id))}
                  className={cn(
                    "grid size-7 place-items-center rounded-full border border-border bg-surface-raised text-ink-muted shadow-sm outline-none transition-opacity hover:text-ink focus-visible:opacity-100 focus-visible:shadow-[var(--focus-ring)]",
                    picker === m.id ? "opacity-100" : "opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100",
                  )}
                >
                  <SmilePlus aria-hidden className="size-3.5" />
                </button>
              </div>
            </li>
          );
        })}
      </ol>
    </section>
  );
}

```

### components/examples/chat-bubbles/attachments.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowDown, Check, FileText, Pause, Play } from "lucide-react";
import { cn } from "@/lib/utils";

export interface AttachmentMessagesProps {
  sender?: string;
  note?: string;
  file?: { name: string; size: string; pages?: number };
  /** Voice note length in seconds. */
  voiceSeconds?: number;
  /** Simulated download time, in ms. */
  downloadMs?: number;
  onDownload?: () => void;
  className?: string;
}

const BARS = Array.from({ length: 42 }, (_, i) => Math.round(5 + Math.abs(Math.sin(i * 1.7) * 0.55 + Math.sin(i * 0.43) * 0.45) * 20));
const SPEEDS = [1, 1.5, 2] as const;
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;

/** Chat messages carrying a file with a ring-progress download and a voice note with a seekable waveform, play/pause morph and speed control. */
export function AttachmentMessages({
  sender = "Priya",
  note = "Brand audit is ready. Pages 4 to 9 cover the new type scale.",
  file = { name: "Q3-brand-audit.pdf", size: "2.4 MB", pages: 18 },
  voiceSeconds = 34,
  downloadMs = 1600,
  onDownload,
  className,
}: AttachmentMessagesProps) {
  const reduce = useReducedMotion() ?? false;
  const [dl, setDl] = React.useState<"idle" | "loading" | "done">("idle");
  const [playing, setPlaying] = React.useState(false);
  const [pos, setPos] = React.useState(0);
  const [speed, setSpeed] = React.useState<(typeof SPEEDS)[number]>(1);
  const dlTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  const posRef = React.useRef(0);

  function seek(seconds: number) {
    posRef.current = seconds;
    setPos(seconds);
  }

  React.useEffect(() => () => {
    if (dlTimer.current) clearTimeout(dlTimer.current);
  }, []);

  React.useEffect(() => {
    if (!playing) return;
    const t = setInterval(() => {
      const next = posRef.current + 0.1 * speed;
      const ended = next >= voiceSeconds;
      posRef.current = ended ? 0 : next;
      setPos(posRef.current);
      if (ended) setPlaying(false);
    }, 100);
    return () => clearInterval(t);
  }, [playing, speed, voiceSeconds]);

  function download() {
    if (dl !== "idle") return;
    setDl("loading");
    onDownload?.();
    dlTimer.current = setTimeout(() => setDl("done"), downloadMs);
  }

  function seekFromPointer(e: React.PointerEvent<HTMLDivElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    seek(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * voiceSeconds);
  }

  const played = pos / voiceSeconds;

  return (
    <div role="log" aria-label={`Conversation with ${sender}`} className={cn("flex w-full max-w-[400px] flex-col gap-2 rounded-xl border border-border bg-surface-raised p-4 shadow-sm", className)}>
      <p className="max-w-[85%] self-start rounded-lg rounded-bl-xs border border-border bg-surface px-3.5 py-2 text-[13.5px] leading-5 text-ink">{note}</p>

      <div className="flex w-[85%] items-center gap-3 self-start rounded-lg rounded-tl-xs border border-border bg-surface p-2.5">
        <span aria-hidden className="relative grid h-11 w-9 shrink-0 place-items-center rounded-xs bg-surface-sunken text-ink-muted">
          <FileText className="size-4" />
          <span className="absolute -bottom-1 -right-1 rounded-[4px] bg-ink px-1 font-mono text-[8px] font-semibold text-bg">PDF</span>
        </span>
        <div className="min-w-0 flex-1">
          <p className="truncate text-[13px] font-medium text-ink">{file.name}</p>
          <p className="text-[11.5px] text-ink-subtle">
            {file.size}
            {file.pages ? ` · ${file.pages} pages` : ""}
            {dl === "done" && <span className="text-ink-muted"> · Saved</span>}
          </p>
        </div>
        <button
          type="button"
          onClick={download}
          aria-label={dl === "done" ? `${file.name} downloaded` : dl === "loading" ? `Downloading ${file.name}` : `Download ${file.name}`}
          className="relative grid size-9 shrink-0 place-items-center rounded-full border border-border-strong text-ink outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)]"
        >
          {dl === "loading" && (
            <svg viewBox="0 0 36 36" className="absolute -inset-px -rotate-90" aria-hidden>
              <motion.circle cx="18" cy="18" r="16.5" fill="none" strokeWidth="2.5" strokeLinecap="round" className="stroke-volt-ink" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: downloadMs / 1000, ease: "easeInOut" }} />
            </svg>
          )}
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span key={dl === "done" ? "d" : "a"} initial={{ scale: 0.5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.5, opacity: 0 }} transition={{ type: "spring", stiffness: 500, damping: 26 }}>
              {dl === "done" ? <Check aria-hidden className="size-4 text-volt-ink" strokeWidth={2.75} /> : <ArrowDown aria-hidden className="size-4" />}
            </motion.span>
          </AnimatePresence>
        </button>
      </div>

      <span className="mb-1 self-start px-1 text-[11px] text-ink-subtle">{sender} · 4:12</span>

      <div className="flex w-[88%] items-center gap-2.5 self-end rounded-lg rounded-br-xs bg-ink py-2 pl-2 pr-3 text-bg">
        <motion.button
          type="button"
          aria-label={playing ? "Pause voice note" : "Play voice note"}
          onClick={() => setPlaying((p) => !p)}
          whileTap={reduce ? undefined : { scale: 0.9 }}
          className="grid size-9 shrink-0 place-items-center rounded-full bg-bg text-ink outline-none focus-visible:shadow-[var(--focus-ring)]"
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span key={playing ? "p" : "s"} initial={{ scale: 0.4, opacity: 0, rotate: reduce ? 0 : -45 }} animate={{ scale: 1, opacity: 1, rotate: 0 }} exit={{ scale: 0.4, opacity: 0 }} transition={{ type: "spring", stiffness: 520, damping: 28 }}>
              {playing ? <Pause aria-hidden className="size-4 fill-current" /> : <Play aria-hidden className="ml-0.5 size-4 fill-current" />}
            </motion.span>
          </AnimatePresence>
        </motion.button>
        <div
          role="slider"
          tabIndex={0}
          aria-label="Voice note position"
          aria-valuemin={0}
          aria-valuemax={voiceSeconds}
          aria-valuenow={Math.round(pos)}
          aria-valuetext={`${clock(pos)} of ${clock(voiceSeconds)}`}
          onPointerDown={seekFromPointer}
          onKeyDown={(e) => {
            const step = e.key === "ArrowRight" ? 5 : e.key === "ArrowLeft" ? -5 : 0;
            if (!step) return;
            e.preventDefault();
            seek(Math.max(0, Math.min(voiceSeconds - 0.1, posRef.current + step)));
          }}
          className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-[2px] rounded-xs outline-none focus-visible:shadow-[var(--focus-ring)]"
        >
          {BARS.map((h, i) => (
            <span
              key={i}
              aria-hidden
              style={{ height: h }}
              className={cn("w-[3px] flex-1 rounded-full transition-colors duration-150", i / BARS.length < played ? "bg-bg" : "bg-bg/30")}
            />
          ))}
        </div>
        <span className="w-9 shrink-0 text-right font-mono text-[11px] tabular-nums text-bg/80">{clock(playing || pos > 0 ? pos : voiceSeconds)}</span>
        <button
          type="button"
          aria-label={`Playback speed ${speed}x`}
          onClick={() => setSpeed((s) => SPEEDS[(SPEEDS.indexOf(s) + 1) % SPEEDS.length])}
          className="h-6 w-10 shrink-0 rounded-full bg-bg/15 font-mono text-[11px] font-semibold text-bg outline-none transition-colors hover:bg-bg/25 focus-visible:shadow-[var(--focus-ring)]"
        >
          {speed}x
        </button>
      </div>
      <span className="self-end px-1 text-[11px] text-ink-subtle">You · 4:15</span>
      <span role="status" className="sr-only">
        {dl === "done" ? `${file.name} downloaded` : ""}
      </span>
    </div>
  );
}

```

### components/examples/chat-bubbles/delivery-states.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { AlertCircle, ArrowUp, Check, CheckCheck, Clock3, RotateCw } from "lucide-react";
import { cn } from "@/lib/utils";

export type DeliveryStatus = "sending" | "sent" | "delivered" | "read" | "failed";

export interface DeliveryMessage {
  id: string;
  mine: boolean;
  text: string;
  time: string;
  status?: DeliveryStatus;
}

export interface DeliveryStatesProps {
  contact?: string;
  messages?: DeliveryMessage[];
  /** Walk new messages through sent, delivered and read. */
  autoRead?: boolean;
  onSend?: (text: string) => void;
  onRetry?: (id: string) => void;
  className?: string;
}

const DEFAULT_MESSAGES: DeliveryMessage[] = [
  { id: "d1", mine: false, text: "Are the new invoice templates ready for review?", time: "9:30" },
  { id: "d2", mine: true, text: "Yes, both versions are in the shared folder.", time: "9:31", status: "read" },
  { id: "d3", mine: true, text: "The second one has the tax breakdown you asked for.", time: "9:32", status: "delivered" },
  { id: "d4", mine: true, text: "Sending the PDF export now.", time: "9:33", status: "failed" },
];

const LABEL: Record<DeliveryStatus, string> = { sending: "Sending", sent: "Sent", delivered: "Delivered", read: "Read", failed: "Not delivered" };
const STEPS: [DeliveryStatus, number][] = [["sent", 700], ["delivered", 1500], ["read", 2800]];

function StatusIcon({ status }: { status: DeliveryStatus }) {
  const cls = "size-3.5";
  if (status === "sending") return <Clock3 aria-hidden className={cls} />;
  if (status === "sent") return <Check aria-hidden className={cls} strokeWidth={2.5} />;
  if (status === "failed") return <AlertCircle aria-hidden className={cn(cls, "text-danger")} />;
  return <CheckCheck aria-hidden className={cn(cls, status === "read" && "text-volt-ink")} strokeWidth={2.5} />;
}

/** Direct-message thread where each sent message animates through sending, sent, delivered and read, with a failed state you can retry. */
export function DeliveryStates({ contact = "Theo Lind", messages = DEFAULT_MESSAGES, autoRead = true, onSend, onRetry, className }: DeliveryStatesProps) {
  const reduce = useReducedMotion() ?? false;
  const [list, setList] = React.useState(messages);
  const [draft, setDraft] = React.useState("");
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);
  const seq = React.useRef(0);
  const inputId = React.useId();

  React.useEffect(() => {
    const pending = timers.current;
    return () => pending.forEach(clearTimeout);
  }, []);

  function setStatus(id: string, status: DeliveryStatus) {
    setList((l) => l.map((m) => (m.id === id ? { ...m, status } : m)));
  }

  function walk(id: string, fail = false) {
    setStatus(id, "sending");
    if (fail) return;
    STEPS.filter(([s]) => autoRead || s !== "read").forEach(([s, ms]) => timers.current.push(setTimeout(() => setStatus(id, s), ms)));
  }

  function send(e: React.FormEvent) {
    e.preventDefault();
    const text = draft.trim();
    if (!text) return;
    seq.current += 1;
    const id = `new-${seq.current}`;
    setList((l) => [...l, { id, mine: true, text, time: "Now", status: "sending" }]);
    setDraft("");
    onSend?.(text);
    walk(id);
  }

  const lastRead = list.reduce((acc, m, i) => (m.mine && m.status === "read" ? i : acc), -1);
  const lastMine = list.reduce((acc, m, i) => (m.mine ? i : acc), -1);

  return (
    <section aria-label={`Messages with ${contact}`} className={cn("flex w-full max-w-[400px] flex-col rounded-xl border border-border bg-surface-raised shadow-sm", className)}>
      <header className="flex items-center gap-3 border-b border-border px-4 py-3">
        <span aria-hidden className="grid size-8 place-items-center rounded-full bg-surface-sunken font-display text-[12px] font-semibold text-ink">
          {contact.split(" ").map((p) => p[0]).join("")}
        </span>
        <p className="text-[14px] font-semibold text-ink">{contact}</p>
      </header>

      <ol aria-live="polite" className="flex max-h-[320px] min-h-[240px] flex-col gap-1.5 overflow-y-auto px-4 py-4">
        <AnimatePresence initial={false}>
          {list.map((m, i) => {
            const status = m.status;
            return (
              <motion.li
                key={m.id}
                layout={reduce ? false : "position"}
                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.95 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                transition={{ type: "spring", stiffness: 380, damping: 30 }}
                style={{ originX: m.mine ? 1 : 0 }}
                className={cn("flex flex-col", m.mine ? "items-end" : "items-start")}
              >
                <p
                  className={cn(
                    "max-w-[82%] rounded-lg px-3.5 py-2 text-[13.5px] leading-5 transition-opacity",
                    m.mine ? "rounded-br-xs bg-ink text-bg" : "rounded-bl-xs border border-border bg-surface text-ink",
                    status === "sending" && "opacity-70",
                    status === "failed" && "opacity-80",
                  )}
                >
                  {m.text}
                </p>
                {m.mine && status && (
                  <div className="mt-1 flex h-5 items-center gap-1.5 px-1 text-[11px] text-ink-subtle">
                    <span>{m.time}</span>
                    <AnimatePresence mode="popLayout" initial={false}>
                      <motion.span
                        key={status}
                        initial={{ opacity: 0, scale: reduce ? 1 : 0.5, filter: "blur(4px)" }}
                        animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                        exit={{ opacity: 0, scale: reduce ? 1 : 0.5 }}
                        transition={{ type: "spring", stiffness: 520, damping: 28 }}
                        className={cn("inline-flex items-center gap-1", status === "failed" && "text-danger")}
                      >
                        <StatusIcon status={status} />
                        {status === "failed" || i === lastRead || i === lastMine ? LABEL[status] : <span className="sr-only">{LABEL[status]}</span>}
                      </motion.span>
                    </AnimatePresence>
                    {status === "failed" && (
                      <button
                        type="button"
                        onClick={() => {
                          onRetry?.(m.id);
                          walk(m.id);
                        }}
                        className="inline-flex items-center gap-1 rounded-full px-1.5 font-medium text-ink outline-none underline-offset-2 hover:underline focus-visible:shadow-[var(--focus-ring)]"
                      >
                        <RotateCw aria-hidden className="size-3" />
                        Retry
                      </button>
                    )}
                  </div>
                )}
              </motion.li>
            );
          })}
        </AnimatePresence>
      </ol>

      <form onSubmit={send} className="flex items-center gap-2 border-t border-border p-3">
        <label htmlFor={inputId} className="sr-only">
          Message {contact}
        </label>
        <input
          id={inputId}
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          placeholder="Write a message"
          autoComplete="off"
          className="h-10 min-w-0 flex-1 rounded-full border border-border-strong bg-surface px-4 text-[13.5px] text-ink outline-none placeholder:text-ink-subtle focus-visible:shadow-[var(--focus-ring)]"
        />
        <motion.button
          type="submit"
          aria-label="Send message"
          disabled={!draft.trim()}
          whileTap={reduce ? undefined : { scale: 0.9 }}
          className="grid size-10 shrink-0 place-items-center rounded-full bg-volt text-on-volt outline-none transition-colors focus-visible:shadow-[var(--focus-ring)] disabled:bg-surface-sunken disabled:text-ink-subtle"
        >
          <ArrowUp aria-hidden className="size-4" strokeWidth={2.5} />
        </motion.button>
      </form>
    </section>
  );
}

```
