Patterns · Collaboration
MessageTimeline
Scrolling conversation: day separators, a “New messages” divider, older history on scroll-up and stick-to-bottom.
Conversation with older history
import { useCallback, useState } from 'react';
import { Button } from '@rookcom/monarch-react';
import { MessageRow, MessageTimeline, type MessageTimelineItem } from '@rookcom/monarch-react/collaboration';
const NOW = Date.parse('2026-03-02T12:00:00Z');
const PEOPLE: Record<string, string> = {
ana: 'Ana Lopez',
ben: 'Ben Okafor',
chloe: 'Chloé Martin',
};
const LINES = [
'Morning! Is the launch checklist up to date?',
'Mostly. Legal sign-off is still open.',
'I’ll chase it after standup.',
'Thanks. The press kit is in the shared folder.',
'Looks good. One typo on page two.',
'Fixed, and the screenshots are refreshed.',
'Great. Shall we freeze copy at 3pm?',
'Works for me.',
];
type Demo = MessageTimelineItem & { text: string };
/** Minutes before `endAt` for each message: pairs close together group, the first ones fall on the previous day. */
const OFFSETS = [960, 957, 300, 240, 238, 120, 30, 28];
const OLDER_OFFSETS = [60, 58, 20, 18];
function history(count: number, endAt = NOW - 30 * 60_000): Demo[] {
const authors = ['ana', 'ana', 'ben', 'chloe', 'chloe', 'ana', 'ben', 'ben'];
const offsets = count === OFFSETS.length ? OFFSETS : OLDER_OFFSETS;
return Array.from({ length: count }, (_, i) => ({
id: `m${endAt}-${i}`,
time: endAt - (offsets[i] ?? 0) * 60_000,
authorId: authors[i % authors.length] ?? 'ana',
text: LINES[i % LINES.length] ?? '',
}));
}
const renderMessage = (m: Demo, ctx: { grouped: boolean }) => (
<MessageRow
author={{ name: PEOPLE[m.authorId ?? ''] ?? 'Someone', seed: m.authorId ?? 'unknown' }}
time={m.time}
now={NOW}
grouped={ctx.grouped}
timeDisplay="clock"
timeZone="UTC"
>
{m.text}
</MessageRow>
);
function LiveDemo() {
const [items, setItems] = useState<Demo[]>(() => history(8));
const [older, setOlder] = useState(2);
const [loadingOlder, setLoadingOlder] = useState(false);
const loadOlder = useCallback(() => {
setLoadingOlder(true);
setTimeout(() => {
setItems((list) => {
const first = Number(list[0]?.time ?? NOW);
const page = history(4, first - 3 * 3_600_000).map((m, i) => ({
...m,
id: `o${older}-${i}`,
}));
return [...page, ...list];
});
setOlder((n) => n - 1);
setLoadingOlder(false);
}, 700);
}, [older]);
const send = () =>
setItems((list) => [
...list,
{ id: `n${list.length}`, time: NOW, authorId: 'ana', text: 'New message from the demo.' },
]);
return (
<div style={{ width: '100%', display: 'grid', gap: 8 }}>
<div
style={{
height: 340,
border: '1px solid var(--mn-color-border-subtle)',
borderRadius: 'var(--mn-radius-lg)',
overflow: 'hidden',
}}
>
<MessageTimeline
items={items}
renderItem={renderMessage}
firstUnreadId={items[items.length - 2]?.id ?? null}
hasOlder={older > 0}
loadingOlder={loadingOlder}
onLoadOlder={loadOlder}
header={<p style={{ margin: 0, fontSize: 13 }}>This is the start of #launch.</p>}
status="Ben Okafor is typing…"
now={NOW}
timeZone="UTC"
/>
</div>
<div>
<Button size="sm" onClick={send}>
Add a message
</Button>
</div>
</div>
);
}
<LiveDemo />Usage examples
In your own virtualiser
itemContent={(i, m) => {
const prev = messages[i - 1];
const newDay = !prev || formatMessageDay(prev.time) !== formatMessageDay(m.time);
return (
<>
{newDay && <MessageTimelineDaySeparator>{formatMessageDay(m.time)}</MessageTimelineDaySeparator>}
<MessageRow grouped={!newDay && shouldGroupMessages(prev, m)} … />
</>
);
}}Purpose
Holds a conversation’s messages in a scrolling log that behaves like chat: it opens at the newest message, follows new ones while the reader is at the bottom, loads older history as they scroll up without the view jumping, and announces arrivals politely.
Use when
- Channels, direct messages and threads.
- Ticket and request conversations, interleaved with system rows.
- Any reverse-chronological thread whose newest item sits next to a composer.
Avoid when
- Feeds read top-down, newest first (activity, notifications): use ActivityTimeline.
- Performance: MessageTimeline is not virtualised; every loaded message stays in the DOM. For histories of many thousands of loaded messages (Rook Chat), keep your virtualiser (react-virtuoso) and render MessageRow with the exported
MessageTimelineDaySeparator,MessageTimelineUnreadDivider,shouldGroupMessagesandformatMessageDay.
Design principles
- The reader’s place is sacred: prepending older pages and late-loading images never move what they are reading.
- Follow new messages only when the reader is at the bottom; otherwise count them on a “Jump to latest” button.
- One live region for messages, a separate status for typing, so nothing is announced twice.
- Data and fetching stay in the product; the timeline only asks for older pages through
onLoadOlder.
API reference
import { MessageTimeline, MessageTimelineDaySeparator, MessageTimelineUnreadDivider, formatMessageDay, shouldGroupMessages } from '@rookcom/monarch-react/collaboration';| Export | Kind | Rendering | Root class |
|---|---|---|---|
MessageTimeline | component | client | .mn-message-timeline |
MessageTimelineDaySeparator | component | client | .mn-message-timeline |
MessageTimelineUnreadDivider | component | client | .mn-message-timeline |
formatMessageDay | utility | client | – |
shouldGroupMessages | utility | client | – |
Generic over your item type: items need id, time and an optional authorId; renderItem(item, { grouped, index, previous }) draws each one.
handleRef exposes scrollToBottom() and scrollToItem(id); the ref is forwarded to the root <div>.
shouldGroupMessages, formatMessageDay, MessageTimelineDaySeparator and MessageTimelineUnreadDivider are exported for products that keep their own virtualiser (react-virtuoso and similar).
Not virtualised: every loaded message is in the DOM. Paging keeps that bounded for typical conversations.
Generated from the TypeScript declarations of packages/react/src/patterns/collaboration/message-timeline.
Properties
MessageTimeline
A scrolling conversation: MessageRows separated by day, a "New messages" divider, older history loaded on scroll-up with the view held still, and new messages followed while the reader is at the bottom. A polite `role="log"` announces messages as they arrive.
| Prop | Type | Description |
|---|---|---|
items required | readonly T[] | Loaded messages, oldest first. |
renderItem required | (item: T, context: MessageTimelineRenderContext<T>) => ReactNode | Renders one item, normally a MessageRow with `grouped={context.grouped}`. |
atBottomThreshold | number | How close to the bottom, in pixels, counts as "at the bottom". Default 64. |
className | string | |
empty | ReactNode | Shown instead of the list when there are no items and nothing is loading. |
error | ReactNode | First-load failure, for example an InlineErrorRetry. Replaces the list. |
firstUnreadId | string | null | The first unread message. The "New messages" divider is drawn above it. Keep it fixed while the conversation is open, so the divider does not move as the reader catches up. |
footer | ReactNode | Content after the last message, such as messages still sending. Inside the log. |
groupWindowMs | number | Consecutive messages from one author closer than this are grouped. Default 5 minutes. |
handleRef | Ref<MessageTimelineHandle> | Imperative scrolling: `scrollToBottom()` and `scrollToItem(id)`. |
hasOlder | boolean | There is older history to load. |
header | ReactNode | Content above the first message once all history is loaded: the conversation intro. |
highlightedId | string | null | Highlights a message, for example after a deep link. |
id | string | |
jumpToLatest | boolean | Show a "Jump to latest" button while scrolled up. Default `true`. |
loading | boolean | First load: shows a skeleton. |
loadingOlder | boolean | Older history is being fetched. |
loadOlderThreshold | number | Distance from the top, in pixels, that triggers `onLoadOlder`. Default 240. |
locale | string | |
messages | Partial<MessageTimelineMessages> | |
now | DayInput | Reference time for "Today" and "Yesterday". |
olderError | boolean | The last fetch of older history failed. Shows Retry and pauses loading on scroll. |
onAtBottomChange | ((atBottom: boolean) => void) | Called when the reader reaches or leaves the bottom, for example to mark the conversation read. |
onLoadOlder | (() => void) | Loads the next page of older history. Called when the reader scrolls near the top (or the history does not fill the view), and from the "Load older messages" button. The scroll position is kept when the items are prepended. |
onRetryOlder | (() => void) | Called by the Retry button after `olderError`. Default `onLoadOlder`. |
ref | Ref<HTMLDivElement> | |
scrollerClassName | string | Class for the scrolling log element. |
scrollToId | string | null | Scrolls this message into view whenever it changes and is loaded (deep links). |
status | ReactNode | Transient status such as "Ana is typing…". Rendered outside the log in its own `role="status"` region, so it is announced once and never recorded as a message. |
stickToBottom | boolean | Follow new messages while the reader is at the bottom. Default `true`. |
timeZone | string | IANA time zone used to split days. Default: the viewer's zone. |
MessageTimelineDaySeparator
The "Today" line between days. Exported for virtualised lists.
| Prop | Type | Description |
|---|---|---|
children required | ReactNode | |
className | string | |
id | string | |
label | string | The spoken text, when `children` is not plain text. |
MessageTimelineUnreadDivider
The "New messages" line before the first unread message. Exported for virtualised lists.
| Prop | Type | Description |
|---|---|---|
children required | ReactNode | |
className | string | |
id | string | |
label | string | The spoken text, when `children` is not plain text. |
Variants
| Prop | Values | Description |
|---|---|---|
stickToBottom | 'true''false' | Follow new messages while at the bottom. Off for read-only transcripts. |
jumpToLatest | 'true''false' | Floating “Jump to latest” (or “3 new messages”) button while scrolled up. |
States
| State | Exposed as | Behaviour |
|---|---|---|
| Loading | – | Skeleton rows; the log is aria-busy. |
| Error | – | The error node replaces the list (for example InlineErrorRetry). |
| Empty | – | empty, or header when no empty node is given. |
| Loading older | – | LoadMore at the top shows “Loading older messages…”; the log is busy so old history is not read as new. |
| Older failed | – | “Couldn’t load older messages.” with Retry; scroll loading pauses. |
| Scrolled up | – | Jump button with a count of messages that arrived meanwhile. |
| Highlighted | data-highlighted | A deep-linked message flashes (a steady tint with reduced motion). |
Interaction behaviour
- Opens at the newest message, or at
scrollToIdwhen given. - Scrolling within
loadOlderThreshold(240px) of the top callsonLoadOlderonce per load; it is also called while the history is shorter than the view. - When older items are prepended the scroll offset grows by exactly their height, so the view stays still.
- New items scroll into view only when the reader was at the bottom;
onAtBottomChangereports reaching or leaving it (for marking read). - Jump to latest scrolls down (instantly with reduced motion) and keeps focus in the log.
Keyboard support
| Keys | Action |
|---|---|
| Tab | Focuses the log, then the controls inside each message. |
| Arrow Up / Down, Page Up / Down | Scroll the focused log (native scrolling). |
| Enter / Space | On “Load older messages” or “Jump to latest”, activates it. |
Accessibility
- Role
- log
- Accessible name
- “Messages” by default (
messages.label).
role="log"witharia-live="polite"andaria-relevant="additions": new messages are read without interrupting.- While older history loads and settles the log is
aria-busy, so prepended pages are not announced as new. - Typing and other transient text goes in a separate
role="status"outside the log. - Day separators and the unread divider are
role="separator"with their text as the name. - The log is a focusable scroll region, so keyboard users can scroll it.
Responsive behaviour
- Fills its container’s height; give the parent a height (flex child or fixed).
- Few messages sit at the bottom, next to the composer.
- Rows reflow at any width down to 320px; the jump button stays in the corner.
Theme support
- Separators use
border-subtleandtext-muted; the unread divider usesdanger-stronganddanger-border. - The jump button is a raised surface with
shadow-md; the deep-link flash usesaccent-subtle.
Do and don't
Do
- Keep
firstUnreadIdfixed while the conversation is open. - Put typing indicators in
status, never in the log. - Give the timeline a bounded height so it can scroll.
Don't
- Do not reverse the items: pass them oldest first.
- Do not change item ids after sending; reconcile the pending id with the server id in place.
- Do not call
onLoadOlderwork twice: return early while a page is in flight.
Related components
- MessageRow: Each message.
- MessageComposer: The input under the timeline (planned).
- LoadMore: Used for the “Load older messages” control.
- ActivityTimeline: Top-down event feeds.
Dependencies
Testing coverage
- Polite log with day separators, unread divider and grouping rules.
- Opens at the bottom and follows appended messages while there; not with
stickToBottom={false}. - Keeps the view still when older messages are prepended.
- Loads older near the top once per load, from the button, and until the view is filled; pauses after an error and retries.
- Counts messages that arrive while scrolled up; Jump to latest scrolls down and keeps focus.
- Typing status outside the log; header, footer, empty, loading and error states.
- Deep link scroll and highlight; imperative handle; focusable scroll region; translations.
shouldGroupMessagesandformatMessageDay.
| Check | Covered |
|---|---|
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/message-timeline; Cross-browser axe: /components/message-timeline |
Tests: packages/react/src/patterns/collaboration/message-timeline