Message Scroller

Message Scroller shows a conversation's messages and keeps the newest in view as they arrive, unless the reader has scrolled away.

Message Scroller is the scrolling transcript of a chat. It follows new messages and streamed replies while the reader is at the end, stays put once they scroll up, and keeps their place when older messages load above. For a scrolling region that holds anything else, use a Scroll Area.

Playground

Can you summarise the launch plan?
The launch is in three stages: a private beta for 200 teams, a public waitlist two weeks later, and general availability at the end of the month.
What are the risks?
The main risk is support load during the waitlist. The second is the billing migration, which has to finish before general availability.
Who owns the billing migration?
Priya owns it, with a review from finance on the 14th.
Scroll up, then send, to see it stay put.

Usage

import { Box, Flex, MessageScroller, Text } from '@kushagradhawan/kookie-ui';
 
export function Conversation({ messages }: { messages: Message[] }) {
  return (
    <MessageScroller.Root style={{ height: '100%' }}>
      <MessageScroller.Viewport aria-label="Conversation">
        <MessageScroller.Content>
          {messages.map((message) => (
            <MessageScroller.Item key={message.id} messageId={message.id} scrollAnchor={message.role === 'user'}>
              <Box px="4" py="3">
                <Text size="2">{message.text}</Text>
              </Box>
            </MessageScroller.Item>
          ))}
        </MessageScroller.Content>
      </MessageScroller.Viewport>
      <MessageScroller.Button />
    </MessageScroller.Root>
  );
}

The Root needs a height, from its parent or its own style, so the viewport has something to scroll.

Anatomy

<MessageScroller.Root>
  <MessageScroller.Viewport>
    <MessageScroller.Content>
      <MessageScroller.Item />
    </MessageScroller.Content>
  </MessageScroller.Viewport>
  <MessageScroller.Button />
</MessageScroller.Root>
  • Root: Holds the scroll state. Every other part goes inside it.
  • Viewport: The element that scrolls.
  • Content: The list of messages.
  • Item: One message.
  • Button: Jumps back to the latest message. It shows only while the reader is away from the end, centred over the bottom edge of the transcript.

Best practices

Anchor the reader's own messages. Set scrollAnchor on the messages people send. When one arrives, it moves near the top of the viewport and the reply streams in below it, so people read the reply from its start.

Give every message a stable messageId. Use the id your data already has. It lets useMessageScroller().scrollToMessage find the message and useMessageScrollerVisibility report it.

Don't take the reader back while they are reading. Leave autoScroll on. It follows new content only while the reader is at the end, and the Button gives them a way back when they're ready.

Name the transcript. Pass aria-label to the Viewport, such as "Conversation". Without a name it isn't a landmark people can jump to.

Keep the composer outside. Put the Chatbar below the Root, not inside the Viewport, so it stays in place while the messages scroll.

Following new messages

autoScroll keeps the newest content in view while the reader is at the end, which includes a reply that grows as it streams. Once the reader scrolls up with the wheel, touch or the keyboard, the transcript stays where they left it until they return to the end. Turn it off for a log people read from the top.

defaultScrollPosition sets where the transcript opens:

ValueOpens atUse it for
endThe latest message. This is the defaultMost chats
startThe first messageA transcript people read from the top
last-anchorThe last anchored message, or the end when the rest fitsReturning to a conversation mid-reply

Examples

Loading older messages

When you add messages above the first one, the reader's place stays where it was. Set preserveScrollOnPrepend={false} on the Viewport to turn this off.

Jumping to a message

useMessageScroller returns scrollToEnd, scrollToStart and scrollToMessage. Call it from a component inside the Root, such as a search result that opens the message it matched.

const { scrollToMessage } = useMessageScroller();
 
scrollToMessage(result.messageId, { align: 'center', behavior: 'smooth' });

Fading under a floating bar

The Viewport fades messages toward an edge while there is more past it. When a bar floats over an edge, set --scroll-fade-start or --scroll-fade-end on the Viewport to its height, so messages fade as they pass under it. Set fade={false} to turn the fade off.

Props

Root

PropTypeDefaultDescription
autoScrollbooleantrueFollows new content while the reader is at the end
defaultScrollPosition'end' | 'start' | 'last-anchor''end'Where the transcript opens
scrollEdgeThresholdnumber8Pixels from the end that still count as at the end
scrollMarginnumber0Pixels kept above a message that is scrolled to or anchored

Viewport

PropTypeDefaultDescription
aria-labelstringThe transcript's name, such as "Conversation"
preserveScrollOnPrependbooleantrueKeeps the reader's place when messages are added above
fadebooleantrueFades messages toward an edge with more past it

Item

PropTypeDefaultDescription
messageIdstringA stable id, for scrollToMessage
scrollAnchorbooleanfalseMoves the message near the top when it arrives

Button

Button takes the Button props except iconOnly and fullWidth. It is always icon-only, with a down arrow when it has no children.

PropTypeDefaultDescription
direction'end' | 'start''end'Jumps to the latest message, or to the first
behaviorScrollBehavior'auto'How the jump scrolls. 'auto' jumps at once, with no long animation
aria-labelstring'Jump to the latest'The name. 'Jump to the first' for direction="start"
variantButton variant'surface'The look. Or set emphasis
colorAccentColor'gray'The colour
radiusButton radius'full'The corner

Hooks

HookReturns
useMessageScroller(){ scrollToEnd, scrollToStart, scrollToMessage }. Each takes { align, behavior, scrollMargin }
useMessageScrollerScrollable(){ start, end }: whether there is more to scroll each way
useMessageScrollerVisibility(){ visibleMessageIds, currentAnchorId }: which messages are in view

Each hook must be called inside a Root. Root, Viewport, Content and Item accept the attributes of the HTML div element.

Accessibility

KeyAction
TabMoves focus to the viewport, then to the Button when it shows
Arrow Up or Arrow DownScrolls the focused viewport, and stops following
Page Up, Page Down, SpaceScrolls a page, and stops following
Home or EndGoes to the start or end of the transcript, and stops following
  • Region: The Viewport is a focusable role="region", named by its aria-label. It shows a focus ring for the keyboard only.
  • New messages: The Content has role="log", so a screen reader announces messages as they are added without moving focus.
  • Button: While the reader is at the end, the Button is hidden, inert and out of the tab order. Its name is "Jump to the latest" unless you pass your own aria-label.
  • Focus: Pressing the Button scrolls to the end and moves focus to the Viewport, so the Button can hide and keyboard focus stays in the transcript.