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.
npx shadcn@latest add @paragon/read-receiptAlso installs: tooltip
"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>
);
}