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.
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>"Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<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
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Message nodes; each message's outer element needs data-message-id, or wrap it in ChatScroller.Message |
followThreshold | number | 48 | Distance from the bottom (px) that still counts as "at the bottom"; while at the bottom, new content is followed smoothly |
duration | number | 200 | Duration (ms) of short smooth scrolls; also written to --wb-chat-scroller-duration |
longDuration | number | 400 | Duration (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 |
loadMoreThreshold | number | 120 | Distance from the top (px) that triggers onLoadMore |
hasMore | boolean | false | Whether earlier messages exist; when false, onLoadMore never fires |
newMessageLabel | string | "New messages" | Text of the new-messages pill (the Chinese build defaults to "新訊息", "new messages") |
unreadCount | number | — | 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 |
ariaLabel | string | "Conversation" | Accessible name of the scroll container (the Chinese build defaults to "對話紀錄", "conversation log") |
ref | React.Ref<ChatScrollerHandle> | — | Imperative API (in React 19, ref is passed directly as a prop) |
className | string | — | Applied to the scroll container; you set the height, e.g. h-[480px] |
ChatScrollerHandle
| Field | Type | Default | Description |
|---|---|---|---|
scrollToBottom | (opts?: { smooth?: boolean }) => void | smooth: true | Scroll 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
| Field | Type | Default | Description |
|---|---|---|---|
align | "start" | "center" | "end" | "center" | Place the message at the top, middle or bottom of the scroll area |
smooth | boolean | true | Whether to scroll smoothly |
highlight | boolean | false | Fade the message background in and out once in amber on arrival (1.2 s total) |
ChatScroller.Message
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Message id, rendered as data-message-id (not as the DOM id, to avoid duplicate ids) |
className | string | — | 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,
scrollTopis set toscrollHeightwith 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: smoothis up to the browser and cannot be controlled, so every smooth scroll is a requestAnimationFrame tween; distances beyond one viewport height uselongDuration. 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
onLoadMorefires,scrollHeightandscrollTopare recorded; once older messages are inserted at the top, the added height is added back toscrollTop, so the message being read stays in place.overflow-anchor: autois only a helper (Safari does not support it); the compensation does not depend on it - Highlight:
highlightinscrollToMessageaddsbg-amber-100/60 dark:bg-amber-500/10directly 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"witharia-live="polite"andaria-relevant="additions", so screen readers announce newly added messages;aria-labeldefaults to "Conversation" - The scroll container has
tabIndex={0}: focus it and scroll with the arrow keys or PageUp/PageDown, with afocus-visiblering;aria-busyis set while history loads - The new-messages pill is a
type="button"whosearia-labelincludes 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
AI Branch Switcher
A React version switcher for a single AI reply: a ‹ 2 / 3 › pager slides the content directionally, regenerate opens a new branch with a typing skeleton, an optional mini branch tree sits above the bubble, ←/→ switch versions while the bubble has focus, plus copy and model/time captions.
Scripted Chat Thread
A scripted chat thread React component: user bubbles spring in, a dotted typing indicator bounces, and assistant replies stream character by character, including code blocks, with optional looping.