A chat scroll area that follows new messages, keeps your place while you read back, and counts what you missed on the jump button.
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.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @shadcn/react @tabler/icons-react cn - 3
Copy and paste the following code into your project.
components/ui/message-scroller.tsx "use client" import * as React from "react" import { MessageScroller as MessageScrollerPrimitive, useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility, } from "@shadcn/react/message-scroller" import { IconArrowDown } from "@tabler/icons-react" import { cn } from "cn" import { useSizeMorph } from "@/lib/motion" import { Button } from "@/components/ui/button" function MessageScrollerProvider( props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider> ) { return <MessageScrollerPrimitive.Provider {...props} /> } function MessageScroller({ className, ...props }: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) { return ( <MessageScrollerPrimitive.Root data-slot="message-scroller" className={cn( "group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden", className )} {...props} /> ) } function MessageScrollerViewport({ className, ...props }: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) { return ( <MessageScrollerPrimitive.Viewport data-slot="message-scroller-viewport" className={cn( "size-full min-h-0 min-w-0 [scrollbar-width:thin] [scrollbar-color:var(--color-border)_transparent] [scrollbar-gutter:stable] overflow-y-auto overscroll-none mask-[linear-gradient(to_bottom,transparent,#000_var(--scroller-fade-start),#000_calc(100%-var(--scroller-fade-end)),transparent)] transition-[--scroller-fade-start,--scroller-fade-end] duration-200 ease-out-cubic [contain:content] outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden focus-visible:ring-inset data-autoscrolling:[scrollbar-color:transparent_transparent] data-pending-scroll:invisible data-[scrollable~=end]:[--scroller-fade-end:--spacing(10)] data-[scrollable~=start]:[--scroller-fade-start:--spacing(10)] motion-reduce:transition-none", className )} {...props} /> ) } const MessageScrollerReadyContext = React.createContext(false) function useSpacerHold(contentRef: React.RefObject<HTMLDivElement | null>) { React.useLayoutEffect(() => { const content = contentRef.current const viewport = content?.parentElement const spacer = content?.querySelector<HTMLElement>( "[data-message-scroller-spacer]" ) if ( !content || !viewport || !spacer || typeof ResizeObserver === "undefined" ) { return } let lastScrollTop = viewport.scrollTop let spacerHeight = spacer.getBoundingClientRect().height let held = Infinity let collapsed = false let reading = false const naturalHeight = () => { const rows = Array.from(content.children).filter( (child): child is HTMLElement => child instanceof HTMLElement && child !== spacer ) const last = rows.at(-1) const padding = parseFloat(getComputedStyle(content).paddingBottom) || 0 return last ? last.offsetTop + last.offsetHeight + padding : 0 } const release = () => { collapsed = false if (held !== Infinity) { held = Infinity content.style.minHeight = "" } } const hold = (scrollTop: number) => { const floor = Math.min( held, scrollTop + viewport.clientHeight - content.offsetTop ) if (floor <= Math.max(viewport.clientHeight, naturalHeight()) + 0.5) { release() return } held = floor content.style.minHeight = `${floor}px` } const onScroll = () => { lastScrollTop = viewport.scrollTop if (collapsed && held !== Infinity) { hold(lastScrollTop) } } const onUserScroll = () => { reading = true if (!collapsed) { release() } } const onSpacer = () => { const next = spacer.getBoundingClientRect().height const shrank = next < spacerHeight - 0.5 spacerHeight = next if (next > 0.5) { collapsed = false if (reading) { return } content.style.minHeight = "" held = content.getBoundingClientRect().height content.style.minHeight = `${held}px` return } if (held !== Infinity) { collapsed = true hold(viewport.scrollTop) return } if (shrank) { const keep = lastScrollTop collapsed = true hold(keep) viewport.scrollTop = keep } } const onRows = () => { reading = false release() } const spacerObserver = new ResizeObserver(onSpacer) spacerObserver.observe(spacer) const rows = new MutationObserver(onRows) rows.observe(content, { childList: true }) viewport.addEventListener("scroll", onScroll, { passive: true }) const intents = ["wheel", "touchstart", "keydown", "pointerdown"] as const for (const type of intents) { viewport.addEventListener(type, onUserScroll, { passive: true }) } return () => { spacerObserver.disconnect() rows.disconnect() viewport.removeEventListener("scroll", onScroll) for (const type of intents) { viewport.removeEventListener(type, onUserScroll) } content.style.minHeight = "" } }, [contentRef]) } function MessageScrollerContent({ className, ref, ...props }: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) { const [ready, setReady] = React.useState(false) const contentRef = React.useRef<HTMLDivElement | null>(null) useSpacerHold(contentRef) const setRef = React.useCallback( (node: HTMLDivElement | null) => { contentRef.current = node if (typeof ref === "function") { return ref(node) } if (ref) { ref.current = node } return undefined }, [ref] ) React.useEffect(() => { setReady(true) }, []) return ( <MessageScrollerReadyContext.Provider value={ready}> <MessageScrollerPrimitive.Content ref={setRef} data-slot="message-scroller-content" className={cn("flex h-max min-h-full flex-col gap-4 p-4", className)} {...props} /> </MessageScrollerReadyContext.Provider> ) } type MessageScrollerItemProps = React.ComponentProps< typeof MessageScrollerPrimitive.Item > & { animated?: boolean } function MessageScrollerItem({ className, scrollAnchor = false, animated = true, ...props }: MessageScrollerItemProps) { const ready = React.useContext(MessageScrollerReadyContext) const [entering] = React.useState(() => animated && ready) return ( <MessageScrollerPrimitive.Item data-slot="message-scroller-item" data-entering={entering ? "" : undefined} scrollAnchor={scrollAnchor} className={cn( "min-w-0 shrink-0 [contain-intrinsic-size:auto_6rem] [content-visibility:auto] data-entering:motion-safe:animate-message-in motion-reduce:data-entering:animate-in motion-reduce:data-entering:fade-in-0", className )} {...props} /> ) } function useUnseenCount(enabled: boolean) { const anchorRef = React.useRef<HTMLButtonElement | null>(null) const { visibleMessageIds } = useMessageScrollerVisibility() const { end } = useMessageScrollerScrollable() const seenRef = React.useRef<Set<string> | null>(null) const [unseen, setUnseen] = React.useState(0) React.useEffect(() => { if (!enabled) { return } const root = anchorRef.current?.closest("[data-slot=message-scroller]") if (!root) { return } const ids = () => Array.from( root.querySelectorAll<HTMLElement>("[data-message-id]"), (node) => node.dataset.messageId ?? "" ).filter(Boolean) if (!seenRef.current) { seenRef.current = new Set(ids()) } const seen = seenRef.current const count = () => { if (!end) { for (const id of ids()) { seen.add(id) } setUnseen(0) return } for (const id of visibleMessageIds) { seen.add(id) } setUnseen(ids().filter((id) => !seen.has(id)).length) } count() const content = root.querySelector("[data-slot=message-scroller-content]") if (!content) { return } const observer = new MutationObserver(count) observer.observe(content, { childList: true }) return () => observer.disconnect() }, [enabled, end, visibleMessageIds]) return { anchorRef, unseen } } function defaultUnseenLabel(count: number) { return count === 1 ? "1 new message" : `${count} new messages` } type MessageScrollerButtonProps = React.ComponentProps< typeof MessageScrollerPrimitive.Button > & Pick<React.ComponentProps<typeof Button>, "variant"> & { showUnseen?: boolean unseenLabel?: (count: number) => React.ReactNode } function MessageScrollerButton({ direction = "end", className, children, render, variant = "outline", showUnseen = true, unseenLabel = defaultUnseenLabel, ref, ...props }: MessageScrollerButtonProps) { const counting = showUnseen && direction === "end" && children === undefined const { anchorRef, unseen } = useUnseenCount(counting) const morphRef = useSizeMorph<HTMLButtonElement>({ axis: "width", duration: 260, }) const setRef = React.useCallback( (node: HTMLButtonElement | null) => { anchorRef.current = node const cleanup = morphRef(node) if (typeof ref === "function") { ref(node) } else if (ref) { ref.current = node } return () => { cleanup?.() anchorRef.current = null } }, [anchorRef, morphRef, ref] ) const label = counting && unseen > 0 ? unseenLabel(unseen) : null return ( <MessageScrollerPrimitive.Button ref={setRef} data-slot="message-scroller-button" data-direction={direction} data-unseen={label ? unseen : undefined} direction={direction} className={cn( "absolute start-1/2 z-10 -translate-x-1/2 overflow-hidden rounded-full shadow-md transition-[translate,scale,opacity] duration-200 data-morphing:overflow-clip data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-300 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-out-quint data-[direction=end]:bottom-3 data-[direction=end]:data-[active=false]:translate-y-2 data-[direction=start]:top-3 data-[direction=start]:data-[active=false]:-translate-y-2 motion-reduce:transition-opacity rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180", className )} render={ render ?? ( <Button variant={variant} size={label ? "sm" : "icon-sm"} className={label ? "ps-2.5 pe-3" : undefined} /> ) } {...props} > {children ?? ( <> <IconArrowDown /> {label ? ( <span className="whitespace-nowrap">{label}</span> ) : ( <span className="sr-only"> {direction === "end" ? "Scroll to latest" : "Scroll to start"} </span> )} </> )} </MessageScrollerPrimitive.Button> ) } export { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility, } export type { MessageScrollerButtonProps, MessageScrollerItemProps }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" import { Spinner } from "@/components/ui/spinner" import { useButtonFeedback, type ButtonFeedbackOptions, type ButtonStatus, } from "@/hooks/use-button-feedback" const buttonVariants = cva( "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70", link: "text-primary underline-offset-4 hover:underline forced-colors:border-0", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40", error: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake", }, size: { default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5", lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", icon: "size-9 shrink-0", "icon-xs": "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden", "icon-lg": "size-10 shrink-0", }, }, defaultVariants: { variant: "default", size: "default", }, } ) type ButtonVariant = Exclude< NonNullable<VariantProps<typeof buttonVariants>["variant"]>, "success" | "error" > type ButtonClickEvent = Parameters< NonNullable<ButtonPrimitive.Props["onClick"]> >[0] type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> & Omit<VariantProps<typeof buttonVariants>, "variant"> & ButtonFeedbackOptions & { variant?: ButtonVariant | null onClick?: (event: ButtonClickEvent) => unknown feedback?: boolean loading?: boolean status?: ButtonStatus loadingLabel?: React.ReactNode successLabel?: React.ReactNode errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode) } const stateTransitionTime = 220 const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"] const quietVariants: ButtonVariant[] = ["ghost", "link"] const quietStatusClasses: Partial<Record<ButtonStatus, string>> = { success: "text-success hover:text-success", error: "text-destructive hover:text-destructive motion-safe:animate-button-shake", } function isThenable(value: unknown): value is PromiseLike<unknown> { return ( typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" ) } function useStatusTransition(status: ButtonStatus) { const [shown, setShown] = React.useState(status) const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null) const [transitionKey, setTransitionKey] = React.useState(0) if (status !== shown) { setLeaving(shown) setShown(status) setTransitionKey((key) => key + 1) } React.useEffect(() => { if (leaving === null) { return } const timer = setTimeout(() => setLeaving(null), stateTransitionTime) return () => clearTimeout(timer) }, [leaving, transitionKey]) return { leaving, transitionKey } } function useLayerWidth(status: ButtonStatus, active: boolean) { const contentRef = React.useRef<HTMLSpanElement>(null) const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>()) const observerRef = React.useRef<ResizeObserver | null>(null) const statusRef = React.useRef(status) const applyWidth = React.useCallback(() => { const content = contentRef.current const layer = layersRef.current.get(statusRef.current) if (!content || !layer) { return } const natural = parseFloat(getComputedStyle(layer).width) if (!Number.isFinite(natural)) { return } content.style.width = `${natural}px` if (!content.hasAttribute("data-measured")) { requestAnimationFrame(() => content.setAttribute("data-measured", "")) } }, []) React.useLayoutEffect(() => { statusRef.current = status if (active) { applyWidth() } }, [active, applyWidth, status]) React.useLayoutEffect(() => { if (!active || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(applyWidth) observerRef.current = observer layersRef.current.forEach((layer) => observer.observe(layer)) document.fonts?.addEventListener?.("loadingdone", applyWidth) return () => { observer.disconnect() observerRef.current = null document.fonts?.removeEventListener?.("loadingdone", applyWidth) } }, [active, applyWidth]) const layerRef = React.useCallback((node: HTMLElement | null) => { if (!node) { return } const layer = node.dataset.layer as ButtonStatus layersRef.current.set(layer, node) observerRef.current?.observe(node) return () => { observerRef.current?.unobserve(node) if (layersRef.current.get(layer) === node) { layersRef.current.delete(layer) } } }, []) return { contentRef, layerRef } } function composeHandlers<Event>( first: ((event: Event) => void) | undefined, second: (event: Event) => void ) { return (event: Event) => { first?.(event) second(event) } } function Button({ className, variant = "default", size = "default", children, onClick, disabled, focusableWhenDisabled, feedback, loading, status: statusProp, onStatusChange, onError, resetAfter, loadingLabel, successLabel = "Done", errorLabel = "Failed", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: ButtonProps) { const internal = useButtonFeedback({ resetAfter, onStatusChange, onError }) const status = statusProp ?? (loading ? "loading" : internal.status) const requested = Boolean(feedback) || loading !== undefined || statusProp !== undefined const [enabled, setEnabled] = React.useState(requested) if (requested && !enabled) { setEnabled(true) } const { leaving, transitionKey } = useStatusTransition(status) const iconOnly = size?.startsWith("icon") ?? false const widthStatus = status === "loading" && loadingLabel === undefined ? "idle" : status const { contentRef, layerRef } = useLayerWidth( widthStatus, enabled && !iconOnly ) const isLoading = status === "loading" const quiet = quietVariants.includes(variant ?? "default") const resolvedVariant = !quiet && status === "success" ? "success" : !quiet && status === "error" ? "error" : variant const resolvedErrorLabel = typeof errorLabel !== "function" ? errorLabel : internal.error !== undefined ? errorLabel(internal.error) : "Failed" const handleClick = (event: ButtonClickEvent) => { if (feedback && internal.isPending()) { return } const result = onClick?.(event) if (feedback && isThenable(result)) { internal.track(result) } } const layerContent = (layer: ButtonStatus, animated: boolean) => { if (layer === "idle") { return children } if (layer === "loading") { return ( <> <Spinner aria-hidden size={null} animated={animated} /> {iconOnly ? null : loadingLabel} </> ) } if (layer === "success") { return ( <> <IconCircleCheck className={cn( animated && "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]" )} /> {iconOnly ? null : successLabel} </> ) } return ( <> <IconAlertCircle /> {iconOnly ? null : resolvedErrorLabel} </> ) } const button = ( <ButtonPrimitive data-slot="button" data-status={enabled ? status : undefined} aria-busy={isLoading || undefined} disabled={disabled || isLoading} focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)} className={cn( buttonVariants({ variant: resolvedVariant, size }), quiet && quietStatusClasses[status], className )} onClick={handleClick} onPointerEnter={composeHandlers( onPointerEnter, internal.buttonProps.onPointerEnter )} onPointerLeave={composeHandlers( onPointerLeave, internal.buttonProps.onPointerLeave )} onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)} onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)} {...props} > {enabled ? ( <span ref={contentRef} data-slot="button-content" className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]" > {statuses.map((layer) => { const active = layer === status const exiting = layer === leaving const namesButton = layer === "idle" && isLoading && loadingLabel === undefined return ( <span key={active ? `${layer}-${transitionKey}` : layer} ref={layerRef} data-slot="button-layer" data-layer={layer} aria-hidden={active || namesButton ? undefined : true} className={cn( "inline-flex items-center justify-center gap-[inherit] backface-hidden", !active && !exiting && "pointer-events-none absolute opacity-0", active && transitionKey > 0 && "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0", exiting && "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out" )} > {layerContent(layer, active || exiting)} </span> ) })} </span> ) : ( children )} </ButtonPrimitive> ) if (!enabled) { return button } const announcement = status === "loading" ? typeof loadingLabel === "string" ? loadingLabel : "Loading" : status === "success" ? typeof successLabel === "string" ? successLabel : "Done" : status === "error" ? typeof resolvedErrorLabel === "string" ? resolvedErrorLabel : "Failed" : "" return ( <> {button} <span data-slot="button-status" role="status" className="sr-only"> {announcement} </span> </> ) } export { Button, buttonVariants, useButtonFeedback } export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }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 = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
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>Give every item a stable messageId. The scroller uses it to follow new messages, keep your place and count what you haven't seen. Pair it with <Message /> and <Bubble /> for the rows.
MessageScrollerProvider
└── MessageScroller
├── MessageScrollerViewport
│ └── MessageScrollerContent
│ └── MessageScrollerItem
└── MessageScrollerButtonGroup chat#
Anchors don't have to be messages. Here a <Marker /> for someone joining starts the turn. Scroll up, receive messages, and the jump button counts what arrived while you were away.
"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>
)
}Keeping context#
scrollPreviousItemPeek keeps a slice of the previous turn above a newly anchored one, so the thread still feels continuous. Try each amount, then ask the next question.
"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>
)
}Opening saved threads#
defaultScrollPosition="last-anchor" reopens a conversation at its last question, with the answer below, instead of dropping the reader mid-answer at the bottom.
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>
)
}Loading earlier messages#
Older messages added above keep what you're reading in place (preserveScrollOnPrepend, on by default).
"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>
)
}Jumping to messages#
useMessageScroller drives the thread from outside it, and useMessageScrollerVisibility reports the current turn, so the outline highlights where you are.
"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>
)
}Inside the provider, these hooks let your own controls scroll the thread and react to what's on screen.
const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScroller()
const { start, end } = useMessageScrollerScrollable()
const { visibleMessageIds, currentAnchorId } = useMessageScrollerVisibility()| Key | Action |
|---|---|
| Tab | Focuses the conversation. The jump button joins the tab order only while it's showing. |
| ↑↓ | Scrolls the conversation. |
| Page UpPage Down | Scrolls by a screen. |
| HomeEnd | Jumps to the first or latest message. |
- The viewport is a labelled, focusable region and the content is a
role="log", so screen readers announce new messages as they arrive. Give the viewport anaria-label. - The jump button's label says how many messages are new, so "3 new messages" is read out, not just an arrow.
- Following new messages never moves you while you're reading back. It only resumes once you're at the bottom again.
| Prop | Type | Default |
|---|---|---|
autoScrollFollow new messages while the reader is at the bottom. | boolean | false |
defaultScrollPositionWhere the thread opens. | "start" | "end" | "last-anchor" | "end" |
scrollPreviousItemPeekHow much of the previous item stays visible above a new anchor. | number | 64 |
scrollEdgeThresholdPixels from an edge that still count as at it. | number | – |
scrollMarginSpace kept above messages scrolled into view. | number | – |
| Prop | Type | Default |
|---|---|---|
preserveScrollOnPrependKeep the reading position when messages are added above. | boolean | false |
aria-labelNames the conversation region. | string | – |
| Attribute | Description |
|---|---|
data-scrollable="start end" | Which edges have more to scroll. Drives the edge fades. |
data-autoscrolling | Present while following new messages. The scrollbar hides. |
--scroller-fade-start / --scroller-fade-end | Size of the edge fades. They animate in and out. |
| Prop | Type | Default |
|---|---|---|
messageIdStable id for following, anchors and counts. | string | – |
scrollAnchorScroll this item to the top when it arrives, with defaultScrollPosition="last-anchor". | boolean | false |
| Prop | Type | Default |
|---|---|---|
direction | "start" | "end" | "end" |
showUnseenGrow into a pill that counts messages that arrived while you were scrolled up. | boolean | true |
unseenLabel | (count: number) => ReactNode | "3 new messages" |
variant | Button variant | "outline" |
behavior | ScrollBehavior | "smooth" |
| Attribute | Description |
|---|---|
data-slot="message-scroller-button" | Target the button in CSS. |
data-active | "true" while there's somewhere to jump to. |
data-unseen | The unseen count, while it's above zero. |