AI Interface Components

Chat Scroller

A scroll container for chat: it follows streaming output while pinned to the bottom, shows a new-messages pill when you scroll away, keeps position when loading history, and can scroll to any message on demand.

This is a WebberUI Pro component

The install command below needs a licence to run; without one it returns 401.

How to install Pro components →See the plans →

In AI chat and messaging UIs the hard part is usually not the messages, it is the scrollbar: a streaming reply should stay pinned to the bottom, yet someone reading older messages must not be yanked back down; and loading earlier history must not make the view jump. This container handles all of that at once — near the bottom it smoothly follows new content, away from the bottom it shows a "new messages" pill instead; reaching the top triggers loading and compensates the scroll position; and an imperative API lets you scroll to the bottom or to any message with a brief highlight.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add "https://webberui.com/r/chat-scroller.en.json?t=<install token>"

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

48
200
400
Active edited usageDefault props
<ChatScroller/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add "https://webberui.com/r/chat-scroller.en.json?t=<install token>"

Or, once registries are configured in components.json, install it as @webberui/chat-scroller.

Usage

import * as React from "react";
import {
  ChatScroller,
  type ChatScrollerHandle,
} from "@/components/ui/chat-scroller";

export function Conversation({ messages, hasMore, loadOlder }) {
  const scrollerRef = React.useRef<ChatScrollerHandle>(null);

  return (
    <>
      <ChatScroller
        ref={scrollerRef}
        className="h-[480px] p-4"
        hasMore={hasMore}
        onLoadMore={loadOlder}
        onAtBottomChange={(atBottom) => console.log("atBottom", atBottom)}
      >
        {messages.map((m) => (
          <ChatScroller.Message key={m.id} id={m.id} className="py-1.5">
            {m.text}
          </ChatScroller.Message>
        ))}
      </ChatScroller>

      <button
        type="button"
        onClick={() =>
          scrollerRef.current?.scrollToMessage("m3", { highlight: true })
        }
      >
        Jump to message 3
      </button>
    </>
  );
}

Props

PropTypeDefaultDescription
childrenReact.ReactNode—Message nodes; each message's outer element needs data-message-id, or wrap it in ChatScroller.Message
followThresholdnumber48Distance from the bottom (px) that still counts as "at the bottom"; while at the bottom, new content is followed smoothly
durationnumber200Duration (ms) of short smooth scrolls; also written to --wb-chat-scroller-duration
longDurationnumber400Duration (ms) used when the scroll distance exceeds one viewport height
onLoadMore() => Promise<void>—Fires when scrolled within loadMoreThreshold of the top; not re-fired until the Promise settles; a small spinner shows at the top meanwhile
loadMoreThresholdnumber120Distance from the top (px) that triggers onLoadMore
hasMorebooleanfalseWhether earlier messages exist; when false, onLoadMore never fires
newMessageLabelstring"New messages"Text of the new-messages pill (the Chinese build defaults to "新訊息", "new messages")
unreadCountnumber—Externally supplied unread count; when above 0 the pill reads "New messages (3)" and appears as soon as you leave the bottom
onAtBottomChange(atBottom: boolean) => void—Called when the at-bottom state changes
ariaLabelstring"Conversation"Accessible name of the scroll container (the Chinese build defaults to "對話紀錄", "conversation log")
refReact.Ref<ChatScrollerHandle>—Imperative API (in React 19, ref is passed directly as a prop)
classNamestring—Applied to the scroll container; you set the height, e.g. h-[480px]

ChatScrollerHandle

FieldTypeDefaultDescription
scrollToBottom(opts?: { smooth?: boolean }) => voidsmooth: trueScroll to the very bottom
scrollToMessage(id: string, opts?: ChatScrollerScrollToMessageOptions) => void—Scroll to the message whose data-message-id equals id; does nothing if not found
isAtBottom() => boolean—Whether the container is at the bottom, measured from the current geometry

ChatScrollerScrollToMessageOptions

FieldTypeDefaultDescription
align"start" | "center" | "end""center"Place the message at the top, middle or bottom of the scroll area
smoothbooleantrueWhether to scroll smoothly
highlightbooleanfalseFade the message background in and out once in amber on arrival (1.2 s total)

ChatScroller.Message

FieldTypeDefaultDescription
idstring—Message id, rendered as data-message-id (not as the DOM id, to avoid duplicate ids)
classNamestring—Extra classes; all other div attributes are passed through

The ChatScrollerProps, ChatScrollerHandle, ChatScrollerScrollToMessageOptions and ChatScrollerMessageProps types are exported as well.

How it works

  • Starts at the bottom: before the first paint, scrollTop is set to scrollHeight with no animation, so users never see it slide down from the top
  • Follow or notify: a ResizeObserver watches the content wrapper (not the scroll container). When content grows and the container was at the bottom before the change, it follows with a duration-ms ease-out tween; during streaming every character triggers this, and an unfinished tween restarts from the current position, so there is no jitter. When not at the bottom it stays put and shows the new-messages pill instead
  • Hand-written tween, no CSS smooth: the duration of scroll-behavior: smooth is up to the browser and cannot be controlled, so every smooth scroll is a requestAnimationFrame tween; distances beyond one viewport height use longDuration. Any wheel, touch or scroll key (PageUp and so on) during a tween cancels it immediately and respects the user's position
  • History loads without jumping: before onLoadMore fires, scrollHeight and scrollTop are recorded; once older messages are inserted at the top, the added height is added back to scrollTop, so the message being read stays in place. overflow-anchor: auto is only a helper (Safari does not support it); the compensation does not depend on it
  • Highlight: highlight in scrollToMessage adds bg-amber-100/60 dark:bg-amber-500/10 directly to the target node, fades in, holds, fades out and is removed after 1.2 s — the message component does not need to cooperate

Accessibility

  • The scroll container is role="log" with aria-live="polite" and aria-relevant="additions", so screen readers announce newly added messages; aria-label defaults to "Conversation"
  • The scroll container has tabIndex={0}: focus it and scroll with the arrow keys or PageUp/PageDown, with a focus-visible ring; aria-busy is set while history loads
  • The new-messages pill is a type="button" whose aria-label includes the unread count and "jump to latest"; after a click, focus returns to the scroll container instead of being lost when the pill disappears
  • The loading spinner is role="status" with hidden "Loading earlier messages" text
  • When the user has turned on reduce motion, every scroll (following, jump to latest, jump to message) completes instantly, the pill only fades in and out without moving or scaling, and the highlight switches color without a transition