Web InnoventixFreeCode

Chat Read Receipts

Original · free

read receipts and timestamps

byWeb InnoventixReact + Tailwind
chatreadreceipts
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-read-receipts.json
chat-read-receipts.tsx
"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

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 →