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 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.
import {Conversation,Message,Response,} from '@dnb/eufemia/extensions/ai'function Chat({messages,}: {messages: Array<{id: stringfrom: 'user' | 'assistant'text: stringisStreaming?: boolean}>}) {return (<Conversation style={{ height: '40rem' }}>{messages.map((message) => (<Message key={message.id} id={message.id} from={message.from}>{message.from === 'user' ? (message.text) : (<ResponseparseIncompleteMarkdown={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.
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.
.my-chat {--ai-conversation-peek: 4rem;}
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 demo above, switch to end to compare.
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.
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.
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.
@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.
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.
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.
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.
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.
import { useConversationScrollState } from '@dnb/eufemia/extensions/ai'const { isAtStart, isAtEnd, hasUnreadMessages } =useConversationScrollState('my-chat')
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 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. 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:
.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.
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 for scrollBehavior, id and the values of Ai.useConversation, Ai.useConversationVisibility and Ai.useConversationScrollState.