125k

Message Scroller

独自のマークアップとスタイルで、@shadcn/react パッケージの MessageScroller 動作を直接使用します。

MessageScroller は @shadcn/react パッケージのスタイルなしプリミティブとして提供されています。 このパッケージは、すべてのスクロール動作、ターンの固定、ストリーミング出力への追従、履歴読み込み時の読者位置の保持、 表示状態の追跡を管理し、独自のスタイルは描画しません。

レジストリの message-scroller.tsx コンポーネントは、その上に Tailwind クラスを追加する薄いラッパーです。 マークアップとスタイルを完全に制御したい場合や、レジストリを使わない場合は、パッケージを直接使用してください。

動作ガイドと実際に操作できる例については、 Message Scroller コンポーネントをご覧ください。

インストール

pnpm add @shadcn/react

使い方

import {
  MessageScroller,
  useMessageScroller,
} from "@shadcn/react/message-scroller"

パッケージはフラットなコンポーネントではなく、名前空間オブジェクトをエクスポートします。 パーツと動作はスタイル付きコンポーネントと同じで、スタイルだけがありません。

<MessageScroller.Provider>
  <MessageScroller.Root>
    <MessageScroller.Viewport>
      <MessageScroller.Content>
        {messages.map((message) => (
          <MessageScroller.Item
            key={message.id}
            messageId={message.id}
            scrollAnchor={message.role === "user"}
          >
            {/* your message UI */}
          </MessageScroller.Item>
        ))}
      </MessageScroller.Content>
    </MessageScroller.Viewport>
    <MessageScroller.Button />
  </MessageScroller.Root>
</MessageScroller.Provider>

パーツ

スタイル付きコンポーネントから移行する場合、フラットなパーツは次のように名前空間オブジェクトへ対応します。

Styled componentUnstyled part
MessageScrollerProviderMessageScroller.Provider
MessageScrollerMessageScroller.Root
MessageScrollerViewportMessageScroller.Viewport
MessageScrollerContentMessageScroller.Content
MessageScrollerItemMessageScroller.Item
MessageScrollerButtonMessageScroller.Button

フックも同じ方法でインポートし、MessageScroller.Provider から読み取るため同じように動作します。

import {
  useMessageScroller,
  useMessageScrollerScrollable,
  useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"

例

独自のスタイルを使い、スクローラーを AI SDK に接続する完全な例を示します。

"use client"
 
import { useChat } from "@ai-sdk/react"
import { MessageScroller } from "@shadcn/react/message-scroller"
import { DefaultChatTransport } from "ai"
 
import { ChatInput } from "@/components/chat-input"
 
export function Chat() {
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  })
 
  return (
    <div className="flex h-svh w-full flex-col">
      <MessageScroller.Provider>
        <MessageScroller.Root className="relative flex flex-1 flex-col overflow-hidden">
          <MessageScroller.Viewport className="flex flex-1 flex-col overflow-y-auto">
            <MessageScroller.Content className="flex flex-col gap-4 p-6 text-base">
              {messages.map((message, index) => (
                <MessageScroller.Item
                  key={message.id}
                  messageId={`message-${index}`}
                  scrollAnchor={message.role === "user"}
                >
                  <div className="rounded-lg bg-muted p-4">
                    {message.parts.map((part, i) =>
                      part.type === "text" ? (
                        <span key={i}>{part.text}</span>
                      ) : null
                    )}
                  </div>
                </MessageScroller.Item>
              ))}
            </MessageScroller.Content>
          </MessageScroller.Viewport>
          <MessageScroller.Button className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background px-3 py-1 text-sm font-medium inert:opacity-0">
            最新へ移動
          </MessageScroller.Button>
        </MessageScroller.Root>
      </MessageScroller.Provider>
      <ChatInput onSend={sendMessage} disabled={status !== "ready"} />
    </div>
  )
}

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

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

defaultScrollPosition が "end" または "last-anchor" の場合、位置が適用されるまでビューポートに data-pending-scroll が付きます。ビューポートに id を付け、この属性がある間は非表示にします。ルートまで隠さないよう、ルールはその id に限定してください。

#messages[data-pending-scroll] {
  visibility: hidden;
}

