125k

Message Scroller

ターンを固定し、保存済みのトランスクリプトを開き、ストリーミング応答に追従し、ジャンプせずに履歴を読み込み、任意のメッセージへ移動できるチャットスクロールコンテナです。

新しいチャット
今日はどのようなお手伝いができますか?
おはようございます、shadcn!
今日は何に取り組みますか?送信を押して新しい会話を始めましょう
アプリにチャットを組み込んでいるのですが、AIが返信をストリーミングするたびに会話全体が飛び回ってしまい、スクロールの挙動に困っています
これは閲覧専用のデモで、送信を押すとメッセージを送信できます
"use client"

import { useChat } from "@ai-sdk/react"

優れたストリーミングチャット体験とは

チャット インターフェースの構築は、以前は簡単でした。入力欄と逆順リストを作り、 メッセージを入力すると下部に追加され、返信が届くとリストが伸びてスクロールします。これで完了です。

ストリーミングでは、このモデルが崩れます。まだ読んでいたり、スクロールしていたり、まったく別の場所を見ている間にも、 メッセージが分割されて届くためです。

ここでの課題は、会話が変化し続ける間も読者の位置を保つことです。これを誤ると、読者が下部へ引き寄せられ、 文脈を失い、元の位置を探し直すことになって、体験が不安定になります。

実際には、これはスクロールの制御に集約されます。追従するタイミング、停止するタイミング、そして読者に判断を委ねるタイミングです。 優れたストリーミング チャットは次のように動作します。

  1. 読者が移動を求めたときだけ移動する。 読んでいる人を別の場所へ引っ張らない。自動スクロールを既定にしない。
  2. 読者が追従している間だけ追従する。 ライブ エッジにいる間はストリームを表示し、離れたらその位置に留める。
  3. すべての操作をシグナルとして扱う。 スクロールだけではない。テキスト選択、キーボード操作、リンクの表示、検索もインターフェースの移動を止めるべきです。
  4. 新しいターンをビューポート上部付近から始める。 これにより、新しいターンを冒頭から読める場所ができます。
  5. その後で回答をストリーミングする。 回答は画面内へ伸びていくべきで、すべてをすぐに押し流してはいけません。
  6. 前の会話の一部を文脈として残す。 プロンプトと返信を視覚的につなげ、読者が現在位置を把握できるよう前のターンを十分に表示します。
  7. 新しい内容を画面外に到着させる。 読者が見ている場所を変えずに、会話をストリーミングし続けられます。
  8. 見えていない場所で起きていることを示す。 返信のストリーミング中や新しいメッセージの到着を明確にします。
  9. 最新の返信へ戻りやすくする。 「最新へ移動」操作で読者を戻し、追従を再開できるようにします。
  10. 会話内のどこへでも移動できるようにする。 長いスレッドにはメッセージ リンク、検索、未読マーカー、直接ナビゲーションが必要です。
  11. 読者が離れた場所から再開する。 保存済みの会話は最後に意味のあるターンから開きます。多くの場合は最後のユーザー メッセージで、絶対的な末尾ではありません。
  12. レイアウトが変わっても読者の位置を保つ。 画像、Markdown、コード ブロック、上部に現れる古いメッセージがあっても位置を失わせません。
  13. 位置を奪わずに中断を処理する。 停止、再試行、再生成、分岐、エラーによって会話を予期せず移動させません。
  14. 長いスレッドでも応答性を保つ。 ストリーミング テキスト、Markdown、コード、画像、長い履歴でも軽快に動作させます。
  15. ノイズを増やさずアクセシブルにする。 トランスクリプトを操作可能にし、キーボード フォーカスを保ち、重要なイベントを無理のない速度で通知します。

読者の意図に反して移動させないでください。

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>
  <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>
          {/* a message, marker, or row */}
        </MessageScrollerItem>
        <MessageScrollerItem />
        <MessageScrollerItem />
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton />
  </MessageScroller>
</MessageScrollerProvider>
  • MessageScrollerProvider — スタイルを持たないルートです。開始位置、自動スクロール、アンカー、スクロール コマンド、表示状態追跡のための props とスクロール状態を管理します。
  • MessageScroller — スタイル付きフレームです。プロバイダー内でビューポート、コンテンツ、コントロールを配置します。
  • MessageScrollerViewport — スクロール可能な要素です。ネイティブのスクロール イベントを受け取り、古いメッセージが先頭に追加されたときも表示中の行を保持します。
  • MessageScrollerContent — トランスクリプト コンテナです。行を保持し、新しいメッセージ向けのライブ リージョンの既定値を提供します。
  • MessageScrollerItem — トランスクリプト行の境界です。コンテンツの直接の子要素をすべてこれでラップすると、スクローラーがサイズを測定し、固定、位置の保持、表示状態の追跡、移動を行えます。メッセージ、マーカー、入力中インジケーター、区切り、参加・退出イベント、「前を読み込む」行などに使えます。
  • MessageScrollerButton — スクロール コントロールです。トランスクリプトの先頭または末尾へ移動し、その方向にコンテンツがあるまで操作不能になります。

基本概念

ターンを固定する

ターンとは、新しいやり取りを開始する会話の一部分です。単純な AI チャットでは、通常はユーザーの メッセージと、それに続くアシスタントの返信を指します。

アンカーとは、ビューポートがそのターンの開始位置として扱う行です。その行に scrollAnchor を付けます。 新しいアンカーが追加されると、ビューポートはそれを上部付近へ移動し、直前の項目を一部だけ上に残します。 これにより、新しいターンが文脈から切り離されたように見えません。

// This tells the scroller to anchor the user's message for the next turn.
<MessageScrollerItem
  messageId={message.id}
  scrollAnchor={message.role === "user"}
/>

スクロール アンカーはメッセージのロールに結びついていません。ユーザー メッセージ、システム マーカー、引き継ぎイベントなど、 意味のあるターンを開始する任意の行をアンカーにできます。MessageScroller が必要とするのは、どの行をビューポートの基準にするかという情報だけです。

次の例では、ユーザーのメッセージをアンカーにしています。新しいメッセージを送信すると、ビューポートはそれを上部付近に固定し、その下にアシスタントの返信を追加します。アンカーをアシスタントのメッセージに切り替えて違いを確認してください。

ターンのアンカリング
上端付近に固定するロールを選択してください。
まだアンカーされたメッセージはありません
最初のメッセージを送信すると、選択したロールのアンカーを確認できます。
アンカーのロールを切り替えてメッセージを送信し、ターンがどこに収まるか比較してみましょう。
"use client"

import * as React from "react"

グループチャット

グループ チャットでは、ターンの境界は「ユーザー メッセージ」より具体的です。多くの場合、モデルに返信を求めるメッセージや、「Marcus がチャットに参加しました」のようなマーカーが境界になります。入力中インジケーターや履歴コントロールは通常、アンカーにしません。

アンカーはロールに依存しないため、メッセージと同じようにマーカーも簡単にアンカーにできます。

<MessageScrollerItem messageId="marcus-joined" scrollAnchor>
  <Marker variant="separator">
    <MarkerContent>Marcus joined the chat</MarkerContent>
  </Marker>
</MessageScrollerItem>
グループチャット
複数の参加者とアシスタントによるグループチャットです。Markerはターンとしてマークされています。
@mary、アストロファージの数値が金星のエネルギー出力とずっと一致するの。計算を確認してもらえる?
Mary (Agent)
うん、確認した。このカーブは、恒星のエネルギーを取り込みながら二酸化炭素の近くで増殖する微生物を示している。@rocky が同意すれば、これが探していた手がかりだ。
@rocky、いる?

マーカーを作成し、それをアンカーにします

ユーザーが参加すると、マーカーが作成されます。マーカーの scrollAnchor が次のターンとしてマークします
"use client"

import * as React from "react"

コンテキストを表示し続ける

新しいターンが始まっても、同じ連続したスレッドの一部として感じられるべきです。scrollPreviousItemPeek はアンカーの上に直前の項目を一部表示し、会話が白紙のページで再開したように感じさせず、読者の文脈を保ちます。

