Web InnoventixFreeCode

Chat Typing

Original · free

typing indicator and delivery states

byWeb InnoventixReact + Tailwind
chattyping
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/chat-typing.json
chat-typing.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type ChangeEvent,
  type FormEvent,
  type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Status = "sending" | "sent" | "delivered" | "read";

type Message = {
  id: string;
  side: "in" | "out";
  text: string;
  time: string;
  status?: Status;
};

const STATUS_ORDER: Status[] = ["sending", "sent", "delivered", "read"];

const STATUS_META: Record<Status, { label: string; hint: string }> = {
  sending: {
    label: "Sending",
    hint: "Held on this device. The clock stays until the server writes the message and hands back an ID.",
  },
  sent: {
    label: "Sent",
    hint: "One tick. The server has it and it survives a refresh — but no device has picked it up yet.",
  },
  delivered: {
    label: "Delivered",
    hint: "Two ticks. At least one of Priya's devices acknowledged receipt, awake or in the background.",
  },
  read: {
    label: "Read",
    hint: "Two indigo ticks. The thread was open and focused on her side when the message arrived.",
  },
};

const SEED: Message[] = [
  {
    id: "s1",
    side: "in",
    text: "Morning — I pulled the retry logs for the three webhooks you flagged on Tuesday.",
    time: "09:14",
  },
  {
    id: "s2",
    side: "out",
    text: "Thanks. Those are the only events from the 02:00 deploy that never landed on our side.",
    time: "09:15",
    status: "read",
  },
  {
    id: "s3",
    side: "in",
    text: "Found all three. Your endpoint answered 504 at 02:11, so our queue parked them instead of dropping them. Nothing is lost.",
    time: "09:16",
  },
];

const REPLIES: string[] = [
  "Re-queuing those three now — they should land inside a minute.",
  "Checked the retry: your endpoint came back in 240 ms, so we're clear on that path.",
  "I've widened the retry window to 24 hours on your account so a cold start won't park events again.",
  "Sending the event IDs over so you can reconcile them against your own log.",
  "That deploy window is the only gap I can see — everything since 02:30 delivered first try.",
];

function clock(): string {
  const d = new Date();
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}

function Ticks({ status }: { status: Status }) {
  if (status === "sending") {
    return (
      <svg
        key="sending"
        viewBox="0 0 14 14"
        className="ct-tick h-3.5 w-3.5 shrink-0"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        aria-hidden="true"
      >
        <circle cx="7" cy="7" r="5.4" />
        <path d="M7 4.2V7l1.9 1.4" />
      </svg>
    );
  }

  if (status === "sent") {
    return (
      <svg
        key="sent"
        viewBox="0 0 18 13"
        className="ct-tick h-3.5 w-[18px] shrink-0"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
      >
        <path d="M4.2 7.4 7.4 10.6 14.4 2.6" />
      </svg>
    );
  }

  return (
    <svg
      key={status}
      viewBox="0 0 18 13"
      className="ct-tick h-3.5 w-[18px] shrink-0"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.7"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M1.4 7.4 4.4 10.6 11.2 2.6" />
      <path d="M6.9 7.4 9.9 10.6 16.7 2.6" />
    </svg>
  );
}

