Chat Typing
Original · freetyping indicator and delivery states
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"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
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 quoteSimilar components
Browse all →
Chat Bubbles
Originalmessage bubble styles both sides

Chat Conversation
Originalfull conversation thread with avatars

Chat Input
Originalchat composer with attach and send

Chat Support Widget
Originalfloating support chat widget

Chat Group
Originalgroup chat with member names

Chat List
Originalconversations list with unread counts

Chat AI
OriginalAI assistant chat interface

Chat Header
Originalchat window header with actions

Chat Attachments
Originalmessages with image and file attachments

Chat Reactions
Originalmessage reactions and replies

Chat Read Receipts
Originalread receipts and timestamps

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