初回描画から "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.Root>
  <MessageScroller.Viewport id="messages" suppressHydrationWarning>
    <MessageScroller.Content>{/* transcript */}</MessageScroller.Content>
  </MessageScroller.Viewport>
  <script dangerouslySetInnerHTML={{ __html: scrollToEndScript }} />
  <MessageScroller.Button />
</MessageScroller.Root>

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

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

API リファレンス

MessageScroller.Provider

スタイルなしのルートです。スクロール状態と動作用 props を管理し、パーツとフックへ提供します。独自の DOM は描画しません。

PropTypeDefaultDescription
autoScrollbooleanfalse読者がすでにライブ エッジにいる間だけ新しい内容に追従します。ホイール、タッチ、キーボード スクロール、明示的な移動で解除されます。
defaultScrollPosition"start" | "end" | "last-anchor""end"空でない最初の描画時に一度だけ適用する開始位置です。"last-anchor" は最後の scrollAnchor 行から開き、ターンが収まる場合やアンカーがない場合は "end" にフォールバックします。"end" と "last-anchor" では、位置が適用されるまでビューポートに data-pending-scroll が付きます。再読み込み時のちらつきを防ぐを参照してください。
scrollEdgeThresholdnumber8先頭または末尾にいると判定する、各端からの距離です。状態属性とスクロール ボタンの表示を制御します。
scrollMarginnumber0scrollToMessage、表示状態、プログラムによる対象に適用する、整列先の端からのマージンです。
scrollPreviousItemPeeknumber64新しく追加された scrollAnchor 項目を配置する際、前の項目の一部を表示し続けるため scrollMargin に加える余白です。

MessageScroller.Root

フレーム兼レイアウト コンテナです。親要素いっぱいに広がるため、MessageScroller.Provider 内の高さを制約したレイアウトで使用します。

PropTypeDefaultDescription
...propsReact.ComponentProps<"div">-フレーム要素に展開する props です。

ルートは以下のスクロール状態属性をミラーリングします(ビューポートにも付与されます)。そのため、フレーム端のフェードなど、 スクロール状態に応じてコンテナをスタイル設定できます。スタイルなしで使う場合は、開始時のスクロール用レイアウトを維持するため、 data-pending-scroll がある間は visibility: hidden でビューポートを隠してください。

Data attributeValueDescription
data-scrollable"start" | "end" | "start end" | absentビューポートがスクロールできる方向です。[data-scrollable~="end"] のように検索できます。存在しない場合は内容が収まっています。
data-autoscrollingpresentビューポートが最新メッセージへプログラムでスクロールしている間に存在します。
data-pending-scrollpresentdefaultScrollPosition("end" または "last-anchor")が適用されるまで存在します。空のトランスクリプトでは省略されます。設定中はビューポートを隠してください。再読み込み時のちらつきを防ぐを参照してください。

MessageScroller.Viewport

スクロール可能なビューポートです。

PropTypeDefaultDescription
preserveScrollOnPrependbooleantrue古い行を先頭に追加したとき、最初に表示されていたメッセージ項目を同じ位置に保ちます。
rolestring"region"ラベル付きスクロール可能トランスクリプト ビューポートのランドマーク ロールです。
aria-labelstring"Messages"スクロール可能なチャット トランスクリプトのアクセシブルな名前です。
tabIndexnumber0トランスクリプト ビューポートをキーボードでスクロール可能にします。
...propsReact.ComponentProps<"div">-ビューポート要素に展開する props です。
Data attributeValueDescription
data-scrollable"start" | "end" | "start end" | absentビューポートがスクロールできる方向です。[data-scrollable~="end"] のように検索できます。存在しない場合は内容が収まっています。
data-autoscrollingpresentビューポートが最新メッセージへプログラムでスクロールしている間に存在します。
data-pending-scrollpresentdefaultScrollPosition("end" または "last-anchor")が適用されるまで存在します。空のトランスクリプトでは省略されます。設定中はビューポートを隠してください。再読み込み時のちらつきを防ぐを参照してください。

MessageScroller.Content

トランスクリプトのコンテンツ要素です。直接の子要素はすべて MessageScroller.Item にします。

