All patterns

Read Receipt Tick

One tick becomes two, then the pair tints — sent, delivered, read.

feedbacksubtleminimalautomatic · finite · starter · ~2.1s
Variant

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.

210 lines · react + motion only
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.

Chat message statusSupport conversationDirect message threadComment delivery state

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Dana Whitfieldonline
    Morning — did the venue confirm?
    They did, contract came back signed.10:14
    Are we still on for Thursday?
    Yes — booked the room for 2pm.10:14
    Perfect. I'll bring the printouts.
    See you then.10:14
    Message
    Chat thread

    Single tick, double tick, then a blue pair once the message is opened.

Related patterns