- 手风琴
- 提示
- 警告对话框
- 宽高比
- 附件
- 头像
- 徽章
- 面包屑导航
- Bubble
- 按钮
- 按钮组
- 日历 Calendar
- 卡片
- 轮播图
- 图表 Chart
- 复选框
- 折叠面板
- 组合框
- 命令
- 上下文菜单
- 数据表格 Data Table
- 日期选择器 Date Picker
- 对话框 Dialog
- 方向
- 抽屉
- 下拉菜单
- 空状态
- 字段
- 悬停卡片
- 输入
- 输入组
- Input OTP
- 项目
- Kbd
- 标签
- 标记
- 菜单栏
- 消息
- 消息滚动器
- 原生选择框
- 导航菜单 Navigation Menu
- 分页
- 弹出框
- 进度 Progress
- 单选框组
- 可调整大小
- 滚动区域 Scroll Area
- 选择框
- 分隔符 Separator
- 侧边栏 Sheet
- 侧边栏 Sidebar
- 骨架屏
- 滑块
- Sonner
- 加载指示器 Spinner
- 开关
- 表格
- 标签页 Tabs
- 文本域
- 吐司
- 切换按钮 Toggle
- 切换组
- 提示 Tooltip
- 排版
"use client"
import { useChat } from "@ai-sdk/react"是什么造就了出色的流式聊天体验#
构建聊天界面曾经很简单。你创建一个带输入框的反向列表。输入一条消息,它就追加到底部。回复到来时,列表增长并自动滚动。完成。
流式传输打破了这种模式。消息会分块到达,而你可能还在阅读、滚动,或者看向别的地方。
现在的挑战是在对话不断变化时,仍然保留读者所在的位置。如果处理不当,体验就会显得很跳:人们会被拉到底部,丢失上下文,还得再找回原来的位置。
在实践中,这归结为滚动:什么时候跟随,什么时候保持,什么时候让读者自己决定。出色的流式聊天应该:
- 只在读者要求移动时移动。 如果有人正在阅读,不要把他们拉到别处。自动滚动绝不应该是默认行为。
- 只在他们正在跟随时跟随。 如果他们停留在实时边缘,就让流保持在视野内。如果他们滚开,就让他们停在那儿。
- 每一次交互都是一个信号。 滚动不是唯一的信号。选中文本、使用键盘、打开链接或搜索,都应该阻止界面移动。
- 让新的回合从视口顶部附近开始。 这样新回合就有一个可以从头开始阅读的位置。
- 然后把答案流式填入。 答案应该在屏幕中逐渐展开,而不是立刻把一切都推开。
- 保留上一轮对话的一部分上下文。 提示和回复应该在视觉上保持关联,并且前一轮应保留足够内容可见,让读者知道自己在哪里。
- 让新内容在屏幕外到达。 对话可以持续流入,而不会改变读者正在看的内容。
- 显示视野之外正在发生的事情。 当回复仍在流式生成,或者有新消息到达时,要让这一点清楚可见。
- 让回到最新回复变得容易。 一个“跳转到最新”操作应该把读者带回去并恢复跟随。
- 允许人们跳转到对话中的任意位置。 长线程需要消息链接、搜索、未读标记和直接导航。
- 在读者离开的地方重新打开。 已保存的对话应该打开在最后一个有意义的回合。通常这是最后一条用户消息,而不是绝对底部。
- 在布局变化时保持读者的位置。 图片加载、Markdown 展开、代码块渲染、旧消息出现在上方——这些都不应让读者丢失当前位置。
- 在处理中断时不要偷走位置。 停止、重试、重新生成、分支或错误,都不应意外移动对话。
- 在长线程中保持响应。 流式文本、Markdown、代码、图片和长历史记录都仍应感觉流畅。
- 在没有噪音的情况下也要可访问。 保持转录内容可导航,保留键盘焦点,并以舒适的节奏播报重要事件。
永远不要违背读者的意图去移动他们。
MessageScroller#
MessageScroller 是一个专为这些行为构建的聊天记录滚动器。
MessageScrollerProvider 负责滚动状态和记录行行为:
初始位置、流式输出、回合新增时的锚定、预置历史记录、
可见性以及滚动控制。MessageScroller 是其中的样式化容器,
用于在内部渲染内容。
MessageScroller 的作用范围仅限于滚动视口。它不负责消息、AI 状态、 传输、持久化、分支或模型状态。你的产品代码仍然专注于组合消息、标记、工具、 附件以及提示输入。
它为聊天提供所需的滚动行为,而不会接管聊天 UI 的其他部分。 而且即使在包含丰富 markdown 的长对话中,它也能保持快速。
安装#
pnpm dlx shadcn@latest add message-scroller
用法#
import { Message } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<Message />
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>MessageScroller 会填充其父容器,因此请将其放在一个高度受限的
容器中。
<div className="flex h-screen flex-col">
<MessageScrollerProvider>
<MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
</MessageScrollerProvider>
</div>组合#
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
<MessageScrollerItem>
{/* 一条消息、标记或行 */}
</MessageScrollerItem>
<MessageScrollerItem />
<MessageScrollerItem />
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>MessageScrollerProvider— 无界面根组件。负责滚动状态,以及打开位置、自动滚动、锚定、滚动命令和可见性跟踪的行为属性。MessageScroller— 带样式的外框。在 provider 内部布局 viewport、content 和 controls。MessageScrollerViewport— 可滚动元素。接收原生滚动事件,并在较旧消息被前置插入时保持可见行不变。MessageScrollerContent— 转录容器。承载各行,并为新消息提供默认的 live region。MessageScrollerItem— 转录行边界。将 content 的每个直接子元素都包裹起来,以便 scroller 可以测量、锚定、保持位置、跟踪可见性并跳转到它。item 可以是一条消息、标记、输入中指示、分隔符、加入/离开事件,或“加载更早”行。MessageScrollerButton— 滚动控制。滚动到转录的开头或结尾,并且在其方向上没有内容之前保持不可用。
核心概念#
锚定转折#
一个 turn 是对话中开启新一轮交互的部分。在简单的 AI 聊天中,通常就是用户消息以及随后出现的助手回复。
anchor 是视口应该视为该 turn 起点的那一行。用
scrollAnchor 标记那一行。当新增一个 anchor 时,视口会将
其移动到靠近顶部的位置,并在其上方保留前一个项目的一小部分,
这样新的 turn 就不会让人感觉与上下文割裂。
// 这会告诉滚动器将用户消息作为下一轮的锚点。
<MessageScrollerItem
messageId={message.id}
scrollAnchor={message.role === "user"}
/>滚动锚点不与消息角色绑定。你可以把任何一行变成锚点:
用户消息、系统标记、交接事件,或任何开启有意义 turn 的内容。
MessageScroller 只需要知道哪一行应该作为视口锚点。
在下面的示例中,用户消息被锚定。当你发送新消息时,视口会将其锚定到靠近顶部的位置,并在其下方追加助手回复。切换锚点到助手消息,看看差异。
"use client"
import * as React from "react"群聊#
在群聊中,turn 边界比“用户消息”更具体。它通常是 请求模型回复的那条消息,或者像“Marcus 加入了 聊天”这样的标记。输入中提示和历史控制通常不应该作为锚点。
因为锚定与角色无关,你可以像锚定消息一样轻松地锚定一个标记。
<MessageScrollerItem messageId="marcus-joined" scrollAnchor>
<Marker variant="separator">
<MarkerContent>Marcus 加入了聊天</MarkerContent>
</Marker>
</MessageScrollerItem>This will create a marker and make it the anchor
"use client"
import * as React from "react"保持上下文可见#
当一个新 turn 开始时,它仍然应该让人感觉是同一条连续
线程的一部分。scrollPreviousItemPeek 会让前一个项目的一小部分
在锚点上方保持可见,这样读者就能保留上下文,而不会觉得
对话是在一个空白页面上重新开始的。
// 让上一轮的 64px 内容在新锚定行上方保持可见。
<MessageScrollerProvider scrollPreviousItemPeek={64}>
<MessageScroller>{/* 锚定的 turns */}</MessageScroller>
</MessageScrollerProvider>调整下面示例中的 peek 数值,看看它如何影响对话。
I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.
The important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent.
"use client"
import * as React from "react"跟随实时边缘#
当读者处于实时边缘时,无论是因为他们一直停留在那里,还是
后来又返回那里,autoScroll 都会在流式回复增长时让其保持可见。
离开实时边缘会释放视图,无论是通过滚轮、触摸、
键盘滚动键,还是拖动滚动条。显式跳转到某条消息
也会释放它。这样,新的分段内容就可以到来,而不会移动读者视图。
<MessageScrollerProvider autoScroll>
<MessageScroller>{/* 流式 turns */}</MessageScroller>
</MessageScrollerProvider>"use client"
import { useChat } from "@ai-sdk/react"调用 scrollToEnd,或按下 MessageScrollerButton,会在启用 autoScroll 时重新接管
跟随输出,这样已经滚离的读者可以
返回实时边缘并继续跟随。当执行这种程序化滚动到最新消息时,
根节点和视口会暴露 data-autoscrolling,因此你可以在过渡期间有条件地应用样式。
打开已保存的线程#
把已保存线程重新打开到转录文本的绝对末尾似乎很合理,但那通常会让读者进入对话时缺少足够的上下文。更好的默认值是 "last-anchor":显示最后一个有意义的 turn,
例如用户的最新消息,并在其下方显示回复。
这样可以让读者立刻进入线程中的某个位置。他们可以看到自己问了什么、答案从哪里开始,并在此基础上继续,而不必从底部重新构建 对话。
<MessageScrollerProvider defaultScrollPosition="last-anchor">
<MessageScroller>{/* 转录文本 */}</MessageScroller>
</MessageScrollerProvider>This is the first message the user sent in the conversation.
Workspace creation rose 8%, but first invite completion only rose 2%.
This is the last message the user sent in the conversation.
Start with the invite step. Teams are creating workspaces but waiting to add collaborators.
Recommended follow-up:
1. Compare invite drop-off by account size. 2. Check whether users who skip invites still return within 24 hours. 3. Review the empty-state copy on the first project screen. 4. Segment activation by template, since template users may not need invites right away.
If that pattern holds, the next experiment should make collaboration useful earlier instead of prompting for invites harder.
"last-anchor" 以 scrollAnchor 为依据,而不是消息角色。如果不存在锚点,
或者最后一个锚定 turn 已经完整地适配在视口中,它会回退到
"end"。
当你想从对话开头恢复时使用 "start",或者当绝对最新的消息才是合适落点时使用
"end"。
加载更早的消息#
加载更早的消息不应该移动读者当前正在查看的对话。当较早的行被
插入到当前转录文本上方时,MessageScrollerViewport 会保留可见行,
这样历史在上方加载时,读者仍停留在同一位置。
这默认通过 preserveScrollOnPrepend 启用。
Only the export queue worker changed. The deploy moved large CSV jobs onto the shared retry policy, which made each failed attempt hold a worker slot longer than before.
The app deploy did not include checkout, pricing, or billing API changes.
Do we need to roll back?
Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.
Keep rollback ready if the queue starts climbing again, but the current trend points toward recovery.
Keep watching for customer-visible issues.
I will watch the queue and support tags for another 15 minutes. I am tracking export failures, delayed download requests, and any support thread that mentions missing reports.
If those stay quiet through the next batch window, we can close this as an internal degradation.
Restore earlier messages while keeping your place.
"use client"
import * as React from "react"为消息行使用稳定的 messageId 值。这样滚动器就可以保留特定行,而不是根据视口边缘碰巧出现的某个像素来猜测。
为新消息添加动画#
MessageScrollerItem 可以直接做动画。为该项创建一个 motion 版本,
保留其上的 messageId 和 scrollAnchor,并使用 transform 和 opacity 作为进入动画。
一种常见的聊天模式是:用户消息发送时对其做动画,然后让助手回复在其下方作为普通行流式出现。将用户行从其最终位置下方开始,这样它会像是从视口实时边缘升上来。
const MotionMessageScrollerItem = motion.create(MessageScrollerItem)"use client"
import * as React from "react"避免在行进入时对 height、margin 或 padding 做动画;这些变化 可能会干扰滚动器的位置计算。如果读者偏好减少动态效果, 就跳过进入动画,但保持滚动行为不变。
跳转到消息#
搜索结果、永久链接、大纲项和工具栏按钮通常需要
从消息列表外部驱动转录文本。对这些控件使用 useMessageScroller。
由于这些 hooks 从 MessageScrollerProvider 读取,因此它们可以在 provider 内的任何组件中工作,
包括渲染在 MessageScroller 框架外部的控件。
import { useMessageScroller } from "@/components/ui/message-scroller"const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller()We're seeing activation dip after workspace creation. Can you help me find the likely step?
The sharpest drop is between creating the workspace and inviting the first teammate.
Workspace creation is still healthy, but the invite step is where users pause. That suggests the product is asking for collaboration before the user has enough confidence in the workspace.
What should I compare before we change the onboarding flow?
Compare three cohorts:
1. Users who choose a template before inviting teammates. 2. Users who start from a blank workspace. 3. Users who skip invites and return within 24 hours.
If template users invite faster, the fix is probably better first-run guidance rather than a louder invite prompt.
Can you turn that into an experiment?
Yes. Create a variant that shows a short checklist after workspace creation:
- Pick a template. - Add one project detail. - Invite a teammate when the workspace has context.
Measure first invite completion, 24-hour return rate, and whether teams create a second project.
What's the risk if we delay the invite prompt?
The main risk is reducing team creation for accounts that already know who they want to invite.
To protect that path, keep the invite action visible in the header and only change the primary empty-state guidance. That gives confident teams a direct route without forcing uncertain users through the invite step too early.
scrollToMessage 以 MessageScrollerItem 上的 messageId 为目标,因此需要可寻址的行
应当有稳定的 id。scrollToMessage 在目标未挂载且无法排队时返回 false。
scrollToMessage 可以在项目尚不存在时先排队一个目标,这适用于
在转录文本挂载期间由客户端解析出的永久链接。行挂载后,如果 id 缺失,它会返回 false,而不是启动一个猜测性的重试循环。返回 true
表示滚动已执行或已排队,而不意味着该行已经在视图中。
跟踪读者位置#
使用 useMessageScrollerVisibility 来跟踪读者在对话中的位置。
一个常见例子是目录或跳转菜单,用于高亮当前锚定的 turn。
import { useMessageScrollerVisibility } from "@/components/ui/message-scroller"const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()Review the incident handoff and tell me what to read first.
Start with the summary and the impact section. The regression affected the upload queue, but the recovery path completed for every queued job.
What was the customer impact?
Impact was limited to delayed processing.
No records were dropped, and the reconciliation worker confirmed each retry batch. Support saw confusion from two customers, but there were no checkout or billing errors.
What actions are open?
Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.
The alert should fire on sustained queue growth, not a single short spike.
Give me the follow-up checklist.
After that, compare the queue recovery graph with the deploy timeline so the handoff shows exactly when processing returned to baseline. That makes it easier for support and engineering to answer the same customer questions without re-reading the whole incident thread.
I would also add a short owner note beside each follow-up item. The checklist is small, but ownership keeps the retry-window decision, alert tuning, and support macro from drifting into separate follow-up conversations.
Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.
The alert should fire on sustained queue growth, not a single short spike.
currentAnchorId 通过报告当前锚定的 turn 来回答“我在哪里”,并且在该锚点滚动到视口上方后仍会保持设置。visibleMessageIds
则回答“屏幕上显示了什么”,按文档顺序排列。
可见性采用按需付费。只有当某些内容订阅了 useMessageScrollerVisibility 时,跟踪才会运行,而且行需要 messageId 才能参与。
读取滚动状态#
当你在 JavaScript 中需要滚动状态时,例如状态指示器或自定义“跳转到最新”控件,请使用
useMessageScrollerScrollable。它会报告视口还可以朝哪些边缘滚动;“在开头/结尾”是其否定形式(!start / !end),而“是否可滚动”则是 start || end。如果要为滚动器本身做样式设置,优先使用 data-scrollable 属性。
import { useMessageScrollerScrollable } from "@/components/ui/message-scroller"const { start, end } = useMessageScrollerScrollable()Review scroll checkpoint 1.
Checkpoint 2 is synced. The scrollable hook updates as the viewport moves.
When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.
At the latest message, the footer should switch again and only point them back up.
Review scroll checkpoint 3.
Checkpoint 4 is synced. The scrollable hook updates as the viewport moves.
When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.
At the latest message, the footer should switch again and only point them back up.
Review scroll checkpoint 5.
Checkpoint 6 is synced. The scrollable hook updates as the viewport moves.
When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.
At the latest message, the footer should switch again and only point them back up.
Review scroll checkpoint 7.
Checkpoint 8 is synced. The scrollable hook updates as the viewport moves.
When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.
At the latest message, the footer should switch again and only point them back up.
Review scroll checkpoint 9.
Checkpoint 10 is synced. The scrollable hook updates as the viewport moves.
When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.
At the latest message, the footer should switch again and only point them back up.
Review scroll checkpoint 11.
Checkpoint 12 is synced. The scrollable hook updates as the viewport moves.
When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.
At the latest message, the footer should switch again and only point them back up.
"use client"
import { MessageAnimated } from "@/components/message-animated"性能#
MessageScroller 针对包含 markdown 和组合消息行的大型转录内容进行了基准测试。
MessageScroller 的性能目标是将滚动热路径保持在 React 状态之外:转录行不进行 React 重新渲染,不在每次滚动时强制布局,并尽可能减少浏览器无法避免的屏幕外绘制工作。
滚动位置、锚定和跟随输出通过命令式方式跟踪,并通过 data-* 属性镜像到根节点和视口上,因此滚动和流式更新不会导致转录行重新渲染。
样式化的 MessageScrollerItem 还附带 content-visibility: auto 和 contain-intrinsic-size。行会保留在 DOM 中,以便进行选择、复制、页内查找、SSR 和辅助技术使用,但浏览器可以跳过视口之外很远的行的渲染工作。
可见性跟踪采用按需付费模式。跳转菜单或当前回合指示器在有人订阅 useMessageScrollerVisibility 之前不会产生任何成本。
对于预期范围内的聊天转录内容,这样的设计已经足够舒适:数百到数千轮对话,包括带有 markdown 和组合组件的消息。
虚拟化#
虚拟化是有意被放在原始组件之外的。MessageScroller
会渲染真实的 DOM 行,并且在上千轮对话中依然保持快速(参见
性能),因此大多数转录内容都不需要它。
当转录内容大到需要虚拟化时,请使用
MessageScrollerViewport 作为滚动元素,并让虚拟化器负责这些
行。
import * as React from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
function VirtualizedTranscript({
messages,
}: {
messages: Array<{ id: string; content: React.ReactNode }>
}) {
const viewportRef = React.useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => viewportRef.current,
estimateSize: () => 86,
getItemKey: (index) => messages[index]?.id ?? index,
overscan: 8,
})
return (
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport ref={viewportRef}>
<MessageScrollerContent className="block min-h-full">
<div
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const message = messages[virtualItem.index]
if (!message) {
return null
}
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="absolute start-0 top-0 w-full"
style={{
transform: `translateY(${virtualItem.start}px)`,
}}
>
<Message>{message.content}</Message>
</div>
)
})}
</div>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
)
}无障碍#
MessageScroller 保持滚动容器可通过键盘访问,并且无需强制特定的消息 UI 就能让对话记录可被宣读。
MessageScrollerViewport 默认是一个带标签、可键盘聚焦的滚动区域。它使用 role="region"、aria-label="Messages" 和 tabIndex={0},因此键盘用户可以聚焦对话记录并直接滚动。
MessageScrollerContent 使用 role="log" 和 aria-relevant="additions" 将对话记录标记为实时区域。新的行可以被宣读,但流式文本的变动不必逐个 token 宣读。
<MessageScrollerContent aria-busy={status === "streaming"}>
{/* 消息 */}
</MessageScrollerContent>如果需要等到完整消息行出现后再宣读,请在一轮消息流式传输时传入 aria-busy。
MessageScrollerButton 渲染的是一个真正的按钮。当没有可滚动的方向时,它会设置 inert,使用 tabIndex={-1},并暴露 data-active="false",这样非活跃的滚动控件就不会产生额外的焦点停留。
无样式#
MessageScroller 中的行为来自 @shadcn/react 包。若要直接使用
它并配合你自己的标记和样式,请参阅
@shadcn/react 下的 Message Scroller。
API 参考#
每个部分的 props、data 属性和 hooks 都记录在 @shadcn/react Message Scroller 页面上。 它们对于带样式组件和无样式部分是相同的。