---
title: 'Message scroller'
description: 'How Ai.Conversation scrolls while new messages and replies stream in, so the user keeps their place and context.'
version: 11.17.0
generatedAt: 2026-11-05T09:55:47.097Z
checksum: 090b7d977ba4be5e2c4c04d199a30a4048416c59f443a56985df2f80629d9c40
---

# Message scroller

**Experimental:** The AI extension and its component APIs may change in the future. Please give us feedback on your experience with them.

A chat list that only grows at the bottom is easy to scroll: stay at the end and show the newest message. Streaming breaks that. A reply grows while the user reads it, parts of it open and close, and older messages can be loaded above. When the view follows every change, the user loses their place.

[Ai.Conversation](/web/patterns-and-extensions/ai/Conversation/) takes care of the scrolling, so that:

- The user decides when the view moves. Content that changes does not move what the user reads.
- A new question starts near the top, with a peek of the previous reply, so the new turn stays connected to what came before.
- The view only follows a streaming reply when the user is at the end, and stops when the user scrolls up.
- Saved conversations open where the last turn starts, not in the middle of a long reply.

## Usage

The scrolling is built into `Ai.Conversation`. Give it a height, and put the messages inside.

```tsx
import {
  Conversation,
  Message,
  Response,
} from '@dnb/eufemia/extensions/ai'

function Chat({
  messages,
}: {
  messages: Array<{
    id: string
    from: 'user' | 'assistant'
    text: string
    isStreaming?: boolean
  }>
}) {
  return (
    <Conversation style={{ height: '40rem' }}>
      {messages.map((message) => (
        <Message key={message.id} id={message.id} from={message.from}>
          {message.from === 'user' ? (
            message.text
          ) : (
            <Response
              parseIncompleteMarkdown={message.isStreaming ?? false}
            >
              {message.text}
            </Response>
          )}
        </Message>
      ))}
    </Conversation>
  )
}
```

## Anchoring turns

A turn starts with a message from the user. When the user sends a message, it is scrolled near the top, and the reply streams in below it. The view stays where it is while the reply streams in, so the user can read the start of the reply in peace. When the reply goes below the view, a button to go to the latest message is shown.

Messages with `from="user"` start a turn.

Send a few messages and see where each new question is placed.


```tsx
const Chat = () => {
  const [scrollBehavior, setScrollBehavior] = useState<'turn' | 'end'>('turn');
  const {
    messages,
    status,
    sendMessage,
    stop
  } = useChatSimulation();
  return <div style={{
    ...chatStyle,
    height: '32rem'
  }}>
              <ToggleButton.Group label="Scroll behavior" value={scrollBehavior} onChange={({
      value
    }) => setScrollBehavior(value as 'turn' | 'end')}>
                <ToggleButton value="turn" text="turn" />
                <ToggleButton value="end" text="end" />
              </ToggleButton.Group>

              <Conversation scrollBehavior={scrollBehavior} style={{
      flex: '1 1 auto'
    }}>
                {messages.map(message => <Message key={message.id} id={message.id} from={message.role}>
                    <DemoMessageContent message={message} />
                  </Message>)}
                {status === 'submitted' && <MessageIndicator />}
              </Conversation>

              <PromptInput variant="compact" placeholder="Try: Compare my spending" isBusy={status === 'submitted' || status === 'streaming'} onSubmit={({
      value
    }) => sendMessage({
      text: value
    })} onStop={stop} />
            </div>;
};
render(<Chat />);
```


## Keeping context visible

A new turn is not scrolled all the way to the top. A part of the previous reply is shown above it, so the new question does not feel like the start of a blank page.

Set how much is shown with the `--ai-conversation-peek` CSS custom property. It defaults to `3rem`.

```css
.my-chat {
  --ai-conversation-peek: 4rem;
}
```