export default function ChatTyping() {
  const prefersReduced = useReducedMotion();
  const reduce = prefersReduced ?? false;

  const [messages, setMessages] = useState<Message[]>(SEED);
  const [typing, setTyping] = useState<boolean>(false);
  const [draft, setDraft] = useState<string>("");
  const [autoReply, setAutoReply] = useState<boolean>(true);

  const baseId = useId();
  const fieldId = `${baseId}-field`;
  const switchLabelId = `${baseId}-switch`;

  const timers = useRef<number[]>([]);
  const seq = useRef<number>(0);
  const replyIndex = useRef<number>(0);
  const logRef = useRef<HTMLDivElement | null>(null);
  const fieldRef = useRef<HTMLTextAreaElement | null>(null);

  const clearTimers = useCallback(() => {
    timers.current.forEach((t) => window.clearTimeout(t));
    timers.current = [];
  }, []);

  const after = useCallback((ms: number, fn: () => void) => {
    timers.current.push(window.setTimeout(fn, ms));
  }, []);

  useEffect(() => clearTimers, [clearTimers]);

  useEffect(() => {
    const el = logRef.current;
    if (!el) return;
    el.scrollTo({ top: el.scrollHeight, behavior: reduce ? "auto" : "smooth" });
  }, [messages, typing, reduce]);

  const setStatus = useCallback((id: string, status: Status) => {
    setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, status } : m)));
  }, []);

  const send = useCallback(
    (raw: string) => {
      const text = raw.trim();
      if (!text) return;

      seq.current += 1;
      const id = `m${seq.current}`;

      setMessages((prev) => [
        ...prev,
        { id, side: "out", text, time: clock(), status: "sending" },
      ]);
      setDraft("");
      if (fieldRef.current) fieldRef.current.style.height = "auto";

      after(560, () => setStatus(id, "sent"));
      after(1240, () => setStatus(id, "delivered"));

      if (!autoReply) return;

      after(1900, () => setTyping(true));
      after(3900, () => {
        setTyping(false);
        setStatus(id, "read");
        seq.current += 1;
        const reply = REPLIES[replyIndex.current % REPLIES.length];
        replyIndex.current += 1;
        setMessages((prev) => [
          ...prev,
          { id: `r${seq.current}`, side: "in", text: reply, time: clock() },
        ]);
      });
    },
    [after, autoReply, setStatus],
  );

  const onSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    send(draft);
  };

  const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
      e.preventDefault();
      send(draft);
    }
  };

  const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
    setDraft(e.target.value.slice(0, 280));
    const el = e.target;
    el.style.height = "auto";
    el.style.height = `${Math.min(el.scrollHeight, 112)}px`;
  };

  const reset = () => {
    clearTimers();
    setTyping(false);
    setDraft("");
    setMessages(SEED);
    replyIndex.current = 0;
    if (fieldRef.current) fieldRef.current.style.height = "auto";
  };

  const lastOut = [...messages].reverse().find((m) => m.side === "out");
  const activeStatus: Status = lastOut?.status ?? "read";

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes ctTypingDot {
          0%, 70%, 100% { transform: translateY(0); opacity: 0.4; }
          35% { transform: translateY(-4px); opacity: 1; }
        }
        @keyframes ctPresencePing {
          0% { transform: scale(1); opacity: 0.7; }
          100% { transform: scale(2.2); opacity: 0; }
        }
        @keyframes ctTickIn {
          from { transform: scale(0.55); opacity: 0; }
          to { transform: scale(1); opacity: 1; }
        }
        .ct-dot { animation: ctTypingDot 1.15s ease-in-out infinite; }
        .ct-dot:nth-child(2) { animation-delay: 0.16s; }
        .ct-dot:nth-child(3) { animation-delay: 0.32s; }
        .ct-ping { animation: ctPresencePing 1.8s cubic-bezier(0, 0, 0.2, 1) infinite; }
        .ct-tick { animation: ctTickIn 0.24s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .ct-dot, .ct-ping, .ct-tick { animation: none !important; }
          .ct-dot { opacity: 0.75; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <header className="mx-auto max-w-2xl text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Chat / Delivery
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Typing indicator and delivery states
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Send a message and watch it walk the full receipt chain — queued, stored, handed to a device,
            then read. The indicator only runs while the other side is actually composing.
          </p>
        </header>

        <div className="mt-12 grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
          <div className="flex h-[34rem] flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
            <div className="flex items-center gap-3 border-b border-slate-200 px-5 py-4 dark:border-slate-800">
              <div className="relative">
                <span
                  aria-hidden="true"
                  className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-semibold text-white"
                >
                  PR
                </span>
                <span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3">
                  <span className="ct-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400" />
                  <span className="relative inline-flex h-3 w-3 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-900" />
                </span>
              </div>
              <div className="min-w-0">
                <p className="truncate text-sm font-semibold text-slate-900 dark:text-white">
                  Priya Raman
                </p>
                <p className="truncate text-xs text-slate-500 dark:text-slate-400">
                  Support lead · online · median reply 2 min
                </p>
              </div>
              <button
                type="button"
                onClick={reset}
                className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
              >
                <svg
                  viewBox="0 0 16 16"
                  className="h-3.5 w-3.5"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.5"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  aria-hidden="true"
                >
                  <path d="M13.4 6.6A5.6 5.6 0 1 0 13.9 10" />
                  <path d="M13.8 2.6v4h-4" />
                </svg>
                Reset thread
              </button>
            </div>

            <div
              ref={logRef}
              role="log"
              tabIndex={0}
              aria-label="Conversation with Priya Raman"
              className="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
            >
              {messages.map((m) => {
                const out = m.side === "out";
                return (
                  <motion.div
                    key={m.id}
                    initial={reduce ? false : { opacity: 0, y: 10, scale: 0.97 }}
                    animate={{ opacity: 1, y: 0, scale: 1 }}
                    transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
                    className={`flex ${out ? "justify-end" : "justify-start"}`}
                  >
                    <div
                      className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed sm:max-w-[78%] ${
                        out
                          ? "rounded-br-md bg-indigo-600 text-white dark:bg-indigo-500"
                          : "rounded-bl-md border border-slate-200 bg-slate-50 text-slate-800 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
                      }`}
                    >
                      <p>{m.text}</p>
                      <p
                        className={`mt-1 flex items-center justify-end gap-1.5 text-[11px] tabular-nums ${
                          out ? "text-indigo-200 dark:text-indigo-100/70" : "text-slate-400 dark:text-slate-500"
                        }`}
                      >
                        <span>{m.time}</span>
                        {out && m.status ? (
                          <>
                            <span
                              className={
                                m.status === "read"
                                  ? "text-sky-300 dark:text-sky-300"
                                  : "text-indigo-200 dark:text-indigo-100/70"
                              }
                            >
                              <Ticks status={m.status} />
                            </span>
                            <span className="sr-only">{STATUS_META[m.status].label}</span>
                          </>
                        ) : null}
                      </p>
                    </div>
                  </motion.div>
                );
              })}

              <AnimatePresence initial={false}>
                {typing ? (
                  <motion.div
                    key="typing"
                    aria-hidden="true"
                    initial={reduce ? false : { opacity: 0, y: 8, scale: 0.94 }}
                    animate={{ opacity: 1, y: 0, scale: 1 }}
                    exit={reduce ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.94 }}
                    transition={{ duration: 0.2, ease: "easeOut" }}
                    className="flex justify-start"
                  >
                    <div className="flex items-center gap-1.5 rounded-2xl rounded-bl-md border border-slate-200 bg-slate-50 px-4 py-3.5 dark:border-slate-700 dark:bg-slate-800">
                      <span className="ct-dot h-1.5 w-1.5 rounded-full bg-slate-500 dark:bg-slate-300" />
                      <span className="ct-dot h-1.5 w-1.5 rounded-full bg-slate-500 dark:bg-slate-300" />
                      <span className="ct-dot h-1.5 w-1.5 rounded-full bg-slate-500 dark:bg-slate-300" />
                    </div>
                  </motion.div>
                ) : null}
              </AnimatePresence>
            </div>

            <p role="status" className="sr-only">
              {typing ? "Priya Raman is typing" : ""}
            </p>
            <p aria-live="polite" className="sr-only">
              {lastOut ? `Your last message: ${STATUS_META[activeStatus].label}` : ""}
            </p>

            <form
              onSubmit={onSubmit}
              className="border-t border-slate-200 px-5 py-4 dark:border-slate-800"
            >
              <label htmlFor={fieldId} className="sr-only">
                Reply to Priya Raman
              </label>
              <div className="flex items-end gap-2">
                <textarea
                  id={fieldId}
                  ref={fieldRef}
                  rows={1}
                  value={draft}
                  onChange={onChange}
                  onKeyDown={onKeyDown}
                  placeholder="Reply to Priya…"
                  className="max-h-28 flex-1 resize-none rounded-xl border border-slate-200 bg-slate-50 px-3.5 py-2.5 text-sm leading-relaxed text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-900"
                />
                <button
                  type="submit"
                  disabled={draft.trim().length === 0}
                  className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-800 dark:disabled:text-slate-600"
                >
                  <span className="sr-only">Send message</span>
                  <svg
                    viewBox="0 0 18 18"
                    className="h-4 w-4"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.6"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden="true"
                  >
                    <path d="M16.2 1.8 8.4 9.6" />
                    <path d="M16.2 1.8 11.2 16.2 8.4 9.6 1.8 6.8Z" />
                  </svg>
                </button>
              </div>
              <div className="mt-2 flex items-center justify-between gap-3">
                <p className="text-[11px] text-slate-400 dark:text-slate-500">
                  Enter sends · Shift + Enter adds a line
                </p>
                <p
                  className={`text-[11px] tabular-nums ${
                    draft.length > 240
                      ? "text-rose-600 dark:text-rose-400"
                      : "text-slate-400 dark:text-slate-500"
                  } ${draft.length > 200 ? "opacity-100" : "opacity-0"}`}
                >
                  {draft.length}/280
                </p>
              </div>
            </form>
          </div>

          <aside className="flex flex-col gap-4">
            <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
              <h3 className="text-sm font-semibold text-slate-900 dark:text-white">Receipt chain</h3>
              <p className="mt-1 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
                Each tick answers a different question. Skipping one is how threads start lying to people.
              </p>
              <ol className="mt-4 space-y-2">
                {STATUS_ORDER.map((s) => {
                  const active = s === activeStatus;
                  return (
                    <li key={s}>
                      <div
                        aria-current={active ? "step" : undefined}
                        className={`rounded-xl border p-3 transition-colors ${
                          active
                            ? "border-indigo-300 bg-indigo-50 dark:border-indigo-500/50 dark:bg-indigo-500/10"
                            : "border-slate-200 bg-slate-50/60 dark:border-slate-800 dark:bg-slate-800/40"
                        }`}
                      >
                        <div className="flex items-center gap-2">
                          <span
                            className={
                              s === "read"
                                ? "text-sky-600 dark:text-sky-400"
                                : active
                                  ? "text-indigo-600 dark:text-indigo-400"
                                  : "text-slate-400 dark:text-slate-500"
                            }
                          >
                            <Ticks status={s} />
                          </span>
                          <span
                            className={`text-xs font-semibold ${
                              active
                                ? "text-indigo-700 dark:text-indigo-300"
                                : "text-slate-700 dark:text-slate-300"
                            }`}
                          >
                            {STATUS_META[s].label}
                          </span>
                          {active ? (
                            <span className="ml-auto text-[10px] font-medium uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
                              Current
                            </span>
                          ) : null}
                        </div>
                        <p className="mt-1.5 text-[11px] leading-relaxed text-slate-500 dark:text-slate-400">
                          {STATUS_META[s].hint}
                        </p>
                      </div>
                    </li>
                  );
                })}
              </ol>
            </div>

            <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
              <div className="flex items-start justify-between gap-4">
                <div className="min-w-0">
                  <p
                    id={switchLabelId}
                    className="text-sm font-semibold text-slate-900 dark:text-white"
                  >
                    Auto-reply
                  </p>
                  <p className="mt-1 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
                    Off, your message stops at Delivered — nobody has opened the thread.
                  </p>
                </div>
                <button
                  type="button"
                  role="switch"
                  aria-checked={autoReply}
                  aria-labelledby={switchLabelId}
                  onClick={() => setAutoReply((v) => !v)}
                  className={`relative mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                    autoReply
                      ? "bg-indigo-600 dark:bg-indigo-500"
                      : "bg-slate-300 dark:bg-slate-700"
                  }`}
                >
                  <span
                    className={`inline-block h-[18px] w-[18px] transform rounded-full bg-white shadow-sm transition-transform ${
                      autoReply ? "translate-x-[1.4rem]" : "translate-x-1"
                    }`}
                  />
                </button>
              </div>
            </div>
          </aside>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →