HextaUI

Message scroller

Yeni mesajları takip eden, geriye doğru okurken yerinizi koruyan ve kaçırdıklarınızı atlama düğmesinde sayan bir sohbet kaydırma alanı.

New chat

How can I help you today?

Morning!
Press send to start the conversation.
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.

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&apos;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

Bileşeni, HextaUI tema token'larını ve bileşenin bağımlı olduğu tüm HextaUI bileşenlerini ekler.

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>

Her öğeye kararlı bir messageId verin. Kaydırıcı bunu yeni mesajları takip etmek, yerinizi korumak ve görmediklerinizi saymak için kullanır. Satırlar için <Message /> ve <Bubble /> ile birlikte kullanın.

MessageScrollerProvider
└── MessageScroller
    ├── MessageScrollerViewport
    │   └── MessageScrollerContent
    │       └── MessageScrollerItem
    └── MessageScrollerButton

Grup sohbeti

Çapalar mesaj olmak zorunda değildir. Burada birinin katılması için bir <Marker /> turu başlatır. Yukarı kaydırın, mesaj alın; atlama düğmesi siz yokken gelenleri sayar.

"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>
  )
}

Bağlamı koruma

scrollPreviousItemPeek yeni çapalanan turun üstünde önceki turdan bir dilim tutar, böylece konuşma kesintisiz hissedilir. Her miktarı deneyin, sonra bir sonraki soruyu sorun.

"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>
  )
}

Kaydedilmiş konuşmaları açma

defaultScrollPosition="last-anchor" bir konuşmayı, okuyucuyu cevabın ortasında en alta bırakmak yerine, son sorusunda ve cevabı altta olacak şekilde yeniden açar.

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>
  )
}

Önceki mesajları yükleme

Üste eklenen eski mesajlar okuduğunuz yeri yerinde tutar (preserveScrollOnPrepend, varsayılan olarak açık).

"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>
  )
}

Mesajlara atlama

useMessageScroller konuşmayı dışarıdan yönetir, useMessageScrollerVisibility ise geçerli turu bildirir; böylece anahat nerede olduğunuzu vurgular.

"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>
  )
}

Sağlayıcının içinde bu hook'lar kendi denetimlerinizin konuşmayı kaydırmasına ve ekrandakilere tepki vermesine olanak tanır.

const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScroller()
const { start, end } = useMessageScrollerScrollable()
const { visibleMessageIds, currentAnchorId } = useMessageScrollerVisibility()
TuşAction
TabKonuşmaya odaklanır. Atlama düğmesi yalnızca görünürken sekme sırasına girer.
↑↓Konuşmayı kaydırır.
Page UpPage DownBir ekran kadar kaydırır.
HomeEndİlk veya son mesaja atlar.
  • Viewport etiketli, odaklanabilir bir bölgedir ve içerik bir role="log"'dur; böylece ekran okuyucular yeni mesajları geldikçe duyurur. Viewport'a bir aria-label verin.
  • Atlama düğmesinin etiketi kaç mesajın yeni olduğunu söyler; böylece yalnızca bir ok değil, "3 yeni mesaj" okunur.
  • Yeni mesajları takip etmek, geriye doğru okurken sizi asla oynatmaz. Yalnızca yeniden en alta geldiğinizde devam eder.
PropTürVarsayılan
autoScrollOkuyucu en alttayken yeni mesajları takip eder.
booleanfalse
defaultScrollPositionKonuşmanın açıldığı yer.
"start" | "end" | "last-anchor""end"
scrollPreviousItemPeekYeni bir çapanın üstünde önceki öğenin ne kadarının görünür kalacağı.
number64
scrollEdgeThresholdBir kenarda sayılacak, kenara olan piksel mesafesi.
number–
scrollMarginGörünüme kaydırılan mesajların üstünde bırakılan boşluk.
number–
PropTürVarsayılan
preserveScrollOnPrependMesajlar üste eklendiğinde okuma konumunu korur.
booleanfalse
aria-labelKonuşma bölgesini adlandırır.
string–
ÖznitelikAçıklama
data-scrollable="start end"Hangi kenarlarda kaydırılacak daha fazla içerik olduğu. Kenar solmalarını yönetir.
data-autoscrollingYeni mesajlar takip edilirken bulunur. Kaydırma çubuğu gizlenir.
--scroller-fade-start / --scroller-fade-endKenar solmalarının boyutu. Giriş ve çıkışta animasyonludur.
PropTürVarsayılan
messageIdTakip, çapalar ve sayımlar için kararlı id.
string–
scrollAnchordefaultScrollPosition="last-anchor" ile bu öğe geldiğinde onu üste kaydırır.
booleanfalse
PropTürVarsayılan
direction
"start" | "end""end"
showUnseenYukarı kaydırılmışken gelen mesajları sayan bir hapa dönüşür.
booleantrue
unseenLabel
(count: number) => ReactNode"3 new messages"
variant
Button variant"outline"
behavior
ScrollBehavior"smooth"
ÖznitelikAçıklama
data-slot="message-scroller-button"CSS'te düğmeyi hedefler.
data-activeAtlanacak bir yer olduğu sürece "true".
data-unseenGörülmeyenlerin sayısı; sıfırın üstündeyken.

Kullanıldığı bloklar

Message scroller üzerine kurulan bloklar.