```tsx
const Chat = () => {
  const [peek, setPeek] = useState('3rem');
  const [count, setCount] = useState(2);
  return <>
              <Flex.Horizontal align="center" bottom>
                <ToggleButton.Group label="Peek" value={peek} onChange={({
        value
      }) => setPeek(String(value))}>
                  <ToggleButton value="0" text="None" />
                  <ToggleButton value="3rem" text="3rem" />
                  <ToggleButton value="6rem" text="6rem" />
                </ToggleButton.Group>
                <Button variant="secondary" onClick={() => setCount(current => current + 1)}>
                  Ask another question
                </Button>
              </Flex.Horizontal>

              <Conversation style={{
      height: '18rem',
      '--ai-conversation-peek': peek
    } as React.CSSProperties}>
                {Array.from({
        length: count
      }, (_, index) => <Fragment key={index}>
                    <Message from="user">Question {index + 1}</Message>
                    <Message>{scrollerReply}</Message>
                  </Fragment>)}
              </Conversation>
            </>;
};
render(<Chat />);
```


## Following the live edge

When the user scrolls to the end, or presses the button to go to the latest message, the conversation follows the reply as it streams in. When the user scrolls up, it stops following, and the button is shown again.

Only the user can stop the following: by scrolling with the mouse wheel, touch, keyboard or the scrollbar. Content that grows or shrinks, like a sources section that closes, does not.

