Skip to content

Patterns · Collaboration

MessageTimeline

Scrolling conversation: day separators, a “New messages” divider, older history on scroll-up and stick-to-bottom.

@rookcom/monarch-react/collaborationPattern

Conversation with older history

Ana Lopez
Morning! Is the launch checklist up to date?
Ana Lopez
Mostly. Legal sign-off is still open.
Ben Okafor
I’ll chase it after standup.
Chloé Martin
Thanks. The press kit is in the shared folder.
Chloé Martin
Looks good. One typo on page two.
Ana Lopez
Fixed, and the screenshots are refreshed.
Ben Okafor
Great. Shall we freeze copy at 3pm?
Ben Okafor
Works for me.
Ben Okafor is typing…

Usage examples

In your own virtualiser

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, shouldGroupMessages and formatMessageDay.

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';
ExportKindRenderingRoot class
MessageTimelinecomponentclient.mn-message-timeline
MessageTimelineDaySeparatorcomponentclient.mn-message-timeline
MessageTimelineUnreadDividercomponentclient.mn-message-timeline
formatMessageDayutilityclient–
shouldGroupMessagesutilityclient–

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.

PropTypeDescription
items requiredreadonly T[]Loaded messages, oldest first.
renderItem required(item: T, context: MessageTimelineRenderContext<T>) => ReactNodeRenders one item, normally a MessageRow with `grouped={context.grouped}`.
atBottomThresholdnumberHow close to the bottom, in pixels, counts as "at the bottom". Default 64.
classNamestring
emptyReactNodeShown instead of the list when there are no items and nothing is loading.
errorReactNodeFirst-load failure, for example an InlineErrorRetry. Replaces the list.
firstUnreadIdstring | nullThe 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.
footerReactNodeContent after the last message, such as messages still sending. Inside the log.
groupWindowMsnumberConsecutive messages from one author closer than this are grouped. Default 5 minutes.
handleRefRef<MessageTimelineHandle>Imperative scrolling: `scrollToBottom()` and `scrollToItem(id)`.
hasOlderbooleanThere is older history to load.
headerReactNodeContent above the first message once all history is loaded: the conversation intro.
highlightedIdstring | nullHighlights a message, for example after a deep link.
idstring
jumpToLatestbooleanShow a "Jump to latest" button while scrolled up. Default `true`.
loadingbooleanFirst load: shows a skeleton.
loadingOlderbooleanOlder history is being fetched.
loadOlderThresholdnumberDistance from the top, in pixels, that triggers `onLoadOlder`. Default 240.
localestring
messagesPartial<MessageTimelineMessages>
nowDayInputReference time for "Today" and "Yesterday".
olderErrorbooleanThe 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`.
refRef<HTMLDivElement>
scrollerClassNamestringClass for the scrolling log element.
scrollToIdstring | nullScrolls this message into view whenever it changes and is loaded (deep links).
statusReactNodeTransient 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.
stickToBottombooleanFollow new messages while the reader is at the bottom. Default `true`.
timeZonestringIANA time zone used to split days. Default: the viewer's zone.

MessageTimelineDaySeparator

The "Today" line between days. Exported for virtualised lists.

PropTypeDescription
children requiredReactNode
classNamestring
idstring
labelstringThe spoken text, when `children` is not plain text.

MessageTimelineUnreadDivider

The "New messages" line before the first unread message. Exported for virtualised lists.

PropTypeDescription
children requiredReactNode
classNamestring
idstring
labelstringThe spoken text, when `children` is not plain text.

Variants

PropValuesDescription
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

StateExposed asBehaviour
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.
Highlighteddata-highlightedA deep-linked message flashes (a steady tint with reduced motion).

Interaction behaviour

  • Opens at the newest message, or at scrollToId when given.
  • Scrolling within loadOlderThreshold (240px) of the top calls onLoadOlder once 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; onAtBottomChange reports reaching or leaving it (for marking read).
  • Jump to latest scrolls down (instantly with reduced motion) and keeps focus in the log.

Keyboard support

KeysAction
TabFocuses the log, then the controls inside each message.
Arrow Up / Down, Page Up / DownScroll the focused log (native scrolling).
Enter / SpaceOn “Load older messages” or “Jump to latest”, activates it.

Accessibility

Role
log
Accessible name
“Messages” by default (messages.label).
  • role="log" with aria-live="polite" and aria-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-subtle and text-muted; the unread divider uses danger-strong and danger-border.
  • The jump button is a raised surface with shadow-md; the deep-link flash uses accent-subtle.

Do and don't

Do

  • Keep firstUnreadId fixed 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 onLoadOlder work twice: return early while a page is in flight.

Dependencies

KindDepends on
npm packagesreact
Monarch componentsLoadMore, Skeleton

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.
  • shouldGroupMessages and formatMessageDay.
CheckCovered
Axe in jsdomYes
Server renderingYes
Browser suitesVisual baselines: /__gallery/message-timeline; Cross-browser axe: /components/message-timeline

Tests: packages/react/src/patterns/collaboration/message-timeline