Grouped bubbles with tails, pinned to the latest while new messages rise in.
01Preview
Maya ChenFinance · Active now
- Sep 22, 2026
- Maya Chen, Tuesday, September 22 at 6:02 PM:
Board deck is locked. Thanks for turning the forecast around so fast.
6:02 PM - You, Tuesday, September 22 at 6:05 PM, read:
Anytime. I’ll send the appendix in the morning.
6:05 PM - Sep 23, 2026
- Maya Chen, Wednesday, September 23 at 8:08 PM:
Morning. Did the EMEA numbers move after the FX update?
8:08 PM - Maya Chen, Wednesday, September 23 at 8:08 PM:
Priya’s asking before standup.
8:08 PM - You, Wednesday, September 23 at 8:11 PM, read:
A little. Revenue is down 1.8% in euros, flat in dollars.
8:11 PM - You, Wednesday, September 23 at 8:11 PM, read:
I’ll put both in the appendix.
8:11 PM - Maya Chen, Wednesday, September 23 at 8:16 PM:12 seconds, not played yet8:16 PM
- You, Wednesday, September 23 at 8:20 PM, not delivered:
Sending the updated appendix now.
8:20 PMMessage not delivered. Try again is available.
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @base-ui/react @number-flow/react03Usage
import { ChatThread } from "@/components/ui/chat-thread";
<ChatThread
label="Conversation with Maya Chen"
messages={messages}
authors={[{ id: me.id, name: "You" }, { id: "maya", name: "Maya Chen", avatar: maya.avatar }]}
currentUserId={me.id}
typing={typingIds}
onReply={(m) => setReplyTo(m)}
onRetry={(m) => resend(m.id)}
footer={<MessageComposer onSend={send} />}
/>04Source
"use client";
import { Avatar } from "@base-ui/react/avatar";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from "motion/react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ArrowDown } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
import { MessageStatus, type MessageStatusValue } from "@/components/ui/message-status";
import { ReplyQuote, jumpToMessage, type ReplyTarget } from "@/components/ui/reply-preview";
/* -------------------------------------------------------------------------------------------------
* Types
* -----------------------------------------------------------------------------------------------*/
export type ChatAuthor = { id: string; name: string; avatar?: string };
export type ChatMessage = {
id: string;
authorId: string;
/** When it was sent: a Date, an ISO string or epoch milliseconds. */
at: Date | string | number;
text?: string;
/** Anything that isn't text: a voice note, an image, a file card. */
content?: React.ReactNode;
/** Render content without the bubble around it, for content that brings its own (a voice message). */
bare?: boolean;
/** Delivery state of your own messages. */
status?: MessageStatusValue;
replyTo?: ReplyTarget;
edited?: boolean;
};
type Position = "single" | "first" | "middle" | "last";
export type ChatRow =
| { type: "day"; key: string; date: Date }
| { type: "message"; key: string; message: ChatMessage; date: Date; own: boolean; position: Position; showName: boolean; showAvatar: boolean };
const toDate = (at: ChatMessage["at"]) => (at instanceof Date ? at : new Date(at));
/**
* Turns a flat list into rows: a day separator whenever the calendar day changes, and each
* message placed in its group (same author, same day, under `windowMinutes` apart).
*/
export function groupMessages(
messages: ChatMessage[],
currentUserId: string,
{ windowMinutes = 5, timeZone, showNames = false }: { windowMinutes?: number; timeZone?: string; showNames?: boolean } = {},
) {
const dayKey = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" });
const rows: ChatRow[] = [];
let lastDay = "";
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
const date = toDate(m.at);
const day = dayKey.format(date);
if (day !== lastDay) {
// Keyed by the day's first message, so re-dating the thread never re-mounts a separator.
rows.push({ type: "day", key: `day-${m.id}`, date });
lastDay = day;
}
const joins = (a?: ChatMessage, b?: ChatMessage) =>
!!a &&
!!b &&
a.authorId === b.authorId &&
dayKey.format(toDate(a.at)) === dayKey.format(toDate(b.at)) &&
Math.abs(toDate(b.at).getTime() - toDate(a.at).getTime()) <= windowMinutes * 60_000;
const withPrev = joins(messages[i - 1], m);
const withNext = joins(m, messages[i + 1]);
const position: Position = withPrev ? (withNext ? "middle" : "last") : withNext ? "first" : "single";
const own = m.authorId === currentUserId;
rows.push({
type: "message",
key: m.id,
message: m,
date,
own,
position,
showName: showNames && !own && !withPrev,
showAvatar: showNames && !own && !withNext,
});
}
return rows;
}
/* -------------------------------------------------------------------------------------------------
* Hydration-safe dates: the server's time zone and locale aren't the reader's, so the first
* render uses fixed ones and the reader's take over straight after.
* -----------------------------------------------------------------------------------------------*/
const noop = () => () => {};
const useHydrated = () =>
useSyncExternalStore(
noop,
() => true,
() => false,
);
function useFormatters(hydrated: boolean, locale?: string, timeZone?: string) {
return useMemo(() => {
const loc = hydrated ? locale : (locale ?? "en-US");
const tz = hydrated ? timeZone : (timeZone ?? "UTC");
const time = new Intl.DateTimeFormat(loc, { hour: "numeric", minute: "2-digit", timeZone: tz });
const full = new Intl.DateTimeFormat(loc, { weekday: "long", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZone: tz });
const weekday = new Intl.DateTimeFormat(loc, { weekday: "long", timeZone: tz });
const dayMonth = new Intl.DateTimeFormat(loc, { weekday: "short", month: "short", day: "numeric", timeZone: tz });
const withYear = new Intl.DateTimeFormat(loc, { month: "short", day: "numeric", year: "numeric", timeZone: tz });
const key = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" });
const day = (date: Date) => {
// Relative words need "now", which only the reader's clock knows.
if (!hydrated) return withYear.format(date);
const now = new Date();
const diff = Math.round((Date.parse(key.format(now)) - Date.parse(key.format(date))) / 86_400_000);
if (diff === 0) return "Today";
if (diff === 1) return "Yesterday";
if (diff > 1 && diff < 7) return weekday.format(date);
return date.getFullYear() === now.getFullYear() ? dayMonth.format(date) : withYear.format(date);
};
return { time: (d: Date) => time.format(d), full: (d: Date) => full.format(d), day, tz };
}, [hydrated, locale, timeZone]);
}
/* -------------------------------------------------------------------------------------------------
* ChatThread
* -----------------------------------------------------------------------------------------------*/
export type ChatThreadProps = Omit<React.ComponentProps<"div">, "children"> & {
messages: ChatMessage[];
authors: ChatAuthor[];
/** Whose messages sit on the right in the filled bubble. */
currentUserId: string;
/** Ids of people typing right now. */
typing?: string[];
/** Reply to a message: from the hover button, the R key, or a swipe right on touch. */
onReply?: (message: ChatMessage) => void;
/** Resend a failed message. */
onRetry?: (message: ChatMessage) => void;
/** Names and avatars on other people's messages. Defaults to on when more than two people are in the thread. */
showNames?: boolean;
/** Messages closer together than this, from the same person, share a group. */
groupWindow?: number;
locale?: string;
timeZone?: string;
/** Shown when there are no messages. */
empty?: React.ReactNode;
/** Older history exists above. Scrolling near the top calls onLoadOlder. */
hasOlder?: boolean;
onLoadOlder?: () => Promise<unknown> | void;
/** The composer, pinned under the thread. The jump-to-latest pill sits just above it. */
footer?: React.ReactNode;
/** Names the log for screen readers: "Conversation with Maya Chen". */
label?: string;
};
const PIN_SLOP = 32;
const SWIPE_REPLY = 56;
const SWIPE_TIMES = 64;
/**
* A conversation: bubbles grouped by sender with a tail on the last of each group, day
* separators that stick while you scroll, times on hover (or a swipe left on touch), and new
* messages that rise into place while the thread stays pinned to the latest.
*/
export function ChatThread({
messages,
authors,
currentUserId,
typing = [],
onReply,
onRetry,
showNames: showNamesProp,
groupWindow = 5,
locale,
timeZone,
empty,
hasOlder = false,
onLoadOlder,
footer,
label = "Conversation",
className,
...rest
}: ChatThreadProps) {
const reduce = !!useReducedMotion();
const hydrated = useHydrated();
const fmt = useFormatters(hydrated, locale, timeZone);
const people = useMemo(() => new Map(authors.map((a) => [a.id, a])), [authors]);
const showNames = showNamesProp ?? new Set(messages.map((m) => m.authorId)).size > 2;
const rows = useMemo(
() => groupMessages(messages, currentUserId, { windowMinutes: groupWindow, timeZone: fmt.tz, showNames }),
[messages, currentUserId, groupWindow, fmt.tz, showNames],
);
const scrollerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLUListElement>(null);
const pinned = useRef(true);
const [atBottom, setAtBottom] = useState(true);
const [seenId, setSeenId] = useState(messages[messages.length - 1]?.id);
const [loadingOlder, setLoadingOlder] = useState(false);
// Rows that were loaded above the history shouldn't animate in as if they just arrived.
// Tracked with the "adjust state while rendering" pattern, so no effect has to catch up.
const [firstId, setFirstId] = useState(messages[0]?.id);
const [older, setOlder] = useState<Set<string>>(() => new Set());
if (messages[0]?.id !== firstId) {
const at = messages.findIndex((m) => m.id === firstId);
if (at > 0) setOlder((s) => new Set([...s, ...messages.slice(0, at).map((m) => m.id)]));
setFirstId(messages[0]?.id);
}
const lastId = messages[messages.length - 1]?.id;
const lastIdRef = useRef(lastId);
useEffect(() => {
lastIdRef.current = lastId;
}, [lastId]);
const unread = useMemo(() => {
const from = messages.findIndex((m) => m.id === seenId);
return messages.slice(from + 1).filter((m) => m.authorId !== currentUserId).length;
}, [messages, seenId, currentUserId]);
/* ----- pinning: follow the newest while you're at the bottom, leave you alone when you're not ----- */
const toBottom = useCallback(
(smooth: boolean) => {
const el = scrollerRef.current;
if (!el) return;
pinned.current = true;
setAtBottom(true);
setSeenId(lastIdRef.current);
el.scrollTo({ top: el.scrollHeight, behavior: smooth && !reduce ? "smooth" : "auto" });
},
[reduce],
);
useLayoutEffect(() => {
const el = scrollerRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, []);
useEffect(() => {
const el = scrollerRef.current;
const content = contentRef.current;
if (!el || !content) return;
// Growing rows animate their height, so this fires every frame of an arrival and the
// scroll follows the growth instead of jumping to its end.
const ro = new ResizeObserver(() => {
if (!pinned.current) return;
el.scrollTop = el.scrollHeight;
setSeenId(lastIdRef.current);
});
ro.observe(content);
return () => ro.disconnect();
}, []);
// Your own message always brings you down to it, wherever you were reading.
const lastOwn = messages[messages.length - 1]?.authorId === currentUserId ? lastId : undefined;
useEffect(() => {
if (lastOwn && !pinned.current) toBottom(true);
}, [lastOwn, toBottom]);
// Keep the reader's place when older messages are added above.
const anchor = useRef<{ first?: string; height: number }>({ first: messages[0]?.id, height: 0 });
useLayoutEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const prev = anchor.current;
if (prev.first && prev.first !== messages[0]?.id && !pinned.current) el.scrollTop += el.scrollHeight - prev.height;
anchor.current = { first: messages[0]?.id, height: el.scrollHeight };
});
const [floatDay, setFloatDay] = useState<string | null>(null);
const floatTimer = useRef<number>(undefined);
useEffect(() => () => window.clearTimeout(floatTimer.current), []);
const handled = useRef(0);
const markHand = () => {
handled.current = performance.now();
};
const onScroll = () => {
const el = scrollerRef.current;
if (!el) return;
const bottom = el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_SLOP;
pinned.current = bottom;
if (bottom !== atBottom) setAtBottom(bottom);
if (bottom) setSeenId(lastIdRef.current);
// While scrolling, the day you're reading floats at the top once its own label has scrolled away.
let day: string | null = null;
// Only for scrolling someone did: a composer growing or a message arriving also scrolls.
const byHand = performance.now() - handled.current < 800;
if (!bottom && byHand)
contentRef.current?.querySelectorAll<HTMLElement>("[data-day]").forEach((sep) => {
const top = sep.offsetTop - el.scrollTop;
if (top + sep.offsetHeight <= 4) day = sep.dataset.day ?? null;
// The next day's own label is about to take over: don't show two days at once.
else if (top < 56) day = null;
});
window.clearTimeout(floatTimer.current);
setFloatDay(day);
if (day) floatTimer.current = window.setTimeout(() => setFloatDay(null), 1100);
if (hasOlder && onLoadOlder && !loadingOlder && el.scrollTop < 120) {
setLoadingOlder(true);
Promise.resolve(onLoadOlder()).finally(() => setLoadingOlder(false));
}
};
/* ----- touch: swipe a message right to reply, the thread left to see every time ----- */
const swipeX = useMotionValue(0);
const timesX = useMotionValue(0);
const [swipeId, setSwipeId] = useState<string | null>(null);
const gesture = useRef<{ x: number; y: number; id: string | null; mode: "none" | "reply" | "times" | "scroll"; pointer: number } | null>(null);
const rubber = (d: number, max: number) => (d <= 0 ? 0 : d < max ? d : max + (d - max) * 0.25);
const onPointerDown = (e: React.PointerEvent) => {
if (e.pointerType !== "touch") return;
const row = (e.target as HTMLElement).closest<HTMLElement>("[data-message-id]");
gesture.current = { x: e.clientX, y: e.clientY, id: row?.dataset.messageId ?? null, mode: "none", pointer: e.pointerId };
};
const onPointerMove = (e: React.PointerEvent) => {
const g = gesture.current;
if (!g || g.pointer !== e.pointerId) return;
const dx = e.clientX - g.x;
const dy = e.clientY - g.y;
if (g.mode === "none") {
if (Math.abs(dy) > 10 && Math.abs(dy) > Math.abs(dx)) g.mode = "scroll";
else if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.2) {
g.mode = dx > 0 && g.id && onReply ? "reply" : dx < 0 ? "times" : "scroll";
if (g.mode === "reply") setSwipeId(g.id);
}
}
if (g.mode === "reply") swipeX.set(rubber(dx, SWIPE_REPLY + 8));
if (g.mode === "times") timesX.set(-rubber(-dx, SWIPE_TIMES));
};
const endGesture = () => {
const g = gesture.current;
gesture.current = null;
if (!g) return;
if (g.mode === "reply") {
if (swipeX.get() >= SWIPE_REPLY) {
const m = messages.find((x) => x.id === g.id);
if (m) onReply?.(m);
}
animate(swipeX, 0, reduce ? { duration: 0 } : spring.snappy).then(() => setSwipeId(null));
}
if (g.mode === "times") animate(timesX, 0, reduce ? { duration: 0 } : spring.soft);
};
/* ----- keyboard: the log is one tab stop; arrows walk the messages ----- */
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
const focusRow = (id: string | undefined) => {
if (!id) return;
const el = contentRef.current?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`);
el?.focus({ preventScroll: true });
el?.scrollIntoView({ block: "nearest" });
};
const onKeyDown = (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement;
const inRow = target.matches("[data-message-id]");
const onLog = target === scrollerRef.current;
if (!inRow && !onLog) return;
const current = inRow ? target.dataset.messageId! : null;
const i = current ? messageIds.indexOf(current) : messageIds.length;
if (e.key === "ArrowUp") {
e.preventDefault();
focusRow(messageIds[Math.max(0, i - 1)]);
} else if (e.key === "ArrowDown" && current) {
e.preventDefault();
if (i >= messageIds.length - 1) scrollerRef.current?.focus();
else focusRow(messageIds[i + 1]);
} else if (e.key === "Home") {
e.preventDefault();
focusRow(messageIds[0]);
} else if (e.key === "End") {
e.preventDefault();
focusRow(messageIds[messageIds.length - 1]);
} else if ((e.key === "r" || e.key === "Enter") && current && onReply && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
const m = messages.find((x) => x.id === current);
if (m) onReply(m);
} else if (e.key === "Escape" && inRow) {
e.preventDefault();
scrollerRef.current?.focus();
}
};
const typingPeople = typing
.filter((id) => id !== currentUserId)
.map((id) => people.get(id))
.filter(Boolean) as ChatAuthor[];
const lastOwnId = [...messages].reverse().find((m) => m.authorId === currentUserId)?.id;
return (
<div className={cn("relative flex min-h-0 flex-col [--bubble:color-mix(in_oklab,var(--fg)_7%,var(--frame))]", className)} {...rest}>
<div
ref={scrollerRef}
role="log"
aria-label={label}
aria-live="polite"
aria-relevant="additions"
tabIndex={0}
onScroll={onScroll}
onWheel={markHand}
onTouchMove={markHand}
onKeyDownCapture={markHand}
onPointerDownCapture={markHand}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endGesture}
onPointerCancel={endGesture}
className={cn(
"relative flex min-h-0 flex-1 touch-pan-y flex-col overflow-y-auto overflow-x-hidden overscroll-contain outline-none",
"[mask-image:linear-gradient(to_bottom,transparent,black_16px,black_calc(100%-10px),transparent)] focus-visible:[mask-image:none]",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4",
)}
>
{messages.length === 0 ? (
<div className="grid min-h-full place-items-center px-6 py-10 text-center">{empty ?? <DefaultEmpty />}</div>
) : (
// Clipped, not hidden: the swipe-left times live past the right edge, and a clip can't be
// scrolled sideways by focus or scrollIntoView the way an overflow region can.
<div className="flex flex-1 flex-col overflow-x-clip">
<motion.ul ref={contentRef} style={{ x: timesX }} className="flex flex-1 flex-col justify-end pb-3 pt-2">
{hasOlder && (
<li className="flex h-10 items-center justify-center text-[11.5px] text-fg-4" aria-live="off">
{loadingOlder ? "Loading earlier messages…" : ""}
</li>
)}
<AnimatePresence initial={false}>
{rows.map((row) =>
row.type === "day" ? (
<DaySeparator key={row.key} label={fmt.day(row.date)} reduce={reduce} />
) : (
<MessageRow
key={row.key}
row={row}
author={people.get(row.message.authorId)}
fmt={fmt}
arrived={!older.has(row.message.id)}
reduce={reduce}
withAvatars={showNames}
onJump={(id) => jumpToMessage(id, { root: contentRef.current })}
showStatusLabel={row.own && (row.message.id === lastOwnId || row.message.status === "failed")}
onReply={onReply}
onRetry={onRetry}
swipe={swipeId === row.message.id ? swipeX : undefined}
timesX={timesX}
/>
),
)}
{typingPeople.length > 0 && <TypingRow key="typing" people={typingPeople} showAvatar={showNames} reduce={reduce} />}
</AnimatePresence>
</motion.ul>
</div>
)}
</div>
{/* The floating day: appears while you scroll through history, fades once you stop. */}
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-2 z-(--z-sticky) flex justify-center">
<AnimatePresence>
{floatDay && (
<motion.span
key="float"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: 0.2 } }}
transition={{ duration: 0.16, ease: ease.out }}
className="rounded-full border border-line-2 bg-raised px-2.5 py-0.5 text-[11px] font-medium leading-4 text-fg-2 shadow-pop"
>
{floatDay}
</motion.span>
)}
</AnimatePresence>
</div>
{/* Jump to latest: only when you've scrolled away, with a count of what you've missed. */}
<div className="pointer-events-none relative z-(--z-sticky) h-0">
<AnimatePresence>
{!atBottom && messages.length > 0 && (
<motion.button
key="jump"
type="button"
onClick={() => toBottom(true)}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: 6, scale: 0.97, transition: { duration: 0.14 } }}
transition={reduce ? { duration: 0.15 } : spring.snappy}
aria-label={unread ? `Jump to latest, ${unread} new message${unread === 1 ? "" : "s"}` : "Jump to latest message"}
className={cn(
"pointer-events-auto absolute bottom-3 left-1/2 flex h-8 -translate-x-1/2 items-center gap-1.5 rounded-full border border-line-2 bg-raised pl-2.5 pr-3 text-[12px] font-medium text-fg shadow-pop outline-none",
"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.96] active:duration-75",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
)}
>
<ArrowDown size={14} className="text-fg-3" />
{unread > 0 ? (
<span className="flex items-center gap-1 tabular">
<NumberFlow value={unread} aria-hidden />
<span>new</span>
</span>
) : (
<span>Latest</span>
)}
</motion.button>
)}
</AnimatePresence>
</div>
{footer}
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* Rows
* -----------------------------------------------------------------------------------------------*/
type Fmt = ReturnType<typeof useFormatters>;
function DaySeparator({ label, reduce }: { label: string; reduce: boolean }) {
return (
<motion.li
data-day={label}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: ease.out }}
className="flex items-center gap-3 px-6 pb-1 pt-4"
>
<span aria-hidden className="h-px flex-1 bg-line" />
<span suppressHydrationWarning className="text-[11px] font-medium leading-4 text-fg-3">
{label}
</span>
<span aria-hidden className="h-px flex-1 bg-line" />
</motion.li>
);
}
const radius: Record<Position, { own: string; other: string }> = {
single: { own: "rounded-[18px] rounded-br-[4px]", other: "rounded-[18px] rounded-bl-[4px]" },
first: { own: "rounded-[18px] rounded-br-[6px]", other: "rounded-[18px] rounded-bl-[6px]" },
middle: { own: "rounded-[18px] rounded-r-[6px]", other: "rounded-[18px] rounded-l-[6px]" },
last: { own: "rounded-[18px] rounded-tr-[6px] rounded-br-[4px]", other: "rounded-[18px] rounded-tl-[6px] rounded-bl-[4px]" },
};
type RowProps = {
row: Extract<ChatRow, { type: "message" }>;
author?: ChatAuthor;
fmt: Fmt;
arrived: boolean;
reduce: boolean;
withAvatars: boolean;
onJump: (id: string) => void;
showStatusLabel: boolean;
onReply?: (m: ChatMessage) => void;
onRetry?: (m: ChatMessage) => void;
swipe?: MotionValue<number>;
timesX: MotionValue<number>;
};
function MessageRow({ row, author, fmt, arrived, reduce, withAvatars, onJump, showStatusLabel, onReply, onRetry, swipe, timesX }: RowProps) {
const { message: m, own, position, date } = row;
const tail = position === "single" || position === "last";
const spaced = position === "single" || position === "first";
const name = author?.name ?? "Unknown";
const time = fmt.time(date);
const bare = !!m.content && m.bare;
const failed = m.status === "failed";
// Arrivals rise from where they came from: yours from the composer below, theirs a shorter way.
const rise = own ? 28 : 14;
const zero = useMotionValue(0);
const x = swipe ?? zero;
const timesOpacity = useTransform(timesX, [0, -SWIPE_TIMES], [0, 1]);
return (
<motion.li
data-message-id={m.id}
data-own={own || undefined}
data-position={position}
tabIndex={-1}
initial={arrived ? { height: 0, opacity: 0 } : false}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0, transition: { duration: reduce ? 0 : 0.18, ease: ease.inOut } }}
transition={reduce ? { duration: 0.15, height: { duration: 0 } } : { height: { duration: 0.3, ease: ease.out }, opacity: { duration: 0.12 } }}
className={cn("group/row relative flex px-4 outline-none", spaced ? "pt-2" : "pt-0.5", own ? "justify-end" : "justify-start", "focus-visible:bg-hover")}
>
{/* Swipe-left times: parked just past the right edge, pulled into view with the whole thread. */}
<motion.span aria-hidden style={{ opacity: timesOpacity }} className="absolute -right-14 top-1/2 w-12 -translate-y-1/2 text-left text-[10.5px] tabular text-fg-4" suppressHydrationWarning>
{time}
</motion.span>
{/* Reply hint revealed behind a message being swiped right. */}
{swipe && <ReplyHint x={swipe} />}
<motion.div
style={{ x }}
initial={arrived && !reduce ? { y: rise, scale: 0.96, filter: "blur(2px)" } : false}
animate={{ y: 0, scale: 1, filter: "blur(0px)" }}
transition={{ duration: 0.34, ease: ease.out }}
className={cn("flex min-w-0 max-w-[82%] items-end gap-2 pointer-fine:max-w-[68%]", own ? "origin-bottom-right flex-row-reverse" : "origin-bottom-left")}
>
{withAvatars && !own && <span className="w-7 shrink-0">{row.showAvatar && <Face author={author} />}</span>}
<div className={cn("flex min-w-0 flex-col", own ? "items-end" : "items-start")}>
<span
className="sr-only"
suppressHydrationWarning
>{`${own ? "You" : name}, ${fmt.full(date)}${own && m.status ? `, ${m.status === "failed" ? "not delivered" : m.status}` : ""}:`}</span>
{row.showName && (
<span aria-hidden className="mb-1 px-3 text-[11.5px] font-medium leading-4 text-fg-3">
{name}
</span>
)}
<div className={cn("relative flex min-w-0 items-center", own && "flex-row-reverse")}>
{bare ? (
<div data-bubble className="relative min-w-0">
{m.content}
</div>
) : (
<div
data-bubble
className={cn(
// Phones read at 15px; the pointer-sized layout drops to the 13px product scale.
"relative flex flex-col gap-1.5 px-3 py-[7px] text-[15px] leading-[21px] [overflow-wrap:anywhere] sm:text-[13px] sm:leading-5",
// A quote needs room to be read; the bubble won't shrink it below 13rem.
m.replyTo ? "min-w-52" : "min-w-0",
own ? "bg-fg text-frame" : "bg-(--bubble) text-fg",
radius[position][own ? "own" : "other"],
failed && "opacity-70",
)}
>
{m.replyTo && <ReplyQuote target={m.replyTo} onJump={onJump} className="-mx-1.5 mt-[-2px] w-auto" />}
{m.content}
{m.text && (
<p className="whitespace-pre-wrap text-pretty">
{m.text}
{m.edited && <span className="ml-1.5 text-[11px] opacity-60">edited</span>}
</p>
)}
{tail && <Tail own={own} />}
</div>
)}
{/* Hover and focus cluster: the time, your ticks, and reply. It floats beside the bubble, so it
costs the bubble no width; touch has no hover, so there a swipe shows times and replies. */}
<div
className={cn(
"absolute top-1/2 flex -translate-y-1/2 items-center gap-1.5 whitespace-nowrap transition-[opacity,translate] duration-150 ease-out pointer-coarse:hidden",
own ? "right-full mr-2" : "left-full ml-2",
"opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100",
own ? "translate-x-1" : "-translate-x-1",
"group-hover/row:translate-x-0 group-focus-within/row:translate-x-0 motion-reduce:translate-x-0",
own && "flex-row-reverse",
)}
>
<span suppressHydrationWarning className="text-[10.5px] leading-4 text-fg-4 tabular">
{time}
</span>
{own && m.status && !showStatusLabel && <MessageStatus status={m.status} onRetry={onRetry ? () => onRetry(m) : undefined} />}
{onReply && !failed && (
<button
type="button"
tabIndex={-1}
aria-label={`Reply to ${own ? "your message" : name}`}
onClick={() => onReply(m)}
className={cn(
"relative grid size-7 place-items-center rounded-full text-fg-3 outline-none",
"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75",
)}
>
<ReplyGlyph />
</button>
)}
</div>
</div>
{showStatusLabel && m.status && (
<div className="mt-1 flex justify-end px-1">
<MessageStatus status={m.status} label onRetry={onRetry ? () => onRetry(m) : undefined} />
</div>
)}
</div>
</motion.div>
</motion.li>
);
}
function ReplyHint({ x }: { x: MotionValue<number> }) {
const opacity = useTransform(x, [0, SWIPE_REPLY], [0, 1]);
const scale = useTransform(x, [0, SWIPE_REPLY], [0.5, 1]);
return (
<motion.span aria-hidden style={{ opacity, scale }} className="absolute left-3 top-1/2 grid size-7 -translate-y-1/2 place-items-center rounded-full bg-hover text-fg-2">
<ReplyGlyph />
</motion.span>
);
}
function Tail({ own }: { own: boolean }) {
// Overlaps the bubble by 5px so the join never shows a seam; the bubble's own color fills it.
return (
<svg
aria-hidden
width="11"
height="16"
viewBox="0 0 11 16"
className={cn("pointer-events-none absolute bottom-0", own ? "-right-[6px] text-fg" : "-left-[6px] -scale-x-100 text-(--bubble)")}
>
<path d="M0 0H5C5 7 6.6 12.4 10.6 15.6 10.8 15.8 10.6 16 10.4 16 6.6 16 3.4 14.8 0 12.6Z" fill="currentColor" />
</svg>
);
}
function Face({ author }: { author?: ChatAuthor }) {
const initials = (author?.name ?? "?")
.split(/\s+/)
.map((w) => w[0])
.slice(0, 2)
.join("")
.toUpperCase();
return (
<Avatar.Root className="grid size-7 select-none place-items-center overflow-hidden rounded-full bg-hover text-[10px] font-medium text-fg-2 shadow-[inset_0_0_0_1px_var(--line)]">
{author?.avatar && <Avatar.Image src={author.avatar} alt="" width={28} height={28} className="size-full object-cover" />}
<Avatar.Fallback delay={author?.avatar ? 400 : 0}>{initials}</Avatar.Fallback>
</Avatar.Root>
);
}
function TypingRow({ people, showAvatar, reduce }: { people: ChatAuthor[]; showAvatar: boolean; reduce: boolean }) {
const names = people.map((p) => p.name.split(" ")[0]);
const text = names.length === 1 ? `${names[0]} is typing` : names.length === 2 ? `${names[0]} and ${names[1]} are typing` : `${names.length} people are typing`;
return (
<motion.li
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0, transition: { duration: reduce ? 0 : 0.16 } }}
transition={reduce ? { duration: 0.15, height: { duration: 0 } } : { height: { duration: 0.26, ease: ease.out }, opacity: { duration: 0.12 } }}
className="flex px-4 pt-2"
>
<motion.div initial={reduce ? false : { y: 10, scale: 0.9 }} animate={{ y: 0, scale: 1 }} transition={spring.soft} className="flex origin-bottom-left items-end gap-2">
{showAvatar && (
<span className="w-7 shrink-0">
<Face author={people[0]} />
</span>
)}
<span role="status" aria-label={text} className="relative flex h-9 items-center gap-1 rounded-[18px] rounded-bl-[4px] bg-(--bubble) px-3.5">
{[0, 1, 2].map((i) => (
<span key={i} className="size-1.5 rounded-full bg-fg-3 motion-safe:animate-dot-wave" style={{ animationDelay: `${i * 0.16}s` }} />
))}
<Tail own={false} />
</span>
</motion.div>
</motion.li>
);
}
function DefaultEmpty() {
return (
<div className="flex max-w-[240px] flex-col items-center gap-1">
<p className="text-[13px] font-medium text-fg">No messages yet</p>
<p className="text-[12.5px] leading-[18px] text-fg-3">Messages you send and receive here will show up in order.</p>
</div>
);
}
function ReplyGlyph() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M6.5 4 3 7.5 6.5 11M3 7.5h6a4 4 0 0 1 4 4v1" />
</svg>
);
}05Props
ChatThread
| Prop | Type | Default | Description |
|---|---|---|---|
| messages* | ChatMessage[] | — | Oldest first: id, authorId, at, and text or content. Own messages take status; any message can carry replyTo and edited. |
| authors* | ChatAuthor[] | — | Everyone in the thread: id, name and an optional avatar URL. |
| currentUserId* | string | — | Whose messages sit on the right in the filled bubble. |
| typing | string[] | [] | Author ids typing now. Shows a typing bubble that arrives like a message. |
| onReply | (message: ChatMessage) => void | — | From the hover button, R or Enter on a focused message, or a swipe right on touch. |
| onRetry | (message: ChatMessage) => void | — | Resend a message whose status is failed. |
| showNames | boolean | — | Names and avatars on other people's groups. Defaults to on when more than two people have written. |
| groupWindow | number | 5 | Minutes. Messages from one person this close together share a group and one tail. |
| locale | string | — | For times and days. Defaults to the reader's. |
| timeZone | string | — | For times and day breaks. Defaults to the reader's. |
| empty | React.ReactNode | — | Shown when there are no messages. |
| hasOlder | boolean | false | More history exists above. |
| onLoadOlder | () => Promise<unknown> | void | — | Called near the top. Prepended messages keep the reader's place and don't animate in. |
| footer | React.ReactNode | — | The composer, under the thread. The jump-to-latest pill floats just above it. |
| label | string | "Conversation" | The log's accessible name. |
groupMessages
| Prop | Type | Default | Description |
|---|---|---|---|
| (messages, currentUserId, options) | (ChatMessage[], string, { windowMinutes?, timeZone?, showNames? }) => ChatRow[] | — | The grouping on its own: day rows, and each message's position (single, first, middle, last) for building your own layout. |
06Notes
Behavior
- Stays pinned to the newest message while you're at the bottom; scroll up and it leaves you alone, with a pill that counts what arrived. Sending your own message always brings you back down.
- Groups by sender, day and a five-minute window: grouped corners tighten on the sender's side and only the last bubble gets a tail. Day breaks read Today, Yesterday, a weekday, then a date.
- Times, your ticks and reply float beside a bubble on hover or focus without taking its width. On touch, swipe the thread left to see every time and swipe a message right to reply.
- The last message you sent carries its words (Sending…, Delivered, Read); a failed one says Not delivered with Try again. Dates render in UTC for the server, then switch to the reader's zone after hydration.
- Scrolling by hand floats the day you're reading at the top, and it fades a second after you stop. Arrivals, resizes and pinning never show it.
Motion
- A new message's row opens its height over 300ms on the expo ease-out while the bubble rises out of a 2px blur from 0.96: 28px for yours, from the composer, 14px for theirs. Pinned scrolling follows the height every frame, so older messages glide up instead of jumping.
- The hover cluster fades in 150ms and slides 4px toward the bubble. The jump pill springs up 8px on the snappy spring and its count rolls.
- Swipes follow the finger 1:1, rubber-band past 56–64px at a quarter speed, and settle back on the snappy or soft spring.
- Reduced motion drops the rise, the height growth and the slides; messages and the pill fade in over 150ms and jumps are instant.
Accessibility
- The thread is a named log, so new messages are announced politely; each message starts with a hidden "Maya Chen, Tuesday, September 22 at 9:41 AM:" so grouped bubbles still say who and when.
- One tab stop: arrows walk messages, Home and End jump, R or Enter replies, Escape returns to the log. The focused message shows its time and actions.
- The jump pill says how many new messages it will show you; hover actions are also reachable by keyboard shortcut, so nothing is pointer-only.