Skip to content

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

Import#

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.

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 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 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 and live demo. 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.

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.

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. 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 demonstrates switching to the full transcript.

Demos#

See 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.


Aino is a chatbot for customer service. Do not share personal information with the chatbot. Read more about your privacy.

Some answers are generated with artificial intelligence and are marked with a tag. AI-generated answers can contain errors.

Aino - 14:32
Hi, I am Aino, the digital assistant of DNB.
How can I help you?
You - 14:33
I see an amount withdrawn from my account that I do not recognize. Can you help me find out what it is?
Aino - 14:33KI-generert
Of course. Choose the transaction you want to know more about, and I will show you the details.
<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.

Unread messages: no

Message 0. Read earlier messages while the reply grows. Message 0. Read earlier messages while the reply grows. Message 0. Read earlier messages while the reply grows. Message 0. Read earlier messages while the reply grows.
Message 1. Read earlier messages while the reply grows. Message 1. Read earlier messages while the reply grows. Message 1. Read earlier messages while the reply grows. Message 1. Read earlier messages while the reply grows.
Message 2. Read earlier messages while the reply grows. Message 2. Read earlier messages while the reply grows. Message 2. Read earlier messages while the reply grows. Message 2. Read earlier messages while the reply grows.
Message 3. Read earlier messages while the reply grows. Message 3. Read earlier messages while the reply grows. Message 3. Read earlier messages while the reply grows. Message 3. Read earlier messages while the reply grows.
Message 4. Read earlier messages while the reply grows. Message 4. Read earlier messages while the reply grows. Message 4. Read earlier messages while the reply grows. Message 4. Read earlier messages while the reply grows.
Message 5. Read earlier messages while the reply grows. Message 5. Read earlier messages while the reply grows. Message 5. Read earlier messages while the reply grows. Message 5. Read earlier messages while the reply grows.
Message 6. Read earlier messages while the reply grows. Message 6. Read earlier messages while the reply grows. Message 6. Read earlier messages while the reply grows. Message 6. Read earlier messages while the reply grows.
Message 7. Read earlier messages while the reply grows. Message 7. Read earlier messages while the reply grows. Message 7. Read earlier messages while the reply grows. Message 7. Read earlier messages while the reply grows.
Message 8. Read earlier messages while the reply grows. Message 8. Read earlier messages while the reply grows. Message 8. Read earlier messages while the reply grows. Message 8. Read earlier messages while the reply grows.
Message 9. Read earlier messages while the reply grows. Message 9. Read earlier messages while the reply grows. Message 9. Read earlier messages while the reply grows. Message 9. Read earlier messages while the reply grows.
Message 10. Read earlier messages while the reply grows. Message 10. Read earlier messages while the reply grows. Message 10. Read earlier messages while the reply grows. Message 10. Read earlier messages while the reply grows.
Message 11. Read earlier messages while the reply grows. Message 11. Read earlier messages while the reply grows. Message 11. Read earlier messages while the reply grows. Message 11. Read earlier messages while the reply grows.
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 for the tradeoffs and driver import.

Current turn: none

Unread messages: no

You
Message 0. A longer reply with details.
Aino
Message 1. A longer reply with details. A longer reply with details.
You
Message 2. A longer reply with details. A longer reply with details. A longer reply with details.
Aino
Message 3. A longer reply with details. A longer reply with details. A longer reply with details. A longer reply with details.
You
Message 4. A longer reply with details.
Aino
Message 5. A longer reply with details. A longer reply with details.
You
Message 6. A longer reply with details. A longer reply with details. A longer reply with details.
Aino
Message 7. A longer reply with details. A longer reply with details. A longer reply with details. A longer reply with details.
You
Message 8. A longer reply with details.
Aino
Message 9. A longer reply with details. A longer reply with details.
You
Message 10. A longer reply with details. A longer reply with details. A longer reply with details.
<VirtualizedDemo />
Suggest an edit