Unread Divider
A marker draws across the thread where you stopped reading, then fades without moving anything.
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 · 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
A red rule marks the first unread message and clears itself once the channel is read.
Related patterns
- Comment Thread ExpandReplies unfold beneath a comment while the indent guide draws down beside them.
- Feed Refresh InsertPosts land above the fold and the line being read never moves — only a counter admits they arrived.
- Read More ExpandA truncated post opens to full height while the fade that hid the cut-off line lifts with it.