// Keep 64px of the previous turn visible above the newly anchored row.
<MessageScrollerProvider scrollPreviousItemPeek={64}>
  <MessageScroller>{/* anchored turns */}</MessageScroller>
</MessageScrollerProvider>

下の例で表示量を調整し、会話に与える影響を確認してください。

文脈を見えるままに保つ
新しいターンでも、直前の返信の一部が見えたままになります。

アプリにチャットを組み込んでいるのですが、AIが返信をストリーミングするたびに会話全体が飛び回ってしまい、スクロールの挙動に困っています

ストリーミング時によくあるスクロール問題なので、メッセージ一覧を `MessageScroller` で囲み、`autoScroll` を有効にすると、トークンが届くたび表示領域が下端に固定され、常に最新のテキストを確認できます

閲覧者が下端にいるときだけ自動スクロールするため、過去の内容を読むために上へスクロールすると位置を保持し、ユーザーの操作を邪魔せず滑らかなストリーミングを実現できます

なるほど、でも新しいメッセージを送ると会話全体が先頭から再読み込みされたように大きく動いてしまいます
64px
スライダーを調整して送信してください。直前のメッセージのピーク表示を確認できます
"use client"

import * as React from "react"

ライブエッジに追従する

読者がその場に留まっているか、戻ってきたことでライブ エッジにいる場合、autoScroll はストリーミング中の返信が伸びても表示範囲に保ちます。ホイール、タッチ、キーボードのスクロール キー、スクロールバーのドラッグでライブ エッジから離れると、追従を解除します。明示的なメッセージ移動でも解除されます。その後は、読者を移動させずに新しいチャンクを受け取れます。

autoScroll はターンの固定と組み合わせて使えます。新しいターンを上部付近に固定すると、その下へ返信がストリーミングされる間も表示位置は変わりません。返信がビューポートを埋めると、読者はライブ エッジに戻り、アンカーから出力追従へ切り替わります。

<MessageScrollerProvider autoScroll>
  <MessageScroller>{/* streamed turns */}</MessageScroller>
</MessageScrollerProvider>
メッセージのストリーミング
自動スクロールは会話の最新の位置に追従します。
ストリーミング準備完了
送信を押すと、あらかじめ用意されたローンチ概要がストリーミングされます。
アプリにチャットを組み込んでいるのですが、AIが返信をストリーミングするたびに会話全体が飛び回ってしまい、スクロールの挙動に困っています
ストリーミングはシミュレーションです。`autoScroll` が有効になっています。
"use client"

import { useChat } from "@ai-sdk/react"

autoScroll が有効な場合、scrollToEnd の呼び出しや MessageScrollerButton の押下で出力追従が再開します。そのため、離れた読者もライブ エッジへ戻って追従を続けられます。最新メッセージへのプログラムによるスクロール中は、ルートとビューポートに data-autoscrolling が公開されるため、遷移中だけ条件付きでスタイルを適用できます。

保存済みスレッドを開く

保存済みスレッドをトランスクリプトの絶対的な末尾で開くのは合理的に思えますが、多くの場合、十分な文脈がないまま会話の途中へ読者を置くことになります。よりよい既定値は "last-anchor" です。最後に意味のあるターン(たとえばユーザーの最新メッセージ)と、その下の返信を表示します。

これにより、読者はスレッド内の位置をすぐに把握できます。何を尋ねたか、回答がどこから始まるかを確認でき、末尾から会話を組み立て直すことなく読み進められます。

<MessageScrollerProvider defaultScrollPosition="last-anchor">
  <MessageScroller>{/* transcript */}</MessageScroller>
</MessageScrollerProvider>
開始位置
保存済みのトランスクリプトをどこから開くか選択してください。

これは会話の中でユーザーが最初に送信したメッセージです。

ワークスペースの作成は8%増加しましたが、最初の招待の完了は2%しか増えませんでした。

これは会話の中でユーザーが最後に送信したメッセージです。

招待のステップから始めましょう。チームはワークスペースを作成していますが、共同作業者の追加を後回しにしています。

