AI 應用元件
對話捲動容器
對話用的捲動容器:串流中貼底自動跟隨,離開底部時浮出「新訊息」膠囊,載入歷史不跳位,可程式捲到任一則訊息。
AI 對話與聊天室最難處理的往往不是訊息本身,而是捲軸:回覆逐字串流時要一路貼著底部,使用者往上翻舊訊息時又不能被硬拉回去;往上載入更早的訊息時,畫面不能突然跳一大段。這個容器把這些行為一次做好——在底部附近就平滑跟隨新內容,離開底部則在下方浮出「新訊息」膠囊;捲到頂部會觸發載入並補償捲動位置;另外提供命令式 API,可以捲到底部或捲到任一則訊息並短暫高亮。
把這個效果裝進你的專案
在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。
npx webberui@latest add "https://webberui.com/r/chat-scroller.json?t=<安裝 token>"Playground
即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。
48
200
400
編輯中的呼叫預設屬性
<ChatScroller/>
完整範例包含預覽的排版與資料。先安裝元件,再把範例檔貼入你的專案。
安裝
npx webberui@latest add "https://webberui.com/r/chat-scroller.json?t=<安裝 token>"或在 components.json 設定 registries 後,改用 @webberui/chat-scroller 安裝。
使用
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 })
}
>
跳到第 3 則
</button>
</>
);
}Props
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
children | React.ReactNode | — | 訊息節點;每則訊息外層需帶 data-message-id,或用 ChatScroller.Message 包裝 |
followThreshold | number | 48 | 距離底部多少 px 內視為「在底部」;在底部時新內容會平滑跟隨 |
duration | number | 200 | 近距離平滑捲動的時長(ms),同時寫入 --wb-chat-scroller-duration |
longDuration | number | 400 | 捲動距離超過一個視窗高度時使用的時長(ms) |
onLoadMore | () => Promise<void> | — | 捲到頂部 loadMoreThreshold 內時觸發;Promise 未完成前不會重複觸發,頂部顯示小 spinner |
loadMoreThreshold | number | 120 | 距離頂部多少 px 內觸發 onLoadMore |
hasMore | boolean | false | 是否還有更早的訊息;false 時不觸發 onLoadMore |
newMessageLabel | string | "新訊息" | 新訊息膠囊的文字 |
unreadCount | number | — | 外部提供的未讀數;大於 0 時膠囊顯示「3 則新訊息」,且離開底部時就會出現 |
onAtBottomChange | (atBottom: boolean) => void | — | 是否在底部的狀態改變時回呼 |
ariaLabel | string | "對話紀錄" | 捲動容器的無障礙名稱 |
ref | React.Ref<ChatScrollerHandle> | — | 命令式 API(React 19 起 ref 直接當 prop 傳入) |
className | string | — | 套用在捲動容器上;高度由你決定,例如 h-[480px] |
ChatScrollerHandle
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
scrollToBottom | (opts?: { smooth?: boolean }) => void | smooth: true | 捲到最底部 |
scrollToMessage | (id: string, opts?: ChatScrollerScrollToMessageOptions) => void | — | 捲到 data-message-id 等於 id 的訊息;找不到就不動 |
isAtBottom | () => boolean | — | 依目前幾何計算是否在底部 |
ChatScrollerScrollToMessageOptions
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
align | "start" | "center" | "end" | "center" | 目標訊息停在捲動區的上緣、中間或下緣 |
smooth | boolean | true | 是否平滑捲動 |
highlight | boolean | false | 抵達時讓訊息背景以琥珀色淡入淡出一次(共 1.2 秒) |
ChatScroller.Message
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
id | string | — | 訊息 id,輸出為 data-message-id(不寫成 DOM 的 id,避免重複 id) |
className | string | — | 額外 class;其餘 div 屬性會原樣透傳 |
另外具名匯出 ChatScrollerProps、ChatScrollerHandle、ChatScrollerScrollToMessageOptions、ChatScrollerMessageProps 型別。
細節
- 初次掛載直接到底:在第一次繪製前把
scrollTop設為scrollHeight,不播動畫,使用者不會看到從頂部滑下來的過程 - 跟隨或提示:用 ResizeObserver 觀察內容容器(不是捲動容器)。內容長高時,若變化前在底部就以
duration毫秒的 ease-out 補間跟到底;串流中每個字都會觸發一次,前一段補間還沒結束就從目前位置重新起算,不會抖動。若不在底部則不動,改為浮出新訊息膠囊 - 自寫補間,不用 CSS smooth:
scroll-behavior: smooth的時長由瀏覽器決定、無法控制,這裡一律用 requestAnimationFrame 補間;距離超過一個視窗高度時改用longDuration。補間途中只要使用者滾輪、觸控或按 PageUp 等捲動鍵,補間立即取消,以使用者的位置為準 - 載入歷史不跳位:觸發
onLoadMore前記下scrollHeight與scrollTop,新訊息插入頂部後把增加的高度補回scrollTop,畫面上正在看的訊息停在原處。overflow-anchor: auto只當輔助(Safari 不支援),實際補償不依賴它 - 高亮:
scrollToMessage的highlight直接在目標節點加上bg-amber-100/60 dark:bg-amber-500/10,淡入、停留後淡出,1.2 秒後移除,訊息元件不需要配合
可及性
- 捲動容器為
role="log"、aria-live="polite"、aria-relevant="additions",螢幕閱讀器會讀出新加入的訊息;aria-label預設「對話紀錄」 - 捲動容器
tabIndex={0},可用鍵盤聚焦後以方向鍵、PageUp/PageDown 捲動,聚焦時顯示focus-visible外框;載入歷史期間設aria-busy - 新訊息膠囊是
type="button",aria-label含未讀數與「捲到最新」;點擊後焦點交回捲動容器,不會因膠囊消失而遺失 - 載入中的 spinner 為
role="status",附「正在載入較早的訊息」的隱藏文字 - 使用者系統開啟「減少動態效果」時,所有捲動(跟隨、跳到最新、跳到訊息)都瞬間完成,膠囊只淡入淡出不位移縮放,高亮也直接上色不漸變