---
id: "packages/active-ui/message-scroller"
title: "MessageScroller"
description: "A scrolling conversation that opens at the newest message, follows new ones, and offers a jump back."
url: "/packages/active-ui/message-scroller"
locale: "en"
revision: "c26425721e1362a2"
---

# MessageScroller

A message scroller holds a conversation that grows at the end. It opens at the newest message,
follows new messages while the reader is at the end (with `autoScroll`), stays where it is when the
reader scrolled up to read, and then shows a button to jump back. It is built on the headless message scroller of
`@shadcn/react`.

**Example: A conversation that follows new messages**

Client: Runs entirely in your browser. Source: `examples/ui/message-scroller.tsx`.

```tsx
// apps/docs/examples/ui/message-scroller.tsx
'use client';

import {useState} from 'react';

import {
  Bubble,
  BubbleContent,
} from '@workspace/active-ui/components/primitives/bubble';
import {Button} from '@workspace/active-ui/components/primitives/button';
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@workspace/active-ui/components/primitives/message-scroller';

const START = Array.from({length: 12}, (_, index) => ({
  id: `m${index + 1}`,
  text: `Status update ${index + 1}`,
  mine: index % 3 === 2,
}));

/**
 * A conversation that scrolls: it opens at the newest message, follows new
 * messages while you are at the end (`autoScroll`), and shows a button to jump back when
 * you have scrolled up. New messages are added by the button below (no
 * server: they are made in the browser).
 */
export default function MessageScrollerExample() {
  const [messages, setMessages] = useState(START);
  const add = () =>
    setMessages((current) => [
      ...current,
      {
        id: `m${current.length + 1}`,
        text: `Status update ${current.length + 1}`,
        mine: false,
      },
    ]);
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <MessageScrollerProvider autoScroll>
        <MessageScroller className="h-72 rounded-lg border">
          <MessageScrollerViewport aria-label="Conversation">
            <MessageScrollerContent className="gap-3 p-4">
              {messages.map((message) => (
                <MessageScrollerItem key={message.id} messageId={message.id}>
                  <Bubble
                    variant={message.mine ? 'default' : 'muted'}
                    align={message.mine ? 'end' : 'start'}
                  >
                    <BubbleContent>{message.text}</BubbleContent>
                  </Bubble>
                </MessageScrollerItem>
              ))}
            </MessageScrollerContent>
          </MessageScrollerViewport>
          <MessageScrollerButton label="Jump to the newest message" />
        </MessageScroller>
      </MessageScrollerProvider>
      <Button variant="outline" className="self-start" onClick={add}>
        Add a message
      </Button>
    </div>
  );
}
```

## Usage

```tsx
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@workspace/active-ui/components/primitives/message-scroller';

<MessageScrollerProvider autoScroll>
  <MessageScroller className="h-96">
    <MessageScrollerViewport aria-label="Conversation">
      <MessageScrollerContent>
        {messages.map((message) => (
          <MessageScrollerItem key={message.id} messageId={message.id}>
            …
          </MessageScrollerItem>
        ))}
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton label="Jump to the newest message" />
  </MessageScroller>
</MessageScrollerProvider>;
```

## Anatomy

- `MessageScrollerProvider`: the scroll state. `autoScroll` (default `false`) follows new messages
  while the reader is at the end,
  `defaultScrollPosition` (`end`, `start` or `last-anchor`) chooses where it opens, and
  `scrollEdgeThreshold` how close to the end counts as "at the end".
- `MessageScroller`: the frame; give it a height.
- `MessageScrollerViewport`: the scrolling region, `role="region"`; `preserveScrollOnPrepend`
  keeps the position when older messages load above.
- `MessageScrollerContent`: the list, `role="log"`, a polite live region for new messages.
- `MessageScrollerItem`: one message; `messageId` lets you jump to it, and `scrollAnchor` marks the
  message to open at with `last-anchor`.
- `MessageScrollerButton`: the jump button, shown only when the reader is away from that end;
  `direction` `end` (default) or `start`.

## The hooks

Three hooks read and move the scroll position, for controls of your own beside the built-in jump
button. They work inside `MessageScrollerProvider` only; outside it they throw.

- `useMessageScroller()` returns `scrollToStart(options?)`, `scrollToEnd(options?)` and
  `scrollToMessage(messageId, options?)`. The options are `behavior` (`auto` by default, or
  `smooth`), `align` (`start` by default, `center`, `end` or `nearest`; for `scrollToMessage`) and
  `scrollMargin` in pixels. Each returns `true` when it scrolled or scheduled the scroll, `false`
  when there is nothing to scroll yet (no viewport) or no message with that id is rendered. A jump
  to a message that has not rendered yet, while the list is still empty, waits for it.
- `useMessageScrollerScrollable()` returns a `MessageScrollerScrollable`, `{start, end}`: whether
  there is more content above and below the visible part, beyond `scrollEdgeThreshold` (8 px by
  default). While `autoScroll` follows new messages, `end` is `false`. Use it to enable or hide
  your own buttons, as `MessageScrollerButton` does.
- `useMessageScrollerVisibility()` returns a `MessageScrollerVisibilityState`,
  `{visibleMessageIds, currentAnchorId}`: the `messageId`s of the items in view, in order, and the
  last `scrollAnchor` item whose top has scrolled past the top of the viewport (`null` if none).
  "In view" starts below a top band of `scrollMargin` plus `scrollPreviousItemPeek` (64 px by
  default), so a message that only peeks in at the top does not count. It watches the items only
  while a component uses the hook, and the first state arrives a frame after it mounts.

**Example: Scrolling and reading the position with the three hooks**

Client: Runs entirely in your browser. Source: `examples/ui/message-scroller-hooks.tsx`.