推奨されるフォローアップ:

1. アカウント規模別に招待の離脱率を比較する。 2. 招待をスキップしたユーザーが24時間以内に戻ってくるかどうかを確認する。 3. 最初のプロジェクト画面の空状態の文言を見直す。 4. テンプレート別にアクティベーションを分けて見る。テンプレートを使うユーザーはすぐに招待を必要としない可能性があるため。

この傾向が続くなら、次の実験では招待を強く促すのではなく、より早い段階でコラボレーションを役立つものにするべきです。

defaultScrollPositionを切り替えて、スレッドを開いたときにトランスクリプトがどこから始まるかを確認してください

"last-anchor" はメッセージのロールではなく scrollAnchor を基準にします。アンカーが存在しない場合や、最後に固定したターンがすでにビューポートに収まっている場合は、"end" にフォールバックします。

会話の先頭から再開したい場合は "start" を、絶対的な最新メッセージへ移動するのが適切な場合は "end" を使います。

再読み込み時のちらつきを防ぐ

スクロール コンテナは常に上部から開きます。HTML には scrollTop を設定する方法がないため、サーバーでレンダリングされたトランスクリプトでは最も古いメッセージが先に表示されます。JavaScript の実行後に defaultScrollPosition が表示位置を移動させるため、ジャンプが見えてしまいます。

defaultScrollPosition が "end" または "last-anchor" の場合、位置が適用されるまでビューポートに data-pending-scroll が付きます。この属性がある間はスタイル付きビューポートが非表示になるため、ジャンプではなくフレームが表示されます。"start" では必要ありません。

初回描画から "end" を表示したい場合は、ビューポートの直後にインライン スクリプトを追加します。ビューポートに id を付けて末尾へスクロールし、data-pending-scroll を削除します。

const scrollToEndScript = `(function () {
  var viewport = document.getElementById("messages")
  if (!viewport) {
    return
  }
  viewport.scrollTop = viewport.scrollHeight
  viewport.removeAttribute("data-pending-scroll")
})()`
 
<MessageScroller>
  <MessageScrollerViewport id="messages" suppressHydrationWarning>
    <MessageScrollerContent>{/* transcript */}</MessageScrollerContent>
  </MessageScrollerViewport>
  <script dangerouslySetInnerHTML={{ __html: scrollToEndScript }} />
  <MessageScrollerButton />
</MessageScroller>

スクリプトはスクローラーではなくページに配置します。これは "end" の場合だけ、かつメッセージがすでに HTML にある場合だけ動作します。ビューポートに suppressHydrationWarning を追加してください。Content Security Policy を使う場合は nonce を渡します。

このスクリプトを "last-anchor" と組み合わせないでください。メッセージをクライアントで読み込む場合は省略します。

以前のメッセージを読み込む

以前のメッセージを読み込んでも、読者が見ている会話を移動させてはいけません。現在のトランスクリプトの上に古い行を追加すると、MessageScrollerViewport は表示中の行を保持し、上に履歴を読み込んでも読者を同じ位置に留めます。

これは preserveScrollOnPrepend によって既定で有効になります。

履歴の読み込み
先頭に追加されたメッセージでも現在の表示位置は保持されます。

変更されたのはエクスポートキューのワーカーだけです。このデプロイで大きなCSVジョブが共有のリトライポリシーに移されたため、失敗した試行がワーカースロットを以前より長く占有するようになりました。

このアプリのデプロイには、チェックアウト・価格設定・請求APIの変更は含まれていません。

ロールバックは必要ですか?

今のところは不要です。リトライの並列数を減らした後、キューの深さは回復しつつあり、最も古い保留中のジョブも5分未満になっています。

キューが再び増加し始めた場合に備えてロールバックは準備しておいてください。ただ、現在の傾向は回復に向かっています。

顧客に見える問題がないか監視を続けてください。

あと15分、キューとサポートのタグを監視します。エクスポートの失敗、ダウンロードリクエストの遅延、レポートが見つからないと言及しているサポートスレッドを追っています。

