Read Receipt Tick
One tick becomes two, then the pair tints — sent, delivered, read.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Read Receipt Tick
*
* The quietest status in messaging: one tick becomes two, then the pair
* tints — sent, delivered, read — under an outgoing bubble.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The bubble is mixed from the inherited text color, so it reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `status`, `message`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ReadReceiptTickProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Drive it from your own delivery events. "auto" walks the three states. */
status?: "auto" | "sent" | "delivered" | "read";
message?: string;
timestamp?: string;
/** In "auto": when each state lands, in ms from mount. */
sentMs?: number;
deliveredMs?: number;
readMs?: number;
};
type Stage = "pending" | "sent" | "delivered" | "read";
type VariantConfig = {
/** px each tick slides in from. */
travel: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** How long the pair takes to take on the read tint. */
tintSeconds: number;
};
// Damping ratios (damping / 2√stiffness) stay at or above 0.8. This mark
// is 12px tall in the corner of a bubble: any overshoot at that size
// reads as a rendering glitch rather than as personality.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a slide. For a busy thread where receipts are constant.
subtle: {
travel: 2,
spring: { type: "spring", stiffness: 590, damping: 49 },
tintSeconds: 0.17,
},
// Enough travel to notice the second tick arrive. All-purpose.
default: {
travel: 4,
spring: { type: "spring", stiffness: 440, damping: 38 },
tintSeconds: 0.28,
},
// A longer slide and a slower tint, for a one-to-one conversation
// where the receipt is the point.
playful: {
travel: 8,
spring: { type: "spring", stiffness: 340, damping: 30 },
tintSeconds: 0.35,
},
};
const READ_COLOR = "#3B82F6";
const TICK_A = "M1.4 6.5 4.6 9.7 10.2 3.1";
const TICK_B = "M7 6.5 10.2 9.7 15.8 3.1";
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function ReadReceiptTick({
variant = "default",
status = "auto",
message = "Sent the updated invoice — let me know if the totals look right.",
timestamp = "9:41",
sentMs = 260,
deliveredMs = 950,
readMs = 1750,
}: ReadReceiptTickProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Controlled use reads straight off the prop; only "auto" keeps state,
// so there is nothing to synchronize back and forth.
const [autoStage, setAutoStage] = useState<Stage>("pending");
const stage: Stage = status === "auto" ? autoStage : status;
useEffect(() => {
if (status !== "auto") return;
const timers = [
setTimeout(() => setAutoStage("sent"), sentMs),
setTimeout(() => setAutoStage("delivered"), deliveredMs),
setTimeout(() => setAutoStage("read"), readMs),
];
return () => timers.forEach(clearTimeout);
}, [status, sentMs, deliveredMs, readMs]);
const showFirst = stage !== "pending";
const showSecond = stage === "delivered" || stage === "read";
const read = stage === "read";
const label = read ? "Read" : showSecond ? "Delivered" : "Sent";
// Reduced motion: the ticks appear where they belong instead of
// sliding in. The count and the tint still carry the whole message.
const tickTransition = reduceMotion
? { duration: 0.16, ease: "easeOut" as const }
: { ...cfg.spring, opacity: { duration: 0.16, ease: "easeOut" as const } };
// Two identical layers, one neutral and one tinted, crossfaded on
// "read". Cheaper to reason about than interpolating a stroke away
// from `currentColor`, and it keeps the pending state theme-adaptive.
const layers = [
{ key: "neutral", color: "currentColor", opacity: read ? 0 : 0.5 },
{ key: "read", color: READ_COLOR, opacity: read ? 1 : 0 },
];
return (
<div
style={{
maxWidth: 250,
marginLeft: "auto",
padding: "9px 12px 7px",
borderRadius: 16,
borderBottomRightRadius: 6,
background: tone(9),
border: `1px solid ${tone(9)}`,
}}
>
<div style={{ fontSize: 13, lineHeight: 1.45 }}>{message}</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 5,
marginTop: 4,
}}
>
<span style={{ fontSize: 10.5, opacity: 0.45 }}>{timestamp}</span>
<span
role="status"
aria-live="polite"
style={{
position: "relative",
display: "inline-block",
width: 18,
height: 12,
}}
>
{/* The state is announced as words, not as a picture of two
ticks — a receipt nobody can read is not a receipt. */}
<span
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{label}
</span>
{layers.map((layer) => (
<motion.svg
key={layer.key}
aria-hidden
width="18"
height="12"
viewBox="0 0 18 12"
fill="none"
initial={false}
animate={{ opacity: layer.opacity }}
transition={{ duration: cfg.tintSeconds, ease: "easeOut" }}
style={{ position: "absolute", inset: 0, display: "block" }}
>
<motion.path
d={TICK_A}
stroke={layer.color}
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ opacity: 0, x: -cfg.travel }}
animate={{ opacity: showFirst ? 1 : 0, x: showFirst ? 0 : -cfg.travel }}
transition={tickTransition}
/>
{/* The second tick slides out from behind the first: the
motion is the message, so it travels rather than fades. */}
<motion.path
d={TICK_B}
stroke={layer.color}
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ opacity: 0, x: -cfg.travel - 1 }}
animate={{
opacity: showSecond ? 1 : 0,
x: showSecond ? 0 : -cfg.travel - 1,
}}
transition={tickTransition}
/>
</motion.svg>
))}
</span>
</div>
</div>
);
}About this pattern
Delivery state under an outgoing message, told in three beats. The first tick slides the last couple of pixels into place, the second slides out from behind it when the message lands on the other device, and the pair takes on the read tint when it is opened. The tint is a crossfade between a neutral layer and a colored one rather than a stroke interpolation, which keeps the pending state derived from the page's own text color. The glyph is 12px tall, so the springs are flat by design and the state is announced as words for screen readers.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
Single tick, double tick, then a blue pair once the message is opened.
Related patterns
- Clipboard Toasts StackRepeat copies push a short stack of confirmations that shuffle down and dim instead of piling up.
- Save Indicator SettleA turning ring under "Saving" resolves into a drawn tick under "Saved", then the chip recedes.
- Hover Tooltip FadeA hint fades up after a deliberate pause, so crossing a row of icons never sets off a flicker.