Read Receipt
Feedback

Read Receipt

Message-status glyph choreography: a clock while sending, a single check once sent, a second check that slides out on delivery, and an accent tint once read. Every hop is an interruptible spring, mirrored to a live region with a timestamp tooltip.

Install

npx shadcn@latest add @paragon/read-receipt

Also installs: tooltip

read-receipt.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Clock } from "lucide-react";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";

export type ReadReceiptStatus = "sending" | "sent" | "delivered" | "read";

const STATUS_LABELS: Record<ReadReceiptStatus, string> = {
  sending: "Sending",
  sent: "Sent",
  delivered: "Delivered",
  read: "Read",
};

export interface ReadReceiptProps extends React.ComponentProps<"span"> {
  status?: ReadReceiptStatus;
  /** Shown in the tooltip after the status, e.g. "2:47 PM". */
  timestamp?: string;
  /** CSS color for the read tint. Defaults to the primary token. */
  readColor?: string;
  /** Override the per-status labels (tooltip + screen readers). */
  labels?: Partial<Record<ReadReceiptStatus, string>>;
  /** Swap glyphs instantly instead of animating. */
  static?: boolean;
}

const swapSpring = { type: "spring", duration: 0.3, bounce: 0 } as const;

/**
 * Message-status glyph choreography: a clock while sending, a single check
 * once sent, a second check that slides out from behind it on delivery, and
 * an accent tint once read. Every hop is an interruptible spring — statuses
 * arriving out of rhythm simply retarget. The current status is mirrored to
 * an `aria-live` region, and hovering (or focusing) reveals a timestamp
 * tooltip. Reduced motion swaps movement for plain crossfades.
 */
export function ReadReceipt({
  status = "sent",
  timestamp,
  readColor,
  labels,
  static: isStatic = false,
  className,
  style,
  ...props
}: ReadReceiptProps) {
  const reducedMotion = useReducedMotion();
  const instant = isStatic || !!reducedMotion;

  const label = labels?.[status] ?? STATUS_LABELS[status];
  const detail = timestamp ? `${label} · ${timestamp}` : label;
  const read = status === "read";
  const double = status === "delivered" || read;

  const swap = {
    initial: instant
      ? { opacity: 0 }
      : { opacity: 0, scale: 0.25, filter: "blur(4px)" },
    animate: instant
      ? { opacity: 1 }
      : { opacity: 1, scale: 1, filter: "blur(0px)" },
    exit: instant
      ? { opacity: 0 }
      : { opacity: 0, scale: 0.25, filter: "blur(4px)" },
  };

  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger asChild>
          <span
            data-slot="read-receipt"
            role="img"
            aria-label={detail}
            tabIndex={0}
            style={{
              ...(read && readColor ? { color: readColor } : null),
              ...style,
            }}
            className={cn(
              "relative inline-flex h-4 w-[19px] shrink-0 items-center rounded-sm outline-none",
              "transition-colors duration-(--duration-base) ease-(--ease-out)",
              read
                ? readColor
                  ? undefined
                  : "text-primary"
                : "text-muted-foreground",
              "focus-visible:ring-2 focus-visible:ring-ring",
              // Informational glyph, but give hover/focus an easy target.
              "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
              className,
            )}
            {...props}
          >
            <span aria-live="polite" className="sr-only">
              {detail}
            </span>
            <AnimatePresence mode="popLayout" initial={false}>
              {status === "sending" ? (
                <motion.span
                  key="clock"
                  aria-hidden
                  className="inline-flex"
                  transition={swapSpring}
                  {...swap}
                >
                  <Clock className="size-3.5" />
                </motion.span>
              ) : (
                <motion.span
                  key="checks"
                  aria-hidden
                  className="relative h-3.5 w-full"
                  transition={swapSpring}
                  {...swap}
                >
                  {/* First check: centered while alone, nudged left once the
                      second check arrives. */}
                  <motion.span
                    className="absolute top-0 left-0 inline-flex"
                    initial={false}
                    animate={{ x: double ? 0 : 2.5 }}
                    transition={instant ? { duration: 0 } : swapSpring}
                  >
                    <Check className="size-3.5" />
                  </motion.span>
                  <AnimatePresence initial={false}>
                    {double && (
                      <motion.span
                        key="second"
                        className="absolute top-0 left-0 inline-flex"
                        initial={
                          instant ? { opacity: 0, x: 5 } : { opacity: 0, x: 0 }
                        }
                        animate={{ opacity: 1, x: 5 }}
                        exit={
                          instant ? { opacity: 0, x: 5 } : { opacity: 0, x: 0 }
                        }
                        transition={
                          instant ? { duration: 0.1 } : swapSpring
                        }
                      >
                        <Check className="size-3.5" />
                      </motion.span>
                    )}
                  </AnimatePresence>
                </motion.span>
              )}
            </AnimatePresence>
          </span>
        </TooltipTrigger>
        <TooltipContent>{detail}</TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}