次のバッチウィンドウまでこれらが落ち着いていれば、社内的な性能劣化としてクローズできます。

会話の終わり

現在の表示位置を保ったまま、以前のメッセージを復元します。

「履歴を読み込む」をクリックすると、会話全体が読み込まれます
"use client"

import * as React from "react"

メッセージ行には安定した messageId 値を使います。これにより、ビューポート端にたまたまあるピクセルから推測せず、保持する具体的な行をスクローラーが特定できます。

新しいメッセージをアニメーション表示する

MessageScrollerItem は直接アニメーションできます。項目のモーション版を作成し、そこに messageId と scrollAnchor を保持して、表示時には transform と opacity を使います。

チャットでよくあるパターンは、送信時にユーザーのメッセージをアニメーションさせ、その下の通常の行へアシスタントの返信をストリーミングする方法です。ユーザー行を最終位置より下から開始すると、ビューポートのライブ エッジから浮かび上がるように見えます。

const MotionMessageScrollerItem = motion.create(MessageScrollerItem)
アニメーション
会話に追加されたときにユーザーのメッセージをどのようにアニメーションさせるか選択してください。
まだメッセージはありません
下のボタンをクリックして最初のメッセージを送信してください。
アニメーションを選択して送信をクリックすると、その動作を確認できます。
"use client"

import * as React from "react"

行の表示時に height、margin、padding をアニメーションさせないでください。これらの変更はスクローラーの位置調整と競合する可能性があります。読者がモーション低減を設定している場合は表示アニメーションを省略し、スクロール動作は同じにします。

メッセージへ移動する

検索結果、パーマリンク、アウトライン項目、ツールバー ボタンから、メッセージ リストの外側でトランスクリプトを操作したいことがあります。そのようなコントロールには useMessageScroller を使います。フックは MessageScrollerProvider から読み取るため、MessageScroller フレームの外側に描画したコントロールを含め、プロバイダー内の任意のコンポーネントで動作します。

import { useMessageScroller } from "@/components/ui/message-scroller"
const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller()
コマンド
外部からトランスクリプトを操作します。

ワークスペース作成後にアクティベーションが落ち込んでいるようです。どのステップが原因か一緒に調べてもらえますか?

最も大きく落ち込んでいるのは、ワークスペースの作成から最初のチームメイトを招待するまでの間です。

ワークスペース作成自体は依然として健全ですが、招待のステップでユーザーが手を止めています。これは、ユーザーがワークスペースに十分な自信を持つ前に、プロダクトがコラボレーションを求めてしまっていることを示唆しています。

オンボーディングフローを変更する前に、何を比較すればいいですか?

3つのコホートを比較してください。

1. チームメイトを招待する前にテンプレートを選ぶユーザー 2. 空のワークスペースから始めるユーザー 3. 招待をスキップして24時間以内に戻ってくるユーザー

テンプレートを使ったユーザーの方が招待が早いなら、対策としてはより強い招待プロンプトよりも、初回利用時のガイダンスを改善する方が効果的でしょう。

それを実験にできますか?

はい。ワークスペース作成後に短いチェックリストを表示するバリアントを作成します。

- テンプレートを選ぶ - プロジェクトの詳細を1つ追加する - ワークスペースに文脈ができたらチームメイトを招待する

最初の招待完了率、24時間以内の復帰率、そしてチームが2つ目のプロジェクトを作成するかどうかを測定してください。

招待プロンプトを遅らせるとどんなリスクがありますか?

主なリスクは、すでに誰を招待したいか分かっているアカウントのチーム作成を減らしてしまうことです。

その経路を守るために、招待アクションはヘッダーに表示し続け、変更するのはメインの空状態のガイダンスだけにしてください。そうすれば、確信を持ったチームには直接的な経路を提供しつつ、まだ迷っているユーザーを早すぎる段階で招待ステップに押し込まずに済みます。

コントロールを使って、会話内の任意のメッセージへジャンプできます。

scrollToMessage は MessageScrollerItem の messageId を対象にするため、移動対象の行には安定した ID を付けます。対象がマウントされておらずキューにも追加できない場合、scrollToMessage は false を返します。