```tsx
// apps/docs/examples/ui/message-scroller-hooks.tsx
'use client';

import {
  Bubble,
  BubbleContent,
} from '@workspace/active-ui/components/primitives/bubble';
import {Button} from '@workspace/active-ui/components/primitives/button';
import {
  MessageScroller,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
  useMessageScrollerScrollable,
  useMessageScrollerVisibility,
} from '@workspace/active-ui/components/primitives/message-scroller';

const MESSAGES = Array.from({length: 20}, (_, index) => ({
  id: `m${index + 1}`,
  text: `Status update ${index + 1}`,
}));

/**
 * The three hooks, inside the provider: buttons that scroll with
 * `useMessageScroller`, and a line that reads `useMessageScrollerScrollable`
 * (is there more above or below?) and `useMessageScrollerVisibility` (which
 * messages are in view). Hooks outside `MessageScrollerProvider` throw.
 */
function Controls() {
  const {scrollToStart, scrollToEnd, scrollToMessage} = useMessageScroller();
  const {start, end} = useMessageScrollerScrollable();
  const {visibleMessageIds} = useMessageScrollerVisibility();
  const first = visibleMessageIds[0]?.slice(1);
  const last = visibleMessageIds.at(-1)?.slice(1);
  return (
    <div className="flex flex-col gap-2">
      <div className="flex flex-wrap gap-2">
        <Button
          variant="outline"
          size="sm"
          disabled={!start}
          onClick={() => scrollToStart({behavior: 'smooth'})}
        >
          First message
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => scrollToMessage('m10', {align: 'center'})}
        >
          Message 10
        </Button>
        <Button
          variant="outline"
          size="sm"
          disabled={!end}
          onClick={() => scrollToEnd({behavior: 'smooth'})}
        >
          Newest message
        </Button>
      </div>
      <p className="text-muted-foreground text-sm" data-message-scroller-state>
        {first && last
          ? `Messages ${first} to ${last} of ${MESSAGES.length} counted as in view.`
          : 'No message counted as in view yet.'}{' '}
        More above: {start ? 'yes' : 'no'}. More below: {end ? 'yes' : 'no'}.
      </p>
    </div>
  );
}

export default function MessageScrollerHooksExample() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <MessageScrollerProvider defaultScrollPosition="start">
        <Controls />
        <MessageScroller className="h-60 rounded-lg border">
          <MessageScrollerViewport aria-label="Status updates">
            <MessageScrollerContent className="gap-3 p-4">
              {MESSAGES.map((message) => (
                <MessageScrollerItem key={message.id} messageId={message.id}>
                  <Bubble variant="muted" align="start">
                    <BubbleContent>{message.text}</BubbleContent>
                  </Bubble>
                </MessageScrollerItem>
              ))}
            </MessageScrollerContent>
          </MessageScrollerViewport>
        </MessageScroller>
      </MessageScrollerProvider>
    </div>
  );
}
```

## What your application provides

- A name for the viewport (`aria-label`), and `label` of an icon jump button (required).
- The messages, with a stable `messageId`, and loading older ones when the reader reaches the
  start.

## Accessibility and keyboard

- The viewport is a focusable region: the arrow keys, `Page Up`, `Page Down`, `Home` and `End`
  scroll it.
- The content is a log: new messages are announced politely.

## API

**MessageScrollerProvider**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |

MessageScrollerProvider has no props of its own.

**MessageScroller**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |

MessageScroller has no props of its own.

**MessageScrollerViewport**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |

MessageScrollerViewport has no props of its own.

**MessageScrollerContent**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |

MessageScrollerContent has no props of its own.

**MessageScrollerItem**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |

MessageScrollerItem has no props of its own.

**MessageScrollerButton**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | no | A visible label instead of the icon, which then names the button. |
| `label` | `string` | no | The accessible name of the icon button, such as "Jump to the newest message". |
| `size` | `"default" \| "icon" \| "icon-lg" \| "icon-sm" \| "icon-xs" \| "lg" \| "sm" \| "xs" \| null` | no | The height and padding. `xs`, `sm`, `default` and `lg` for a button with text; `icon-xs`, `icon-sm`, `icon` and `icon-lg` for a square button with only an icon, which then needs an accessible name (`aria-label`). |
| `variant` | `"default" \| "destructive" \| "ghost" \| "link" \| "outline" \| "secondary" \| null` | no | The visual weight of the button. `default` for the main action of a screen or form; `outline` and `secondary` for other actions; `ghost` for a quiet action in a toolbar or a list; `destructive` for an action that deletes or cannot be undone; `link` for an action that reads as a link. Every variant but `link` sinks 1px while pressed; a link-styled action keeps its text and icon in place (`button-link-still`). |

**useMessageScroller**: `() => { scrollToEnd: (options?: MessageScrollerScrollOptions) => boolean; scrollToMessage: (messageId: string, options?: MessageScrollerScrollOptions) => boolean; scrollToStart: (options?: MessageScrollerScrollOptions) => boolean; }`

Returns an object with:

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `scrollToEnd` | `(options?: MessageScrollerScrollOptions) => boolean` | yes |  |
| `scrollToMessage` | `(messageId: string, options?: MessageScrollerScrollOptions) => boolean` | yes |  |
| `scrollToStart` | `(options?: MessageScrollerScrollOptions) => boolean` | yes |  |

**useMessageScrollerScrollable**: `() => MessageScrollerScrollable`

Returns an object with:

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `start` | `boolean` | yes |  |
| `end` | `boolean` | yes |  |

**useMessageScrollerVisibility**: `() => MessageScrollerVisibilityState`

Returns an object with:

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `currentAnchorId` | `string \| null` | yes |  |
| `visibleMessageIds` | `string[]` | yes |  |
