---
title: 'Ai.Conversation'
description: 'Holds the messages of a chat and keeps the latest message in view while the reply streams in.'
version: 11.17.0
generatedAt: 2026-11-05T09:55:47.090Z
checksum: e751dcc2b503b2725d2a9cb62df1dfc6a3f643cb5ff4bd2a3f46318f080e0c6e
---

# Ai.Conversation

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

## Import

```tsx
import { Conversation } from '@dnb/eufemia/extensions/ai'
import '@dnb/eufemia/extensions/ai/style'
render(<Conversation />)
```

## Description

`Ai.Conversation` holds the messages, and takes care of the scrolling while new messages and replies stream in. Read more in [Message scroller](/web/patterns-and-extensions/ai/message-scroller/).

By default, a new message from the user is scrolled near the top, with a peek of the previous reply above it. The reply then streams in below, and the view stays where it is, so the user can read in peace. When the reply goes below the view, a button to go to the latest message is shown. When the user scrolls to the end, or presses the button, the conversation follows the reply as it streams in.

Use `scrollBehavior="end"` to always follow the end of the conversation instead, until the user scrolls up.

The conversation only moves when the user sends a message or asks for it. Content that changes, like a reply that streams in or older messages that are added above, does not move what the user reads. Short conversations start at the bottom, like in most chats.

Give it a height, e.g. with `style` or by placing it in a flex layout. The conversation can be scrolled with the keyboard when its content overflows.

### Announcements while streaming

The scroll view is a [log](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/log_role) that announces additions by default. This does not guarantee that updates to a streaming message will be announced in full.

The application owns streaming announcements because it knows when a reply finishes, stops or fails. Set `aria-live="off"` on the conversation and keep a separate [AriaLive](/web/components/aria-live/) mounted outside it. Update that region once when the operation ends. Clear its text when a new operation starts, so identical replies can be announced in separate turns. Keep partial replies visible after cancellation or failure, and provide a useful localized announcement for that outcome.