scrollToMessage は項目が存在する前でも対象をキューに追加できるため、トランスクリプトのマウント中にクライアントで解決されるパーマリンクにも対応します。行がマウントされた後に ID が見つからない場合は、推測による再試行ループを開始せず false を返します。true はスクロールが実行されたかキューに追加されたことを示し、行がすでに表示中であることは示しません。

読者の位置を追跡する

useMessageScrollerVisibility を使って、会話内の読者の位置を追跡します。目次やジャンプ メニューで、現在のアンカー ターンを強調表示するのが一般的な例です。

import { useMessageScrollerVisibility } from "@/components/ui/message-scroller"
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
トランスクリプトのアウトライン
現在アンカーされているターンを追跡します。

インシデントの引き渡しを確認して、最初に読むべきものを教えてください。

まずサマリーと影響範囲のセクションから読んでください。この回帰はアップロードキューに影響しましたが、キューに入っていたすべてのジョブで復旧処理は完了しています。

顧客への影響はどの程度でしたか?

影響は処理の遅延にとどまりました。

レコードが失われることはなく、整合性を取るワーカーが各リトライバッチを確認済みです。サポートには2件の顧客からの問い合わせがありましたが、チェックアウトや請求のエラーはありませんでした。

未対応のアクションは何ですか?

次のデプロイまではリトライウィンドウを有効にしたままにし、長期的な対策としてキューの深さに対するアラートを追加してください。

このアラートは、単発の短いスパイクではなく、持続的なキューの増加に対して発火するようにしてください。

フォローアップのチェックリストをください。

その後、キューの復旧グラフとデプロイのタイムラインを比較し、処理がいつベースラインに戻ったのかを引き渡しに明確に示してください。そうすれば、サポートとエンジニアリングがインシデントのスレッドを丸ごと読み直さずに、同じ顧客からの質問に答えやすくなります。

各フォローアップ項目には短い担当者メモも追加するとよいでしょう。チェックリスト自体は小さいものですが、担当者を明確にしておくことで、リトライウィンドウの判断・アラートの調整・サポート用マクロがそれぞれ別のフォローアップの会話に分散してしまうのを防げます。

次のデプロイまではリトライウィンドウを有効にしたままにし、長期的な対策としてキューの深さに対するアラートを追加してください。

このアラートは、単発の短いスパイクではなく、持続的なキューの増加に対して発火するようにしてください。

アウトラインを開くと、読み進めながらアンカーされたターン間を移動できます。

currentAnchorId は現在のアンカー ターンを返して「自分はどこにいるか」に答えます。アンカーがビューポートの上へスクロールされた後も値は保持されます。visibleMessageIds はドキュメント順で「画面に何があるか」に答えます。

表示状態の追跡は利用時だけコストが発生します。何かが useMessageScrollerVisibility を購読している間だけ追跡が実行され、行が参加するには messageId が必要です。

スクロール状態を読み取る

ステータス インジケーターやカスタムの「最新へ移動」コントロールなど、JavaScript でスクロール状態が必要な場合は useMessageScrollerScrollable を使います。ビューポートがまだスクロールできる方向を返します。「先頭/末尾にいる」は否定(!start / !end)で表し、「どちらかにスクロール可能」は start || end です。スクローラー自体のスタイルには data-scrollable 属性を優先してください。

import { useMessageScrollerScrollable } from "@/components/ui/message-scroller"
const { start, end } = useMessageScrollerScrollable()
スクロールの状態
現在のスクロール位置に応じて、読者がどちらへスクロールできるかを示します。

スクロールチェックポイント1を確認してください。

チェックポイント2は同期済みです。表示領域が移動すると、scrollableフックが更新されます。

読者が最初のメッセージにいるときは、フッターは下方向のみを示すべきです。トランスクリプトの中間に移動すると、両方向に進めることを説明する必要があります。

最新のメッセージにいるときは、フッターは再び切り替わり、上方向のみを示すべきです。

スクロールチェックポイント3を確認してください。

チェックポイント4は同期済みです。表示領域が移動すると、scrollableフックが更新されます。

