Chat Read Receipts
Original · freeread receipts and timestamps
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-read-receipts.json"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type DeliveryState = "sending" | "sent" | "delivered" | "read";
type Author = "me" | "them";
type Message = {
id: string;
author: Author;
body: string;
/** Epoch ms. */
at: number;
state: DeliveryState;
/** Epoch ms the message was read, when state === "read". */
readAt?: number;
};
type Reader = {
id: string;
name: string;
initials: string;
hue: string;
};
const READERS: Reader[] = [
{ id: "amara", name: "Amara Osei", initials: "AO", hue: "bg-indigo-500" },
{ id: "ravi", name: "Ravi Menon", initials: "RM", hue: "bg-emerald-500" },
{ id: "june", name: "June Park", initials: "JP", hue: "bg-rose-500" },
];
const T0 = Date.UTC(2026, 2, 14, 9, 12, 0);
const SEED: Message[] = [
{
id: "m1",
author: "them",
body: "Staging is throwing a 502 on the checkout callback. Started right after the 08:40 deploy.",
at: T0,
state: "read",
readAt: T0 + 41_000,
},
{
id: "m2",
author: "me",
body: "Looking. That deploy only touched the webhook retry queue, so I'd bet on the Redis connection cap.",
at: T0 + 66_000,
state: "read",
readAt: T0 + 92_000,
},
{
id: "m3",
author: "them",
body: "Cap is 20. We're sitting at 20 open, 340 waiting. That's the whole story.",
at: T0 + 154_000,
state: "read",
readAt: T0 + 171_000,
},
{
id: "m4",
author: "me",
body: "Rolling the cap to 80 and draining the backlog. Give it ninety seconds and refresh.",
at: T0 + 203_000,
state: "delivered",
},
];
const TICK_MS = 900;
function formatClock(ms: number): string {
const d = new Date(ms);
const h = d.getUTCHours();
const m = d.getUTCMinutes();
const suffix = h < 12 ? "AM" : "PM";
const h12 = h % 12 === 0 ? 12 : h % 12;
return `${h12}:${String(m).padStart(2, "0")} ${suffix}`;
}
function formatFull(ms: number): string {
const d = new Date(ms);
const s = d.getUTCSeconds();
return `${formatClock(ms)}:${String(s).padStart(2, "0")} UTC · Sat 14 Mar 2026`;
}
function relative(from: number, to: number): string {
const delta = Math.max(0, Math.round((to - from) / 1000));
if (delta < 60) return `${delta}s later`;
const mins = Math.round(delta / 60);
return `${mins} min later`;
}
const STATE_LABEL: Record<DeliveryState, string> = {
sending: "Sending",
sent: "Sent to server",
delivered: "Delivered to device",
read: "Read",
};
function CheckSingle() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" className="h-3.5 w-3.5">
<path
d="M2.5 8.6 6 12l7.5-8"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function CheckDouble() {
return (
<svg viewBox="0 0 22 16" aria-hidden="true" className="h-3.5 w-[1.15rem]">
<path
d="M1.5 8.6 5 12l7.5-8"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8.5 8.6 12 12l7.5-8"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ClockGlyph() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" className="h-3.5 w-3.5">
<circle cx="8" cy="8" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path
d="M8 4.6V8l2.3 1.6"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function SendGlyph() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4">
<path
d="M2.8 10 17 3.2 12.4 17l-3-5.2-6.6-1.8Z"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
}
function ReceiptGlyph({ state }: { state: DeliveryState }) {
if (state === "sending") return <ClockGlyph />;
if (state === "sent") return <CheckSingle />;
return <CheckDouble />;
}
export default function ChatReadReceipts() {
const reduced = useReducedMotion();
const panelId = useId();
const [messages, setMessages] = useState<Message[]>(SEED);
const [draft, setDraft] = useState("");
const [openReceipt, setOpenReceipt] = useState<string | null>(null);
const [showSeconds, setShowSeconds] = useState(false);
const [receiptsOn, setReceiptsOn] = useState(true);
const [announcement, setAnnouncement] = useState("");
const [now, setNow] = useState(T0 + 260_000);
const timers = useRef<number[]>([]);
const listRef = useRef<HTMLOListElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const captured = timers;
return () => {
captured.current.forEach((t) => window.clearTimeout(t));
captured.current = [];
};
}, []);
const schedule = useCallback((fn: () => void, delay: number) => {
const id = window.setTimeout(fn, delay);
timers.current.push(id);
}, []);
const advance = useCallback(
(id: string, state: DeliveryState, stamp?: number) => {
setMessages((prev) =>
prev.map((m) => (m.id === id ? { ...m, state, readAt: stamp ?? m.readAt } : m)),
);
},
[],
);
const send = useCallback(() => {
const body = draft.trim();
if (!body) return;
const id = `m${Date.now()}`;
const at = now + 4_000;
setMessages((prev) => [...prev, { id, author: "me", body, at, state: "sending" }]);
setDraft("");
setNow(at);
setAnnouncement(`Message sent at ${formatClock(at)}. Status: sending.`);
schedule(() => {
advance(id, "sent");
setAnnouncement("Status update: sent to server.");
}, TICK_MS);
schedule(() => {
advance(id, "delivered");
setAnnouncement("Status update: delivered to device.");
}, TICK_MS * 2.4);
if (receiptsOn) {
schedule(() => {
advance(id, "read", at + 9_000);
setAnnouncement(`Status update: read at ${formatClock(at + 9_000)}.`);
}, TICK_MS * 4);
}
}, [advance, draft, now, receiptsOn, schedule]);
useEffect(() => {
const el = listRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: reduced ? "auto" : "smooth" });
}, [messages.length, reduced]);
useEffect(() => {
if (!openReceipt) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpenReceipt(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [openReceipt]);
const lastRead = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const m = messages[i];
if (m.author === "me" && m.state === "read") return m.id;
}
return null;
}, [messages]);
return (
<section className="relative w-full bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes crr-pulse-ring {
0% { transform: scale(0.7); opacity: 0.55; }
70% { transform: scale(1.9); opacity: 0; }
100% { transform: scale(1.9); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.crr-ring { animation: none !important; opacity: 0 !important; }
}
`}</style>
<div className="mx-auto w-full max-w-3xl">
<header className="mb-10">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
Delivery telemetry
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-slate-50">
Read receipts that show their work
</h2>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Every outgoing message carries four states. Open any receipt to see the exact timestamp
it moved, and who has seen it.
</p>
</header>
<div className="mb-4 flex flex-wrap items-center gap-2">
<ToggleChip
pressed={showSeconds}
onChange={setShowSeconds}
label="Precise timestamps"
/>
<ToggleChip
pressed={receiptsOn}
onChange={(v) => {
setReceiptsOn(v);
setAnnouncement(v ? "Read receipts enabled." : "Read receipts disabled.");
}}
label="Send read receipts"
/>
</div>
<div className="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">
<span
aria-hidden="true"
className="grid h-9 w-9 place-items-center rounded-full bg-indigo-600 text-[0.7rem] font-semibold text-white"
>
#
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
incident-checkout-502
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
3 members · Amara, Ravi, June
</p>
</div>
</div>
<ol
ref={listRef}
className="max-h-[26rem] space-y-5 overflow-y-auto px-5 py-6"
aria-label="Conversation"
>
{messages.map((m) => {
const mine = m.author === "me";
const open = openReceipt === m.id;
return (
<li
key={m.id}
className={`flex ${mine ? "justify-end" : "justify-start"}`}
>
<motion.div
initial={reduced ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
className={`max-w-[85%] sm:max-w-[75%] ${mine ? "items-end" : "items-start"} flex flex-col gap-1.5`}
>
<div
className={`rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
mine
? "rounded-br-md bg-indigo-600 text-white"
: "rounded-bl-md bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200"
}`}
>
{!mine && (
<p className="mb-1 text-xs font-semibold text-indigo-600 dark:text-indigo-400">
Ravi Menon
</p>
)}
{m.body}
</div>
<div
className={`flex items-center gap-2 ${mine ? "flex-row-reverse" : "flex-row"}`}
>
<time
dateTime={new Date(m.at).toISOString()}
title={formatFull(m.at)}
className="text-[0.7rem] tabular-nums text-slate-400 dark:text-slate-500"
>
{showSeconds
? `${formatClock(m.at)}:${String(new Date(m.at).getUTCSeconds()).padStart(2, "0")}`
: formatClock(m.at)}
</time>
{mine && (
<div className="relative">
<button
type="button"
aria-expanded={open}
aria-controls={`${panelId}-${m.id}`}
onClick={() => setOpenReceipt(open ? null : m.id)}
className={`relative flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[0.7rem] font-medium 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 ${
m.state === "read"
? "text-sky-600 hover:bg-sky-50 dark:text-sky-400 dark:hover:bg-sky-500/10"
: "text-slate-400 hover:bg-slate-100 dark:text-slate-500 dark:hover:bg-slate-800"
}`}
>
<span className="sr-only">
{`${STATE_LABEL[m.state]}. Show receipt details.`}
</span>
{m.state === "read" && !reduced && m.id === lastRead && (
<span
aria-hidden="true"
className="crr-ring pointer-events-none absolute left-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 rounded-full bg-sky-400/50"
style={{ animation: "crr-pulse-ring 1.6s ease-out 1" }}
/>
)}
<ReceiptGlyph state={m.state} />
</button>
<AnimatePresence>
{open && (
<motion.div
id={`${panelId}-${m.id}`}
role="group"
aria-label="Receipt details"
initial={reduced ? { opacity: 1 } : { opacity: 0, y: -4, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.97 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="absolute right-0 top-full z-20 mt-2 w-64 rounded-xl border border-slate-200 bg-white p-3 text-left shadow-lg dark:border-slate-700 dark:bg-slate-800"
>
<ReceiptDetail message={m} receiptsOn={receiptsOn} />
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
</motion.div>
</li>
);
})}
</ol>
<form
onSubmit={(e) => {
e.preventDefault();
send();
}}
className="flex items-center gap-2 border-t border-slate-200 px-4 py-3 dark:border-slate-800"
>
<label htmlFor={`${panelId}-input`} className="sr-only">
Message incident-checkout-502
</label>
<input
id={`${panelId}-input`}
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Reply to the thread…"
autoComplete="off"
className="min-w-0 flex-1 rounded-full border border-slate-200 bg-slate-50 px-4 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<button
type="submit"
disabled={!draft.trim()}
className="grid h-9 w-9 shrink-0 place-items-center rounded-full 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-300 dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-700"
>
<span className="sr-only">Send message</span>
<SendGlyph />
</button>
</form>
</div>
<p aria-live="polite" className="sr-only">
{announcement}
</p>
<dl className="mt-6 grid gap-3 sm:grid-cols-4">
{(["sending", "sent", "delivered", "read"] as DeliveryState[]).map((s) => (
<div
key={s}
className="rounded-xl border border-slate-200 bg-white px-3 py-2.5 dark:border-slate-800 dark:bg-slate-900"
>
<dt className="flex items-center gap-1.5 text-[0.7rem] font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
<span className={s === "read" ? "text-sky-500" : "text-slate-400 dark:text-slate-500"}>
<ReceiptGlyph state={s} />
</span>
{s}
</dt>
<dd className="mt-1 text-xs leading-snug text-slate-600 dark:text-slate-400">
{s === "sending" && "Queued on this device, not yet acknowledged."}
{s === "sent" && "Accepted by the server, stored durably."}
{s === "delivered" && "Pushed to at least one signed-in device."}
{s === "read" && "Opened in a focused window by a recipient."}
</dd>
</div>
))}
</dl>
</div>
</section>
);
}
function ReceiptDetail({ message, receiptsOn }: { message: Message; receiptsOn: boolean }) {
const steps: { key: DeliveryState; at: number | null }[] = [
{ key: "sending", at: message.at },
{ key: "sent", at: message.state === "sending" ? null : message.at + 2_000 },
{
key: "delivered",
at: message.state === "sending" || message.state === "sent" ? null : message.at + 5_000,
},
{ key: "read", at: message.state === "read" ? (message.readAt ?? null) : null },
];
return (
<div>
<p className="mb-2 text-[0.7rem] font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Delivery timeline
</p>
<ol className="space-y-2">
{steps.map((step) => {
const done = step.at !== null;
return (
<li key={step.key} className="flex items-start gap-2.5">
<span
aria-hidden="true"
className={`mt-1 h-1.5 w-1.5 shrink-0 rounded-full ${
done
? step.key === "read"
? "bg-sky-500"
: "bg-emerald-500"
: "bg-slate-300 dark:bg-slate-600"
}`}
/>
<div className="min-w-0">
<p
className={`text-xs font-medium ${
done ? "text-slate-800 dark:text-slate-200" : "text-slate-400 dark:text-slate-500"
}`}
>
{STATE_LABEL[step.key]}
</p>
<p className="text-[0.7rem] tabular-nums text-slate-500 dark:text-slate-400">
{done && step.at !== null
? `${formatClock(step.at)} · ${relative(message.at, step.at)}`
: step.key === "read" && !receiptsOn
? "Receipts off for this thread"
: "Pending"}
</p>
</div>
</li>
);
})}
</ol>
{message.state === "read" && (
<div className="mt-3 border-t border-slate-200 pt-2.5 dark:border-slate-700">
<p className="mb-1.5 text-[0.7rem] font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Seen by
</p>
<ul className="flex items-center gap-1.5">
{READERS.map((r) => (
<li key={r.id} className="flex items-center gap-1.5">
<span
className={`grid h-5 w-5 place-items-center rounded-full text-[0.55rem] font-semibold text-white ${r.hue}`}
title={`${r.name} read this message`}
>
<span aria-hidden="true">{r.initials}</span>
<span className="sr-only">{`${r.name} read this message`}</span>
</span>
</li>
))}
<li className="ml-1 text-[0.7rem] text-slate-500 dark:text-slate-400">All 3 members</li>
</ul>
</div>
)}
</div>
);
}
function ToggleChip({
pressed,
onChange,
label,
}: {
pressed: boolean;
onChange: (next: boolean) => void;
label: string;
}) {
return (
<button
type="button"
aria-pressed={pressed}
onClick={() => onChange(!pressed)}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
pressed
? "border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/40 dark:bg-indigo-500/10 dark:text-indigo-300"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
}`}
>
<span
aria-hidden="true"
className={`relative h-3.5 w-6 rounded-full transition-colors ${
pressed ? "bg-indigo-600" : "bg-slate-300 dark:bg-slate-600"
}`}
>
<span
className={`absolute top-0.5 h-2.5 w-2.5 rounded-full bg-white transition-all ${
pressed ? "left-3" : "left-0.5"
}`}
/>
</span>
{label}
</button>
);
}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 Typing
Originaltyping indicator and delivery states

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

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