Use `scrollBehavior="end"` to follow the end of the conversation from the start, like a classic chat. In the [anchoring turns](#anchoring-turns) demo above, switch to `end` to compare.

```tsx
import { Conversation } from '@dnb/eufemia/extensions/ai'

render(<Conversation scrollBehavior="end">…</Conversation>)
```

## Opening saved conversations

When a conversation is shown with messages from before, it opens where the last turn starts, so the user sees their last question and the start of the reply. With `scrollBehavior="end"`, it opens at the end.


```tsx
const SavedChat = () => {
  const [scrollBehavior, setScrollBehavior] = useState<'turn' | 'end'>('turn');
  return <>
              <ToggleButton.Group label="Scroll behavior" value={scrollBehavior} onChange={({
      value
    }) => setScrollBehavior(value as 'turn' | 'end')} bottom>
                <ToggleButton value="turn" text="turn" />
                <ToggleButton value="end" text="end" />
              </ToggleButton.Group>

              <Conversation key={scrollBehavior} scrollBehavior={scrollBehavior} style={{
      height: '18rem'
    }}>
                {['How do I block my card?', 'Can I order a new one?'].map(question => <Fragment key={question}>
                      <Message from="user">{question}</Message>
                      <Message>
                        <P>{scrollerReply}</P>
                        <P top>{scrollerReply}</P>
                      </Message>
                    </Fragment>)}
              </Conversation>
            </>;
};
render(<SavedChat />);
```


## Loading earlier messages

When older messages are added at the start of the conversation, the messages the user is reading stay in place. This works in all browsers, also in Safari, which does not keep the position by itself.


```tsx
const History = () => {
  const [first, setFirst] = useState(5);
  const turns = Array.from({
    length: 8 - first
  }, (_, index) => first + index);
  return <>
              <Button variant="secondary" bottom disabled={first === 1} onClick={() => setFirst(current => Math.max(1, current - 2))}>
                Load earlier messages
              </Button>

              <Conversation style={{
      height: '18rem'
    }}>
                {turns.map(turn => <Fragment key={turn}>
                    <Message from="user">Question {turn}</Message>
                    <Message>{scrollerReply}</Message>
                  </Fragment>)}
              </Conversation>
            </>;
};
render(<History />);
```


## Animating new messages

New messages fade in, so it is easy to see what was added. Messages that are shown when the conversation opens, or when earlier messages are loaded above, do not fade in. When the user prefers reduced motion, there is no animation.

New items get the `data-entering` attribute. Use it to change the animation. Animate properties that do not change the size or position of a message, like `opacity`, so the conversation can keep the user's place.

```css
@keyframes my-chat-enter {
  from {
    opacity: 0;
    filter: blur(0.25rem);
  }
}

.my-chat .dnb-ai-conversation__content > [data-entering] {
  animation-name: my-chat-enter;
}
```

Switch between the default fade and a custom blur, and ask a few questions.


```tsx
const css = `
          @keyframes my-chat-enter {
            from {
              opacity: 0;
              filter: blur(0.5rem);
            }
          }
          .my-chat--blur .dnb-ai-conversation__content > [data-entering] {
            animation: my-chat-enter 600ms ease-out;
          }
        `;
const Chat = () => {
  const [animation, setAnimation] = useState('fade');
  const [count, setCount] = useState(1);
  return <>
              <style>{css}</style>
              <Flex.Horizontal align="center" bottom>
                <ToggleButton.Group label="Animation" value={animation} onChange={({
        value
      }) => setAnimation(String(value))}>
                  <ToggleButton value="fade" text="Fade" />
                  <ToggleButton value="blur" text="Blur" />
                </ToggleButton.Group>
                <Button variant="secondary" onClick={() => setCount(current => current + 1)}>
                  Ask another question
                </Button>
              </Flex.Horizontal>

              <Conversation className={`my-chat--${animation}`} style={{
      height: '18rem'
    }}>
                {Array.from({
        length: count
      }, (_, index) => <Fragment key={index}>
                    <Message from="user">Question {index + 1}</Message>
                    <Message>{scrollerReply}</Message>
                  </Fragment>)}
              </Conversation>
            </>;
};
render(<Chat />);
```


## Jumping to messages

Give the conversation an `id`, and use `Ai.useConversation(id)` to scroll it from elsewhere, like an outline or a toolbar. Inside the conversation, `Ai.useConversation()` needs no `id`.

```tsx
import { useConversation } from '@dnb/eufemia/extensions/ai'

const { scrollToMessage, scrollToStart, scrollToEnd } =
  useConversation('my-chat')

scrollToMessage(message.id)
```

`scrollToMessage` finds a message by its DOM `id`, and returns `false` when the message is not found. The message is placed below the peek, like a new turn.

## Reading position

`Ai.useConversationVisibility(id)` tells which messages are visible, and which turn the user is reading. Use it to show the current question in an outline, or to mark messages as read.

```tsx
import { useConversationVisibility } from '@dnb/eufemia/extensions/ai'

const { currentTurnId, visibleMessageIds } =
  useConversationVisibility('my-chat')
```

`currentTurnId` is the id of the message from the user that starts the turn being read. It stays set when that message is scrolled above the view, while the user reads the reply. Give each message a unique DOM `id` to track it. Use the same id when calling `scrollToMessage`. The messages are only tracked while the hook is used.

Scroll the conversation, and see how the current question is marked.


```tsx
const questions = [{
  id: 'block',
  text: 'How do I block my card?'
}, {
  id: 'order',
  text: 'Can I order a new card?'
}, {
  id: 'time',
  text: 'How long does it take?'
}, {
  id: 'cost',
  text: 'Does it cost anything?'
}];
const Outline = () => {
  const {
    scrollToMessage
  } = useConversation('outline-chat');
  const {
    currentTurnId
  } = useConversationVisibility('outline-chat');
  return <Flex.Horizontal bottom>
              {questions.map(({
      id,
      text
    }) => <Button key={id} variant={currentTurnId === id ? 'primary' : 'secondary'} size="medium" aria-current={currentTurnId === id ? 'step' : undefined} onClick={() => scrollToMessage(id)}>
                  {text}
                </Button>)}
            </Flex.Horizontal>;
};
render(<>
            <Outline />
            <Conversation id="outline-chat" style={{
    height: '18rem'
  }}>
              {questions.map(({
      id,
      text
    }) => <Fragment key={id}>
                  <Message from="user" id={id}>
                    {text}
                  </Message>
                  <Message>{scrollerReply}</Message>
                </Fragment>)}
            </Conversation>
          </>);
```


## Scroll state

`Ai.useConversationScrollState(id)` tells whether the conversation is scrolled to the start or the end. Use it to show or disable controls, like buttons to go to the start or the end.

```tsx
import { useConversationScrollState } from '@dnb/eufemia/extensions/ai'

const { isAtStart, isAtEnd, hasUnreadMessages } =
  useConversationScrollState('my-chat')
```


```tsx
const Toolbar = () => {
  const {
    scrollToStart,
    scrollToEnd
  } = useConversation('state-chat');
  const {
    isAtStart,
    isAtEnd
  } = useConversationScrollState('state-chat');
  return <Flex.Horizontal bottom>
              <Button variant="secondary" disabled={isAtStart} onClick={scrollToStart}>
                Go to start
              </Button>
              <Button variant="secondary" disabled={isAtEnd} onClick={scrollToEnd}>
                Go to end
              </Button>
            </Flex.Horizontal>;
};
render(<>
            <Toolbar />
            <Conversation id="state-chat" style={{
    height: '18rem'
  }}>
              {Array.from({
      length: 4
    }, (_, index) => <Fragment key={index}>
                  <Message from="user">Question {index + 1}</Message>
                  <Message>{scrollerReply}</Message>
                </Fragment>)}
            </Conversation>
          </>);
```


## Accessibility

- The conversation is a [log](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/log_role) with a translated label. Screen readers announce new messages, but not every word of a reply that streams in.
- The conversation can be scrolled with the keyboard when its content overflows.
- The button to go to the latest message is only shown when there is more content below, and has a translated label.
- When the user prefers reduced motion, the conversation scrolls without animation.

## Performance

Scrolling does not render the messages again. The conversation measures the content when its size changes, and when the user scrolls, it only updates the button to go to the latest message and the components that use its hooks.

Supported browsers can skip off-screen rendering of messages without interactive content with [content-visibility](https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility). Messages with links or controls stay fully rendered so their focus rings and touch areas remain available. Unlike a virtualized list, all messages stay in the page, so the user can still find text with the search of the browser, select text across messages, and use a screen reader to read them. When a message is rendered and gets its real size, the conversation keeps the user's place.

Until a message has been rendered, its height is estimated. Set the estimate with `contain-intrinsic-block-size` when your messages are much taller or shorter:

```css
.my-chat .dnb-ai-conversation__content > .dnb-ai-message {
  contain-intrinsic-block-size: auto 10rem;
}
```

The conversation below has 1000 messages. Scroll through it, or jump to the start, the middle or the end.


```tsx
const turns = Array.from({
  length: 500
}, (_, index) => ({
  id: `question-${index + 1}`,
  number: index + 1
}));
const Toolbar = () => {
  const {
    scrollToStart,
    scrollToMessage,
    scrollToEnd
  } = useConversation('long-chat');
  const {
    currentTurnId
  } = useConversationVisibility('long-chat');
  return <Flex.Horizontal align="center" bottom>
              <Button variant="secondary" onClick={scrollToStart}>
                Go to start
              </Button>
              <Button variant="secondary" onClick={() => scrollToMessage('question-250')}>
                Go to question 250
              </Button>
              <Button variant="secondary" onClick={scrollToEnd}>
                Go to end
              </Button>
              <span>
                Reading:{' '}
                {currentTurnId?.replace('question-', 'question ') ?? '–'}
              </span>
            </Flex.Horizontal>;
};
render(<>
            <Toolbar />
            <Conversation id="long-chat" style={{
    height: '24rem'
  }}>
              {turns.map(({
      id,
      number
    }) => <Fragment key={id}>
                  <Message from="user" id={id}>
                    Question {number}
                  </Message>
                  <Message>{scrollerReply}</Message>
                </Fragment>)}
            </Conversation>
          </>);
```


This works well for conversations with thousands of messages. For even longer conversations, show the latest messages, and load earlier messages when the user asks for them.

## Properties

See [Ai.Conversation properties](/web/patterns-and-extensions/ai/Conversation/properties/) for `scrollBehavior`, `id` and the values of `Ai.useConversation`, `Ai.useConversationVisibility` and `Ai.useConversationScrollState`.