読者が最初のメッセージにいるときは、フッターは下方向のみを示すべきです。トランスクリプトの中間に移動すると、両方向に進めることを説明する必要があります。

最新のメッセージにいるときは、フッターは再び切り替わり、上方向のみを示すべきです。

スクロールチェックポイント5を確認してください。

チェックポイント6は同期済みです。表示領域が移動すると、scrollableフックが更新されます。

読者が最初のメッセージにいるときは、フッターは下方向のみを示すべきです。トランスクリプトの中間に移動すると、両方向に進めることを説明する必要があります。

最新のメッセージにいるときは、フッターは再び切り替わり、上方向のみを示すべきです。

スクロールチェックポイント7を確認してください。

チェックポイント8は同期済みです。表示領域が移動すると、scrollableフックが更新されます。

読者が最初のメッセージにいるときは、フッターは下方向のみを示すべきです。トランスクリプトの中間に移動すると、両方向に進めることを説明する必要があります。

最新のメッセージにいるときは、フッターは再び切り替わり、上方向のみを示すべきです。

スクロールチェックポイント9を確認してください。

チェックポイント10は同期済みです。表示領域が移動すると、scrollableフックが更新されます。

読者が最初のメッセージにいるときは、フッターは下方向のみを示すべきです。トランスクリプトの中間に移動すると、両方向に進めることを説明する必要があります。

最新のメッセージにいるときは、フッターは再び切り替わり、上方向のみを示すべきです。

スクロールチェックポイント11を確認してください。

チェックポイント12は同期済みです。表示領域が移動すると、scrollableフックが更新されます。

読者が最初のメッセージにいるときは、フッターは下方向のみを示すべきです。トランスクリプトの中間に移動すると、両方向に進めることを説明する必要があります。

最新のメッセージにいるときは、フッターは再び切り替わり、上方向のみを示すべきです。

すべてのメッセージが表示領域内に収まっています。
トランスクリプトをスクロールして、フッターの更新を確認してください。
"use client"

import { MessageAnimated } from "@/components/message-animated"

パフォーマンス

MessageScroller は、Markdown と複合的なメッセージ行を含む大規模なトランスクリプトでベンチマークしています。

MessageScroller のパフォーマンス目標は、スクロールのホットパスを React の状態の外に保つことです。トランスクリプト行の React 再レンダリングを発生させず、スクロールごとの強制レイアウトを避け、ブラウザーが省略できる画面外の描画処理を最小限にします。

スクロール位置、アンカー、出力追従は命令的に追跡し、data-* 属性を通じてルートとビューポートへ反映します。そのため、スクロールやストリーミングでトランスクリプト行が再レンダリングされません。

スタイル付きの MessageScrollerItem には content-visibility: auto と contain-intrinsic-size も設定されています。選択、コピー、ページ内検索、SSR、支援技術のために行は DOM に残りますが、ブラウザーはビューポートから遠く離れた行の描画処理を省略できます。

表示状態の追跡は利用時だけコストが発生します。何かが 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" によってトランスクリプトをライブ リージョンとしてマークします。新しい行は通知できますが、ストリーミング中のテキスト変更をトークンごとに通知する必要はありません。

<MessageScrollerContent aria-busy={status === "streaming"}>
  {/* messages */}
</MessageScrollerContent>

メッセージ行が完成するまで通知を待たせたい場合は、ターンのストリーミング中に aria-busy を渡します。

MessageScrollerButton は実際のボタンを描画します。スクロール先がない場合は inert を設定し、tabIndex={-1} を使い、data-active="false" を公開します。これにより、非アクティブなスクロール コントロールが余分なフォーカス停止位置を作りません。

スタイルなし

MessageScroller の動作は @shadcn/react パッケージから提供されます。独自のマークアップとスタイルで直接使う方法は、@shadcn/react の Message Scroller を参照してください。

API リファレンス

各パーツの props、データ属性、フックについては、@shadcn/react Message Scroller のページに記載しています。スタイル付きコンポーネントとスタイルなしパーツで内容は同じです。