See the [announcement recipe](/web/patterns-and-extensions/ai/integrations/#screen-reader-announcements) and [live demo](/web/patterns-and-extensions/ai/demos/#streamed-reply-announcements). Check the result with the screen readers your application supports; DOM attributes alone do not prove spoken behavior.

### Scroll from elsewhere

Give the conversation an `id`, and use `Ai.useConversation(id)` to scroll it, e.g. from a toolbar.

### Customize the peek

The part of the previous reply that is shown above a new turn is set with the `--ai-conversation-peek` CSS custom property, which defaults to `3rem`.

## Incoming messages

When an incoming message is appended while the reader is away from the end, the scroll-to-latest button shows a translated “New message” label. `useConversationScrollState` exposes `hasUnreadMessages` for custom controls. Reaching the end clears it. Restored or prepended history, edits and streamed text within an existing message do not mark a message unread. Keep message keys and item ids stable.

This visual state does not announce a reply. The application still owns announcements for streamed completion and incoming customer-service or proactive messages. See [Screen reader announcements](/web/patterns-and-extensions/ai/integrations/#screen-reader-announcements).

## Virtualization

The Figma chat starts from the bottom. Use `scrollBehavior="end"` for that layout. The `turn` default lets readers follow a streamed reply from its start. In `end` mode, submitting a new user message returns to the end and resumes following. Moving focus into older content, selecting text or navigating away pauses following until the reader returns to the end. Use `--ai-conversation-padding` to adjust the default `1rem` side padding.

Conversation keeps the full transcript mounted by default. Its `content-visibility` styles let supported browsers skip off-screen rendering of messages without interactive content while preserving screen-reader navigation, browser search and text selection. Messages with links or controls remain fully rendered so focus rings and expanded touch areas are not clipped. Start with that behavior.

For large histories where mounted React content becomes expensive, pass `items` and an opt-in `renderDriver`. The separately imported driver uses the same optional `@tanstack/react-virtual` peer dependency as DrawerList; it is not imported by the default AI entry. Install the peer dependency in your application when using it.

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

// Create the driver once, outside your component.
const renderDriver = createConversationVirtualization({
  estimateSize: 120,
  overscan: 5,
})

const items = messages.map((message) => ({
  id: message.id,
  from: message.role,
  render: () => <Message from={message.role}>{message.text}</Message>,
}))

render(
  <Conversation
    id="chat-history"
    items={items}
    renderDriver={renderDriver}
    scrollBehavior="end"
    style={{ height: '30rem' }}
  />
)
```

Each item needs a stable unique `id` and a `render` callback. Set `from="user"` in the item metadata to mark a turn. The item wrapper owns the DOM ID, so do not repeat it on the rendered Message. `items` takes precedence over children. Without a driver, the same items render the full transcript. A driver without items uses the regular children rendering.

The driver measures variable heights, follows streamed growth while at the end, preserves the reader on prepended history and supports both scroll behaviors. `useConversation` can navigate to items that are not mounted; it returns false for unknown IDs. New turns and navigation scroll smoothly unless reduced motion is requested. The driver adjusts the destination as message heights are measured. Keyboard focus takes control of scrolling, so navigating through older messages keeps the focused control visible. New appended messages fade in; mounting older history does not replay the animation. Visibility hooks report mounted visible items and the current turn. Keep the items array stable when its content has not changed. Update items immutably, retain IDs when prepending history, and use new IDs for a different conversation.

Only a window of items is mounted. Unmounted content is unavailable to browser search, screen-reader reading and selection across messages. Local component state is discarded when an item unmounts. Store persistent state in the application, or set `keepMounted: true` for a decision, active media or other stateful content. Focused items and their adjacent items stay mounted for keyboard navigation, along with the latest message and latest user message.

The virtualized log always uses `aria-live="off"`, because scrolling remounts old messages and must not announce them as new replies. The application must announce meaningful outcomes through a separate AriaLive region, following the [announcement recipe](/web/patterns-and-extensions/ai/integrations/#screen-reader-announcements). Provide a way to show the full transcript when users need its complete content. Test your application with its supported screen readers. The [virtualized history demo](/web/patterns-and-extensions/ai/Conversation/demos/#virtualized-history) demonstrates switching to the full transcript.


## Demos

See [Message scroller](/web/patterns-and-extensions/ai/message-scroller/) for demos of how the conversation scrolls.

### Conversation with date and info

Scroll up to see the button that takes you to the latest message.


```tsx
render(<Conversation style={{
  height: '24rem'
}}>
        <DateMarker date="2026-02-02" />
        <Info>
          <P>
            Aino is a chatbot for customer service. Do not share personal
            information with the chatbot. Read more about{' '}
            <Anchor href="https://www.dnb.no/personvern">
              your privacy
            </Anchor>
            .
          </P>
          <P top>
            Some answers are generated with artificial intelligence and are
            marked with a tag. AI-generated answers can contain errors.
          </P>
        </Info>
        <Message name="Aino" timestamp="14:32">
          Hi, I am Aino, the digital assistant of DNB.
        </Message>
        <Message>How can I help you?</Message>
        <Message from="user" name="You" timestamp="14:33">
          I see an amount withdrawn from my account that I do not
          recognize. Can you help me find out what it is?
        </Message>
        <Message name="Aino" timestamp="14:33" aiGenerated>
          Of course. Choose the transaction you want to know more about,
          and I will show you the details.
        </Message>
      </Conversation>)
```


### Reading while content changes

Scroll to earlier messages, then send a new prompt or extend the reply. A new user prompt returns to the end; reading or selecting older content pauses following. Resizing the prompt keeps the latest reply visible when you are following it. Receive message adds an incoming reply; while reading earlier messages, the scroll button shows “New message” and the hook reports unread messages.


```tsx
const UnreadState = () => {
  const {
    hasUnreadMessages
  } = useConversationScrollState('reading-chat');
  return <P>Unread messages: {hasUnreadMessages ? 'yes' : 'no'}</P>;
};
const Reading = () => {
  const [messages, setMessages] = useState(Array.from({
    length: 12
  }, (_, index) => ({
    id: 'reading-' + index,
    from: index % 2 ? 'assistant' as const : 'user' as const,
    text: ('Message ' + index + '. Read earlier messages while the reply grows. ').repeat(4)
  })));
  return <>
              <Flex.Horizontal bottom>
                <Button onClick={() => setMessages(current => current.map((message, index) => index === current.length - 1 ? {
        ...message,
        text: message.text + ' More reply content.'.repeat(12)
      } : message))}>
                  Extend reply
                </Button>
                <Button onClick={() => setMessages(current => [...current, {
        id: 'reading-' + current.length,
        from: 'assistant',
        text: 'A new message from customer service.'
      }])}>
                  Receive message
                </Button>
              </Flex.Horizontal>
              <UnreadState />
              <div style={{
      display: 'flex',
      flexDirection: 'column',
      gap: '1rem',
      height: '28rem'
    }}>
                <Conversation id="reading-chat" scrollBehavior="end" aria-live="off" style={{
        flex: '1 1 auto'
      }}>
                  {messages.map(message => <Message key={message.id} id={message.id} from={message.from} actions={message.from === 'assistant' && <Button size="small">Reply details</Button>}>
                      {message.text}
                    </Message>)}
                </Conversation>
                <PromptInput onSubmit={({
        value
      }) => setMessages(current => [...current, {
        id: 'reading-' + current.length,
        from: 'user',
        text: value
      }, {
        id: 'reading-' + (current.length + 1),
        from: 'assistant',
        text: 'Your next reply starts here.'
      }])} />
              </div>
            </>;
};
render(<Reading />);
```


### Virtualized history

This opt-in example keeps a window of 1,000 variable-height messages mounted. Try navigating to message 100, appending a turn, extending the latest reply and prepending history. Use Show full transcript for browser search, selection across the full conversation and screen-reader access to unmounted messages.

The example has no real requests. The application owns reply announcements. See [Virtualization](/web/patterns-and-extensions/ai/Conversation/#virtualization) for the tradeoffs and driver import.

<VirtualizedExample />

<!-- eufemia-properties-usage:start -->
Some properties provide flexibility for specific integrations. Follow the component's usage guidance. Customizations beyond the documented patterns need a use case approved by DNB UX. [Read about intended use and customization](/web/styling-and-themes/configuration/component-properties/#intended-use-and-customization).
<!-- eufemia-properties-usage:end -->

## Properties


```json
{
  "props": {
    "label": {
      "doc": "Label of the conversation for screen readers. Defaults to a translated text.",
      "type": "string",
      "status": "optional"
    },
    "scrollBehavior": {
      "doc": "Use `turn` to scroll a new message from the user near the top, and let the reply stream in below it. Use `end` to follow the end of the conversation while the reply streams in.",
      "type": [
        "\"turn\"",
        "\"end\""
      ],
      "defaultValue": "\"turn\"",
      "status": "optional"
    },
    "id": {
      "doc": "Identifier used to scroll the conversation from elsewhere with `Ai.useConversation(id)`.",
      "type": "string",
      "status": "optional"
    },
    "children": {
      "doc": "The messages of the conversation.",
      "type": "React.ReactNode",
      "status": "optional"
    },
    "items": {
      "doc": "Data-driven messages with stable unique IDs, optional `from` and `keepMounted`, and a `render` callback. Takes precedence over children. The wrapper owns the DOM ID.",
      "type": "Array<AiConversationItem>",
      "status": "optional"
    },
    "renderDriver": {
      "doc": "Opt-in rendering driver. Import `createConversationVirtualization` separately from `@dnb/eufemia/extensions/ai/Virtualization`. Requires items. The virtualized log leaves announcements to the application.",
      "type": "AiConversationDriver",
      "status": "optional"
    },
    "[Space](/uilib/layout/space/properties)": {
      "doc": "Spacing properties like `top` or `bottom` are supported.",
      "type": [
        "string",
        "object"
      ],
      "status": "optional"
    }
  },
  "usageGuidance": "Some properties provide flexibility for specific integrations. Follow the component's usage guidance. Customizations beyond the documented patterns need a use case approved by DNB UX. [Read about intended use and customization](/web/styling-and-themes/configuration/component-properties/#intended-use-and-customization)."
}
```


## Ai.useConversation

`Ai.useConversation(id)` returns these functions. Without an `id`, it uses the conversation it is inside.


```json
{
  "props": {
    "scrollToEnd": {
      "doc": "Scroll to the end and follow new content.",
      "type": "function",
      "status": "optional"
    },
    "scrollToStart": {
      "doc": "Scroll to the start of the conversation.",
      "type": "function",
      "status": "optional"
    },
    "scrollToMessage": {
      "doc": "Scroll to the message with the given id. Returns `false` when the message is not found.",
      "type": "function",
      "status": "optional"
    }
  }
}
```


## Ai.useConversationVisibility

`Ai.useConversationVisibility(id)` returns these values. Without an `id`, it uses the conversation it is inside. The messages are only tracked while the hook is used.


```json
{
  "props": {
    "currentTurnId": {
      "doc": "The id of the message from the user that starts the turn the user is reading. Stays set when the message is scrolled above the view.",
      "type": [
        "string",
        "null"
      ],
      "status": "optional"
    },
    "visibleMessageIds": {
      "doc": "The ids of the messages that are visible, in the order they are shown.",
      "type": "Array<string>",
      "status": "optional"
    }
  }
}
```


## Ai.useConversationScrollState

`Ai.useConversationScrollState(id)` returns these values. Without an `id`, it uses the conversation it is inside.


```json
{
  "props": {
    "isAtStart": {
      "doc": "`true` when the conversation is scrolled to the start.",
      "type": "boolean",
      "status": "optional"
    },
    "isAtEnd": {
      "doc": "`true` when the conversation is scrolled to the end.",
      "type": "boolean",
      "status": "optional"
    },
    "hasUnreadMessages": {
      "doc": "`true` when new incoming messages arrived while reading older history. Clears at the end.",
      "type": "boolean",
      "status": "optional"
    }
  }
}
```


## Translations

| Key                 | nb-NO                | en-GB                | sv-SE                       | da-DK                 |
| ------------------- | -------------------- | -------------------- | --------------------------- | --------------------- |
| `conversationLabel` | Samtale              | Conversation         | Konversation                | Samtale               |
| `scrollToBottom`    | Gå til siste melding | Go to latest message | Gå till senaste meddelandet | Gå til seneste besked |
| `newMessage`        | Ny melding           | New message          | Nytt meddelande             | Ny besked             |
