Source: https://monarchui.dev/components/message-timeline/

Patterns · Collaboration

# MessageTimeline

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

@rookcom/monarch-react/collaboration

Pattern

## Conversation with older history

## 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

```tsx
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 `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

| 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"` 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.

## Related components

- [MessageRow](https://monarchui.dev/components/message-row/): Each message.
- [MessageComposer](https://monarchui.dev/components/message-composer/): The input under the timeline (planned).
- [LoadMore](https://monarchui.dev/components/load-more/): Used for the “Load older messages” control.
- [ActivityTimeline](https://monarchui.dev/components/activity-timeline/): Top-down event feeds.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [LoadMore](https://monarchui.dev/components/load-more/), [Skeleton](https://monarchui.dev/components/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`.

| 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`

## All example code

### Conversation with older history

```tsx
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 />
```

### In your own virtualiser

```tsx
<p style={{ margin: 0, fontSize: 13 }}>
  Use the exported parts inside react-virtuoso or similar.
</p>
```
