新しいメッセージに追従し、過去を読み返している間は位置を保ち、見逃した件数をジャンプボタンに表示するチャットのスクロール領域です。
New chat
How can I help you today?
The prompt is read only. Press send to play the next turn.
"use client"
import * as React from "react"
import {
IconArrowUp,
IconMessageCircle,
IconRotateClockwise,
IconSparkles,
} from "@tabler/icons-react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
} from "@/components/ui/input-group"
import { Message, MessageAvatar, MessageContent } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
type Turn = { user: string; assistant: string }
type Entry = { id: string; role: "user" | "assistant"; text: string }
const script: Turn[] = [
{
user: "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.",
assistant:
"That's the classic streaming scroll problem. Wrap your message list in a message scroller and turn on autoScroll. The viewport pins to the bottom as tokens arrive, so readers always see the latest text land in place, without the thread lurching around underneath them.\n\nThe important part is that it only follows while the reader is already at the bottom. The moment they scroll up to read something earlier, it backs off and their position is preserved. New text keeps arriving below, the button at the bottom shows there's more, and one tap brings them back to the live edge, where following picks up again.",
},
{
user: "Okay, but when someone sends a new message the view still feels jarring, like the whole conversation reloads from the top.",
assistant:
"That's what turn anchoring fixes. Mark each user message with scrollAnchor and a new question settles near the top of the viewport instead of snapping to the very bottom. The answer then has room to stream in below it, and you read it from its first word instead of chasing the end of the stream.\n\nIt also leaves a small peek of the previous exchange above the anchor, so the new turn still feels connected to what came before. Once the answer grows past the bottom of the view, following takes over from the anchor and keeps the newest text in sight.",
},
{
user: "And if they've scrolled up to re-read an older answer? I don't want to yank them back down.",
assistant:
"You won't. Scrolling away is a deliberate opt-out, so their place in the thread stays put even while new text keeps arriving below. Wheel, touch, keyboard and dragging the scrollbar all count, so it never fights the reader for control.\n\nWhile there's content they haven't reached, the jump button sits at the bottom of the viewport. If whole messages arrive while they're away, it counts them, so they know what they missed. One tap jumps back to the newest message and turns following back on.",
},
{
user: "Last one: does this work with assistive tech?",
assistant:
"Yes. The transcript is a labelled, focusable region, so keyboard users can reach it and scroll it with the arrow keys, Page Up and Page Down. Inside it, the content is a log with additions announced, so screen readers read new messages as they arrive.\n\nSet aria-busy while a reply streams and the announcement waits for the finished message instead of reading every word as it lands. The jump button is a real button with a spoken label, and it leaves the tab order whenever there's nothing to jump to.",
},
]
export function MessageScrollerDemo() {
const [entries, setEntries] = React.useState<Entry[]>([])
const [streaming, setStreaming] = React.useState(false)
const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
const turn = entries.filter((entry) => entry.role === "user").length
const next = script[turn]
React.useEffect(() => {
const pending = timers.current
return () => pending.forEach(clearTimeout)
}, [])
const send = () => {
if (!next || streaming) {
return
}
const id = `turn-${turn}`
setStreaming(true)
setEntries((current) => [
...current,
{ id: `${id}-user`, role: "user", text: next.user },
])
const words = next.assistant.split(" ")
timers.current.push(
setTimeout(() => {
setEntries((current) => [
...current,
{ id: `${id}-assistant`, role: "assistant", text: "" },
])
words.forEach((_, index) => {
timers.current.push(
setTimeout(
() => {
setEntries((current) =>
current.map((entry) =>
entry.id === `${id}-assistant`
? { ...entry, text: words.slice(0, index + 1).join(" ") }
: entry
)
)
if (index === words.length - 1) {
setStreaming(false)
}
},
40 * (index + 1)
)
)
})
}, 600)
)
}
const reset = () => {
timers.current.forEach(clearTimeout)
timers.current = []
setStreaming(false)
setEntries([])
}
return (
<MessageScrollerProvider autoScroll>
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex h-140 flex-col overflow-hidden rounded-xl border bg-card">
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h3 className="text-sm font-medium">New chat</h3>
<p className="text-sm text-muted-foreground">
How can I help you today?
</p>
</div>
<Button
variant="outline"
size="icon-sm"
aria-label="Reset conversation"
disabled={entries.length === 0}
onClick={reset}
>
<IconRotateClockwise />
</Button>
</div>
<div className="min-h-0 flex-1">
{entries.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="stack">
<IconMessageCircle />
</EmptyMedia>
<EmptyTitle>Morning!</EmptyTitle>
<EmptyDescription>
Press send to start the conversation.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<MessageScroller>
<MessageScrollerViewport aria-label="Conversation">
<MessageScrollerContent aria-busy={streaming}>
{entries.map((entry) => (
<MessageScrollerItem
key={entry.id}
messageId={entry.id}
scrollAnchor={entry.role === "user"}
animated={entry.role === "user"}
>
{entry.role === "user" ? (
<Message align="end">
<MessageContent>
<Bubble variant="secondary">
<BubbleContent>{entry.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
) : (
<Message>
<MessageAvatar>
<IconSparkles />
</MessageAvatar>
<MessageContent>
<Bubble variant="ghost">
<BubbleContent>
{entry.text ? (
<div className="flex flex-col gap-3">
{entry.text
.split("\n\n")
.map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
) : (
"Thinking…"
)}
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
)}
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
)}
</div>
<div className="border-t p-3">
<form
className="w-full"
onSubmit={(event) => {
event.preventDefault()
send()
}}
>
<InputGroup>
<div className="min-h-14 w-full px-3 py-2.5 text-sm">
{next ? (
<span className="line-clamp-2">{next.user}</span>
) : (
<span className="text-muted-foreground">
That's the whole script. Reset to start over.
</span>
)}
</div>
<InputGroupAddon align="block-end">
<InputGroupButton
type="submit"
variant="default"
size="icon-sm"
aria-label="Send"
disabled={!next || streaming}
className="ms-auto"
>
<IconArrowUp />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
</div>
</div>
<p className="text-center text-xs text-muted-foreground">
The prompt is read only. Press send to play the next turn.
</p>
</div>
</MessageScrollerProvider>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/message-scroller.jsonコンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。
- 1
まだ追加していない場合は、グローバルCSSにテーマトークンを追加してください。
- 2
依存関係をインストールします。
pnpm add @shadcn/react @tabler/icons-react cn - 3
次のコードをコピーしてプロジェクトに貼り付けてください。
components/ui/message-scroller.tsx "use client" import * as React from "react" import { MessageScroller as MessageScrollerPrimitive, useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility,components/ui/button.tsx "use client" import * as React from "react" import { Button as ButtonPrimitive } from "@base-ui/react/button" import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn"lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { - 4
インポートパスは、お使いのプロジェクト構成に合わせて更新してください。
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport aria-label="Conversation">
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem key={message.id} messageId={message.id}>
<Message>…</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>すべての項目に安定した messageId を付けてください。スクローラーはこれを使って新しいメッセージに追従し、位置を保ち、未読を数えます。各行には <Message /> と <Bubble /> を組み合わせます。
MessageScrollerProvider
└── MessageScroller
├── MessageScrollerViewport
│ └── MessageScrollerContent
│ └── MessageScrollerItem
└── MessageScrollerButtonグループチャット
アンカーはメッセージである必要はありません。ここでは、参加者を示す <Marker /> がターンの始まりです。上にスクロールしてメッセージを受信すると、ジャンプボタンが離れている間に届いた数を表示します。
"use client"
import * as React from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Marker, MarkerContent } from "@/components/ui/marker"
import {
Message,
MessageAvatar,
MessageContent,
MessageHeader,
} from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
type Row =
| { id: string; kind: "joined"; name: string }
| { id: string; kind: "message"; author: string; text: string }
const message = (id: string, author: string, text: string): Row => ({
id,
kind: "message",
author,
text,
})
const initial: Row[] = [
message("1", "Preet", "Morning! Standup in five."),
message("2", "Jun", "Running two minutes late."),
message("3", "Sol", "Same, finishing a review."),
message("4", "Preet", "No rush. Agenda is in the doc."),
message("5", "Jun", "Read it. Looks good to me."),
{ id: "joined", kind: "joined", name: "Mira" },
message("6", "Mira", "Hi all, joining from the design team."),
]
const later: Row[] = [
message("7", "Preet", "Welcome! You're just in time."),
message("8", "Jun", "Hey Mira 👋"),
message("9", "Sol", "Good to have you here."),
]
function Author({ name }: { name: string }) {
return (
<MessageAvatar>
<Avatar>
{name === "Preet" && (
<AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
)}
<AvatarFallback>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
</MessageAvatar>
)
}
export function MessageScrollerGroupChat() {
const [rows, setRows] = React.useState(initial)
const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
React.useEffect(() => {
const pending = timers.current
return () => pending.forEach(clearTimeout)
}, [])
const receive = () => {
later.forEach((row, index) => {
timers.current.push(
setTimeout(
() => {
setRows((current) =>
current.some((existing) => existing.id === row.id)
? current
: [...current, row]
)
},
600 * (index + 1)
)
)
})
}
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="h-80 overflow-hidden rounded-xl border">
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport aria-label="Team chat">
<MessageScrollerContent>
{rows.map((row) =>
row.kind === "joined" ? (
<MessageScrollerItem
key={row.id}
messageId={row.id}
scrollAnchor
>
<Marker variant="separator">
<MarkerContent>
{row.name} joined the chat
</MarkerContent>
</Marker>
</MessageScrollerItem>
) : (
<MessageScrollerItem key={row.id} messageId={row.id}>
<Message>
<Author name={row.author} />
<MessageContent>
<MessageHeader>{row.author}</MessageHeader>
<Bubble variant="secondary" shape="tail">
<BubbleContent>{row.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
)
)}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>
<Button
variant="outline"
size="sm"
className="self-center"
onClick={receive}
>
Scroll up, then receive messages
</Button>
</div>
)
}文脈の保持
scrollPreviousItemPeek は、新しくアンカーされたターンの上に前のターンの一部を残すので、スレッドの連続感が保たれます。各量を試してから、次の質問をしてみてください。
"use client"
import * as React from "react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Message, MessageContent } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
const peeks = [0, 64, 128]
const answer =
"Here is a long answer that fills most of the view, so the next question has to scroll to reach the top. Watch how much of this reply stays visible above the new question."
export function MessageScrollerPeek() {
const [peek, setPeek] = React.useState(64)
const [turns, setTurns] = React.useState(1)
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex flex-wrap items-center justify-center gap-2">
{peeks.map((value) => (
<Button
key={value}
size="sm"
variant={peek === value ? "secondary" : "outline"}
aria-pressed={peek === value}
onClick={() => {
setPeek(value)
setTurns(1)
}}
>
{value}px peek
</Button>
))}
</div>
<div className="h-72 overflow-hidden rounded-xl border">
<MessageScrollerProvider key={peek} scrollPreviousItemPeek={peek}>
<MessageScroller>
<MessageScrollerViewport aria-label="Turns">
<MessageScrollerContent>
{Array.from({ length: turns }, (_, turn) => (
<React.Fragment key={turn}>
<MessageScrollerItem messageId={`q${turn}`} scrollAnchor>
<Message align="end">
<MessageContent>
<Bubble variant="secondary">
<BubbleContent>Question {turn + 1}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
<MessageScrollerItem messageId={`a${turn}`}>
<Message>
<MessageContent>
<Bubble variant="ghost">
<BubbleContent>{answer}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
</React.Fragment>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</MessageScrollerProvider>
</div>
<Button
variant="outline"
size="sm"
className="self-center"
onClick={() => setTurns(turns + 1)}
>
Ask the next question
</Button>
</div>
)
}保存済みスレッドを開く
defaultScrollPosition="last-anchor" は、読み手を回答の途中の下端に置く代わりに、会話を最後の質問から開き、その下に回答を表示します。
import { IconSparkles } from "@tabler/icons-react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Message, MessageAvatar, MessageContent } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
const thread = [
{
prompt: "What's a hairline border?",
reply:
"The thinnest line a screen can draw: 1px on standard displays and two thirds of a pixel on high-density ones.",
},
{
prompt: "And concentric radii?",
reply:
"Inner corners use the outer radius minus the gap between the edges, so nested shapes curve together.",
},
{
prompt: "Why open saved chats at the last question instead of the bottom?",
reply:
"Because the bottom is usually the middle of an answer. Opening at your last question shows what you asked and where the reply starts, so you can pick up the thread without scrolling around to rebuild context. The rest of the answer is right below, and the earlier turns are one scroll away.",
},
]
export function MessageScrollerSavedThread() {
return (
<div className="h-80 w-full max-w-md overflow-hidden rounded-xl border">
<MessageScrollerProvider defaultScrollPosition="last-anchor">
<MessageScroller>
<MessageScrollerViewport aria-label="Saved conversation">
<MessageScrollerContent>
{thread.map((turn, index) => [
<MessageScrollerItem
key={`q${index}`}
messageId={`q${index}`}
scrollAnchor
>
<Message align="end">
<MessageContent>
<Bubble variant="secondary">
<BubbleContent>{turn.prompt}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>,
<MessageScrollerItem key={`a${index}`} messageId={`a${index}`}>
<Message>
<MessageAvatar>
<IconSparkles />
</MessageAvatar>
<MessageContent>
<Bubble variant="ghost">
<BubbleContent>{turn.reply}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>,
])}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton direction="start" />
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>
)
}以前のメッセージの読み込み
上に追加された古いメッセージがあっても、読んでいる位置は動きません(preserveScrollOnPrepend、デフォルトでオン)。
"use client"
import * as React from "react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Message, MessageContent } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
type Entry = { id: number; text: string }
const page = (from: number) =>
Array.from({ length: 6 }, (_, index) => ({
id: from + index,
text: `Message #${from + index}`,
}))
export function MessageScrollerHistory() {
const [entries, setEntries] = React.useState<Entry[]>(() => page(100))
const [loading, setLoading] = React.useState(false)
const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
React.useEffect(() => () => clearTimeout(timer.current), [])
const loadOlder = () => {
setLoading(true)
timer.current = setTimeout(() => {
setEntries((current) => [...page(current[0].id - 6), ...current])
setLoading(false)
}, 600)
}
return (
<div className="h-80 w-full max-w-md overflow-hidden rounded-xl border">
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport
aria-label="Message history"
preserveScrollOnPrepend
>
<MessageScrollerContent>
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
loading={loading}
onClick={loadOlder}
>
Load older messages
</Button>
</div>
{entries.map((entry) => (
<MessageScrollerItem
key={entry.id}
messageId={String(entry.id)}
>
<Message align={entry.id % 3 === 0 ? "end" : "start"}>
<MessageContent>
<Bubble
variant={entry.id % 3 === 0 ? "default" : "secondary"}
>
<BubbleContent>{entry.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton direction="start" />
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>
)
}メッセージへのジャンプ
useMessageScroller はスレッドの外から操作し、useMessageScrollerVisibility は現在のターンを報告するので、アウトラインが今の位置をハイライトします。
"use client"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Item, ItemContent, ItemGroup, ItemTitle } from "@/components/ui/item"
import { Message, MessageContent } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerVisibility,
} from "@/components/ui/message-scroller"
const topics = [
"Install the component",
"Anchor each turn",
"Follow streamed replies",
"Load earlier messages",
"Jump around the thread",
]
function Outline() {
const { scrollToMessage } = useMessageScroller()
const { currentAnchorId } = useMessageScrollerVisibility()
return (
<ItemGroup variant="grouped" className="w-44 shrink-0">
{topics.map((topic, index) => (
<Item
key={topic}
size="xs"
render={<button type="button" />}
aria-current={
currentAnchorId === `topic-${index}` ? "true" : undefined
}
aria-pressed={currentAnchorId === `topic-${index}`}
onClick={() =>
scrollToMessage(`topic-${index}`, { behavior: "smooth" })
}
>
<ItemContent>
<ItemTitle>{topic}</ItemTitle>
</ItemContent>
</Item>
))}
</ItemGroup>
)
}
export function MessageScrollerJump() {
return (
<MessageScrollerProvider defaultScrollPosition="start">
<div className="flex w-full max-w-xl items-start gap-4">
<Outline />
<div className="h-80 min-w-0 flex-1 overflow-hidden rounded-xl border">
<MessageScroller>
<MessageScrollerViewport aria-label="Guide">
<MessageScrollerContent>
{topics.map((topic, index) => [
<MessageScrollerItem
key={`q${index}`}
messageId={`topic-${index}`}
scrollAnchor
>
<Message align="end">
<MessageContent>
<Bubble variant="secondary">
<BubbleContent>
How do I {topic.toLowerCase()}?
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>,
<MessageScrollerItem
key={`a${index}`}
messageId={`a${index}`}
>
<Message>
<MessageContent>
<Bubble variant="ghost">
<BubbleContent>
Here is how to {topic.toLowerCase()}. The outline on
the side highlights this question while it is the
current turn, and clicking another one jumps
straight to it.
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>,
])}
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</div>
</div>
</MessageScrollerProvider>
)
}プロバイダーの内側では、これらのフックを使って独自のコントロールからスレッドをスクロールしたり、画面に表示されているものに反応したりできます。
const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScroller()
const { start, end } = useMessageScrollerScrollable()
const { visibleMessageIds, currentAnchorId } = useMessageScrollerVisibility()| キー | アクション |
|---|---|
| Tab | 会話にフォーカスします。ジャンプボタンは表示されている間だけタブ順に入ります。 |
| ↑↓ | 会話をスクロールします。 |
| Page UpPage Down | 1画面分スクロールします。 |
| HomeEnd | 最初または最新のメッセージにジャンプします。 |
- ビューポートはラベル付きでフォーカス可能な領域で、コンテンツは
role="log"なので、スクリーンリーダーは新しいメッセージを届いた時点で読み上げます。ビューポートにaria-labelを付けてください。 - ジャンプボタンのラベルが新着数を伝えるため、矢印だけでなく「3 new messages」と読み上げられます。
- 過去のメッセージを読んでいる間は、新しいメッセージへの追従で動かされることはありません。再び下端に戻ったときにだけ再開します。
| プロパティ | 型 | デフォルト |
|---|---|---|
autoScroll読み手が下端にいる間、新しいメッセージに追従します。 | boolean | false |
defaultScrollPositionスレッドが開く位置。 | "start" | "end" | "last-anchor" | "end" |
scrollPreviousItemPeek新しいアンカーの上に、前の項目がどれだけ見えたままになるか。 | number | 64 |
scrollEdgeThreshold端に「いる」とみなす、端からのピクセル数。 | number | – |
scrollMargin表示位置までスクロールされたメッセージの上に確保する余白。 | number | – |
| プロパティ | 型 | デフォルト |
|---|---|---|
preserveScrollOnPrepend上にメッセージが追加されても、読んでいる位置を保ちます。 | boolean | false |
aria-label会話の領域に名前を付けます。 | string | – |
| 属性 | 説明 |
|---|---|
data-scrollable="start end" | さらにスクロールできる端。端のフェードを制御します。 |
data-autoscrolling | 新しいメッセージに追従している間付きます。スクロールバーは隠れます。 |
--scroller-fade-start / --scroller-fade-end | 端のフェードのサイズ。出入りにアニメーションが付きます。 |
| プロパティ | 型 | デフォルト |
|---|---|---|
messageId追従、アンカー、カウントに使う安定した id。 | string | – |
scrollAnchordefaultScrollPosition="last-anchor" により、この項目が届いたときに上端までスクロールします。 | boolean | false |
| プロパティ | 型 | デフォルト |
|---|---|---|
direction | "start" | "end" | "end" |
showUnseen上にスクロールしている間に届いたメッセージ数を数えるピルに広がります。 | boolean | true |
unseenLabel | (count: number) => ReactNode | "3 new messages" |
variant | Button variant | "outline" |
behavior | ScrollBehavior | "smooth" |
| 属性 | 説明 |
|---|---|
data-slot="message-scroller-button" | CSS でボタンを指定します。 |
data-active | ジャンプ先があるときは "true"。 |
data-unseen | 未読数。0を超えている間のみ。 |
- Buttonすべてのバリアントとサイズのボタンです。読み込み、成功、エラーのフローを内蔵し、高速なリクエストではスピナーを省略します。
- Motionすべてのコンポーネントがアニメーションに使うイージングカーブ、継続時間、モーション軽減のチェックと、サイズのモーフィングやスライドするハイライト用のフックです。
- Messageアバター、名前、バブル、ステータスを備えたチャットのメッセージ行です。新しいメッセージは送信者の側からせり上がって表示されます。
- Attachmentアップロード向けのファイルカードと画像カードです。進捗、状態、操作、カード全体のトリガーに対応し、名前は拡張子を保ったまま表示されます。
- Bubbleバリアント、グループ化された角、リアクション、インタラクティブなコンテンツの余地を備えたチャットのメッセージバブルです。
- Marker日付の区切りやシステムイベントのように、コンテンツの間に置く控えめなメモです。日付と時刻はスティッキーで、「今日」や「昨日」と表示されます。
使用しているブロック
Message scroller の上に構築されるブロック。
- Prompt Input最初は静かな1行で、入力に合わせてカードへ広がり、会話が始まると下へ移動するチャット入力欄です。Enterで送信でき、日本語や中国語の入力でも安全です。ファイルは貼り付け、ドロップ、選択でき、プレビュー、進捗、再試行に対応します。@でファイルを追加し、/でコマンドをカーソル位置のメニューから実行できます。数字キーで選べるモデルピッカー、Maxで動き出す推論量スライダー、コンテキストリング、リアルタイム波形つきの音声入力、ツールチップ、返信のストリーミング中に入力したメッセージのキュー、再読み込みしても残る下書きを備えています。
- Agent Todosエージェントの作業中のプランを表示します。すべてのステップはバックログからToDo、進行中、完了へと移り、リアルタイムの所要時間、失敗、その裏にあるツール呼び出しも確認できます。コンポーザーの上に置くステータスピル、目に見えるプランの変更、実行前にプランを編集できるレビューステップを備えています。
- Chat Threadコンポーザーを中心にした会話全体です。回答のストリーミング中も質問は上部に固定され、サイドのチェックポイントでメッセージ間を移動でき、すべての返信をコピー、編集、再試行、評価し、バージョン間で切り替えられます。返信はコードブロック、テーブル、引用を含むMarkdownとして描画され、思考、ツール呼び出し、Prompt Inputもそのまま組み込めます。
- Code BlockAIの回答向けに作られたコードブロックです。ストリーミングに追従するシンタックスハイライト、コピー、ダウンロード、折り返し、行番号とハイライト行、承認と却下のある差分、コマンド用のターミナルを備えています。