PropTypeDefaultDescription
rolestring"log"ライブ通知用にメッセージ リストへ適用する ARIA ロールです。
aria-relevantstring"additions"通知するライブ リージョンの更新です。既定では新しいトランスクリプト行だけです。
aria-busyboolean-必要に応じて、ターンのストリーミング中にライブ リージョンがビジーであることを示します。
spacerClassNamestring-アンカー行のための空間を確保する内部スペーサーのクラス名です。
...propsReact.ComponentProps<"div">-コンテンツ要素に展開する props です。

MessageScroller.Item

トランスクリプトの 1 行です。メッセージ、マーカー、入力中の行、区切り、追加読み込み行などを表します。

PropTypeDefaultDescription
messageIdstring-scrollToMessage、表示状態、先頭追加時の位置保持に使う安定した行 ID です。
scrollAnchorbooleanfalse新しく追加されるターンの基準となるターン境界として、この行をマークします。
...propsReact.ComponentProps<"div">-項目要素に展開する props です。
Data attributeValueDescription
data-message-idstring指定されている場合、messageId を反映します。
data-scroll-anchor"true" | "false"scrollAnchor を反映します。

MessageScroller.Button

トランスクリプトの先頭または末尾へスクロールするボタンです。スクロール先がない場合は inert になり、タブ順から除外されます。

PropTypeDefaultDescription
behaviorScrollBehavior"smooth"ボタンが対象の端へスクロールするときに使うネイティブのスクロール動作です。
direction"start" | "end""end"ボタンが向かうトランスクリプトの端です。
childrenReact.ReactNode-ボタンのカスタム コンテンツです。既定ではスクロール アイコンとアクセシブル ラベルを表示します。
renderReact.ReactElement | render function-カスタムの描画先です。
...propsReact.ComponentProps<"button">-ボタンに展開する props です。
Data attributeValueDescription
data-direction"start" | "end"direction を反映します。
data-active"true" | "false"現在このボタンでスクロールできるかを示します。

useMessageScroller

命令的にトランスクリプトを操作するための API です。

MethodTypeDescription
scrollToMessage(messageId: string, options?) => booleanマウント済みのメッセージ ID へスクロールします。
scrollToEnd(options?) => boolean最新のメッセージへスクロールします。
scrollToStart(options?) => boolean上部へスクロールします。

すべてのコマンドは適用できなかった場合に false を返します。 scrollToStart と scrollToEnd は、ビューポートがまだマウントされていない場合だけ false を返します。 scrollToMessage は、対象がマウントされておらずキューにも追加できない場合に false を返します。

コマンドのオプション:

OptionTypeDefaultDescription
align"start" | "center" | "end" | "nearest""start"メッセージ対象をビューポート内でどのように整列するかです。
behaviorScrollBehavior"auto"コマンドに使うネイティブのスクロール動作です。
scrollMarginnumberprovider scrollMarginこのコマンドで整列先の端に適用するマージンです。

useMessageScrollerScrollable

JavaScript で値が必要な周辺 UI 向けに、ビューポートがスクロールできる方向を返します。 スクローラー自体のスタイルには data-scrollable 属性を優先してください。

ValueTypeDescription
startbooleanビューポートが先頭方向へスクロールできるかを示します。上にコンテンツが隠れている状態です(!start は先頭を示します)。
endbooleanビューポートが末尾方向へスクロールできるかを示します。下にコンテンツが隠れている状態です(!end は末尾を示します)。出力追従が読者をライブ エッジに保っている間は false のままです。

useMessageScrollerVisibility

アウトライン、検索、アクティブ ターン UI の表示状態です。useMessageScrollerScrollable とは別に購読するため、 利用側が必要とする場合だけ表示状態の処理コストが発生します。

ValueTypeDescription
currentAnchorIdstring | null読み取りライン上またはそれより上にある最後の scrollAnchor 項目を基準にした、現在のアンカー ターンです。
visibleMessageIdsstring[]ビューポートと交差するメッセージ ID をドキュメント順で返します。

ユーザー メッセージ、アンカー ターン、検索結果など、より限定したアウトラインが必要な場合は、アプリ内で visibleMessageIds を絞り込んでください。