Scroll container for chat messages that sticks to the bottom as new messages arrive.
Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/message-scroller.jsonInstalls to @/components/ui/message-scroller.
Manual installation
Install the dependencies:
pnpm add @shadcn/react@^0.3.1 lucide-react@^1.48.0Add the components it builds on: button.
Copy the source into your project:
'use client'
import { MessageScroller as MessageScrollerPrimitive } from '@shadcn/react/message-scroller'
import { ArrowDownIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
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 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible',
className,
)}
{...props}
/>
)
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn('flex h-max min-h-full flex-col gap-6', className)}
{...props}
/>
)
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn('min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]', className)}
{...props}
/>
)
}
function MessageScrollerButton({
direction = 'end',
className,
children,
render,
variant = 'secondary',
size = 'icon-sm',
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
className={cn(
'absolute left-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 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-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180',
className,
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? (
<>
<ArrowDownIcon />
<span className="sr-only">{direction === 'end' ? 'Scroll to end' : 'Scroll to start'}</span>
</>
)}
</MessageScrollerPrimitive.Button>
)
}
export {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
}
export {
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from '@shadcn/react/message-scroller'A scroll container for chat transcripts built on @shadcn/react. It anchors new turns, follows the live edge while messages stream in, preserves the scroll position when history is prepended and shows a scroll-to-end button. Wrap it in MessageScrollerProvider and give it a height-constrained parent.
Features
- Scroll anchoring on turn boundaries via scrollAnchor
- Auto-scroll that follows streamed messages
- Default opening position: start, end or last anchor
- Preserves position when older messages are prepended
- Scroll-to-end and scroll-to-start buttons
- Hooks: useMessageScroller, useMessageScrollerScrollable and useMessageScrollerVisibility
Usage
import { Message } from '@/components/ui/message'
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from '@/components/ui/message-scroller'
export function Transcript({ messages }: { messages: { id: string; role: string }[] }) {
return (
<div className="h-[480px]">
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={message.role === 'user'}>
<Message>{/* ... */}</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>
)
}Examples
Default
A conversation with a case officer in a fixed-height chat window. Scroll up to reveal the scroll-to-end button.
Open At Last Anchor
Opens at the last user turn and keeps part of the previous turn visible.
Live Conversation
Send a message and watch auto-scroll follow the officer's reply.
Jump To Message
Jump controls built with useMessageScroller and useMessageScrollerVisibility.
With Markers And Attachments
Date markers and a document attachment inside the transcript.
Accessibility
- Give the viewport an aria-label describing the conversation
- The viewport is keyboard scrollable and focusable
- The scroll button includes a screen reader label and is hidden when inactive
- New content is exposed to assistive technology through the content region