All patterns

Unread Divider

A marker draws across the thread where you stopped reading, then fades without moving anything.

socialminimalcalmautomatic · finite · starter · ~0.9s
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.

230 lines · react + motion only
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Unread Divider
 *
 * The line that says "you stopped here". It draws across the thread at
 * the boundary, holds while the reader catches up, then fades — leaving
 * its space behind, so nothing below it moves when it goes.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the thread reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `unreadCount`, `holdMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type UnreadDividerAppearProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Messages below the marker. */
  unreadCount?: number;
  /** Delay from mount before the marker draws, in ms. */
  drawDelayMs?: number;
  /** How long the marker stays once drawn. 0 keeps it indefinitely. */
  holdMs?: number;
  /** Marker color. A state color, so it stays literal. */
  accent?: string;
};

type VariantConfig = {
  /** Seconds for each half of the rule to draw. */
  draw: number;
  /** Seconds for the marker to fade once the reader has passed it. */
  fade: number;
  /** px the label rises as it arrives. */
  rise: number;
};

// No springs: a rule that overshoots its own width is a rule that looks
// broken. Everything here is a short eased tween, and the only thing that
// varies between variants is how deliberately the line is drawn.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost instant. For a thread that gets opened dozens of times a day.
  subtle: { draw: 0.18, fade: 0.4, rise: 2 },
  // Slow enough to see where the boundary is. All-purpose.
  default: { draw: 0.26, fade: 0.5, rise: 3 },
  // A deliberate sweep, for an inbox opened once a morning.
  playful: { draw: 0.34, fade: 0.6, rise: 5 },
};

const ACCENT = "#4C7DF0";

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` yields surfaces and borders correctly toned on a light
 *  page and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

type Message = { id: number; author: string; body: string; time: string; mine?: boolean };

const READ: readonly Message[] = [
  {
    id: 1,
    author: "You",
    body: "Sending the revised scope over tonight.",
    time: "17:42",
    mine: true,
  },
  { id: 2, author: "Priya Raman", body: "Perfect, thanks.", time: "17:44" },
];

const UNREAD: readonly Message[] = [
  { id: 3, author: "Priya Raman", body: "Scope looks right. One question on phase two.", time: "08:02" },
  { id: 4, author: "Tomas Lund", body: "Adding the revised numbers to the doc now.", time: "08:09" },
  { id: 5, author: "Priya Raman", body: "Can we review before the standup?", time: "08:11" },
];

export default function UnreadDividerAppear({
  variant = "default",
  unreadCount = UNREAD.length,
  drawDelayMs = 420,
  holdMs = 2600,
  accent = ACCENT,
}: UnreadDividerAppearProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [phase, setPhase] = useState<"before" | "shown" | "passed">("before");

  useEffect(() => {
    const timers = [setTimeout(() => setPhase("shown"), drawDelayMs)];
    if (holdMs > 0) {
      timers.push(setTimeout(() => setPhase("passed"), drawDelayMs + holdMs));
    }
    return () => timers.forEach(clearTimeout);
  }, [drawDelayMs, holdMs]);

  const shown = phase === "shown";
  const gone = phase === "passed";

  // Reduced motion: the marker still appears and still leaves — it is
  // information, not decoration — it simply does so by fading rather than
  // by being drawn across the thread.
  const drawTween = reduceMotion
    ? { duration: 0.001 }
    : { duration: cfg.draw, ease: [0.32, 0.72, 0, 1] as const };

  const rule = (delay: number) => (
    <motion.span
      style={{
        flex: 1,
        height: 1,
        background: accent,
        transformOrigin: "left center",
      }}
      initial={false}
      animate={{ scaleX: shown || gone ? 1 : 0 }}
      transition={{ ...drawTween, delay: reduceMotion || gone ? 0 : delay }}
    />
  );

  return (
    <div
      style={{
        width: 320,
        padding: "12px 14px 14px",
        borderRadius: 18,
        border: `1px solid ${tone(11)}`,
        background: tone(4),
      }}
    >
      <div
        style={{
          fontSize: 11,
          fontWeight: 600,
          letterSpacing: 0.5,
          opacity: 0.42,
          marginBottom: 10,
        }}
      >
        PHASE TWO PLANNING
      </div>

      {READ.map((message) => (
        <Row key={message.id} message={message} accent={accent} />
      ))}

      {/* The marker keeps its row after it fades, so the messages under it
          never jump to fill the space it used to hold. */}
      <motion.div
        role="separator"
        aria-label={`${unreadCount} unread messages`}
        initial={false}
        animate={{ opacity: gone ? 0 : shown ? 1 : 0 }}
        transition={{
          duration: gone ? cfg.fade : reduceMotion ? 0.2 : 0.16,
          ease: "easeOut",
        }}
        style={{
          display: "flex",
          alignItems: "center",
          gap: 9,
          margin: "9px 0 7px",
        }}
      >
        {rule(0)}
        <motion.span
          initial={false}
          animate={{
            opacity: shown && !gone ? 1 : 0,
            y: shown || reduceMotion ? 0 : cfg.rise,
          }}
          transition={{
            duration: reduceMotion ? 0.2 : 0.22,
            ease: "easeOut",
            delay: reduceMotion || gone ? 0 : cfg.draw * 0.7,
          }}
          style={{
            flexShrink: 0,
            fontSize: 10.5,
            fontWeight: 600,
            letterSpacing: 0.3,
            color: accent,
            whiteSpace: "nowrap",
          }}
        >
          {unreadCount} new messages
        </motion.span>
        {rule(cfg.draw * 0.75)}
      </motion.div>

      {UNREAD.map((message) => (
        <Row key={message.id} message={message} accent={accent} />
      ))}
    </div>
  );
}

function Row({ message, accent }: { message: Message; accent: string }) {
  return (
    <div
      style={{
        display: "flex",
        justifyContent: message.mine ? "flex-end" : "flex-start",
        marginBottom: 6,
      }}
    >
      <div
        style={{
          maxWidth: 224,
          padding: "8px 11px",
          borderRadius: 14,
          borderBottomLeftRadius: message.mine ? 14 : 5,
          borderBottomRightRadius: message.mine ? 5 : 14,
          background: message.mine
            ? `color-mix(in srgb, ${accent} 14%, transparent)`
            : "color-mix(in srgb, currentColor 7%, transparent)",
          fontSize: 12.5,
          lineHeight: 1.4,
        }}
      >
        {message.body}
        <span style={{ display: "block", fontSize: 10, opacity: 0.42, marginTop: 3 }}>
          {message.mine ? message.time : `${message.author} · ${message.time}`}
        </span>
      </div>
    </div>
  );
}

About this pattern

Reopening a conversation, the only question is where to start. The marker answers it by drawing across the thread at the boundary — left rule, label, right rule, in that order, so the eye is led along the line to the first message it has not seen. Then it gets out of the way: after a hold it fades, but keeps its row, because a marker that removes its own height would slide the unread messages up under the reader's eyes at the exact moment they started reading them. Nothing springs; a rule that overshoots its own width looks like a rendering fault rather than a flourish. Under reduced motion the marker still appears and still leaves, by fading instead of being drawn.

Unread boundary in a threadNew messages since last visitInbox catch-up markerComment thread resume point

Where it shows up

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

  • Dana Whitfield
    Priya Raman
    Ops standup
    Marcus Bell
    Design sync
    Nils Bergström
    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

    A red rule marks the first unread message and clears itself once the channel is read.

Related patterns