Message scroller
The scrolling pane of a conversation: it opens at the latest message and keeps it in view as more arrive.
Vibra's jump-to-latest button slides in and out on the motion tokens rather than a fixed 200ms and 400ms, and scrolls smoothly only while motion is allowed — asked for less, by the OS setting or a [data-motion="reduced"] above, it takes the reader there at once. Underneath is @shadcn/react's scroller: it opens at the newest message and, with autoScroll, keeps following new ones while the reader stays at the end. Give the root a height; Bubble and Message draw what goes inside it.
Install
$
npx shadcn@latest add @vibra/message-scrollerNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Message, MessageContent } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
const THREAD = [
{ id: "m1", fromMe: false, text: "The payments sync stopped at 09:40 UTC." },
{ id: "m2", fromMe: true, text: "Seeing it too — Revenue has no payments since then." },
{ id: "m3", fromMe: false, text: "The provider rotated its webhook secret overnight." },
{ id: "m4", fromMe: true, text: "I'll update it in Settings › Integrations." },
{ id: "m5", fromMe: false, text: "Thanks. Can you replay the missed events after?" },
{ id: "m6", fromMe: true, text: "Replaying 312 events from 09:40 to now." },
{ id: "m7", fromMe: false, text: "Revenue is catching up — $4,210 back so far." },
{ id: "m8", fromMe: true, text: "All 312 replayed. The sync is green again." },
]
export default function MessageScrollerDemo() {
return (
// Opens at the newest message; scroll up and the button brings you back.
<MessageScrollerProvider defaultScrollPosition="end">
<MessageScroller className="h-64 w-full max-w-md rounded-xl ring-1 ring-border">
<MessageScrollerViewport aria-label="Messages with Hiro Tanaka" className="px-3">
<MessageScrollerContent aria-label="Conversation with Hiro Tanaka" className="gap-2 py-3">
{THREAD.map((message) => (
<MessageScrollerItem key={message.id} messageId={message.id}>
<Message align={message.fromMe ? "end" : "start"}>
<MessageContent>
<Bubble variant={message.fromMe ? "default" : "muted"} align={message.fromMe ? "end" : "start"}>
<BubbleContent>{message.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| MessageScrollerProvider.defaultScrollPosition | "start" | "end" | "last-anchor" | "end" | Where the pane opens — at the newest message, the oldest, or the last scroll anchor. |
| MessageScrollerProvider.autoScroll | boolean | false | Follows new messages while the reader is at the end, and lets go once they scroll up. |
| MessageScrollerItem.messageId | string | — | Identifies the message, for scrollToMessage and the visibility hooks. |
| MessageScrollerButton.direction | "start" | "end" | "end" | Which end the button scrolls to; it shows only while that end is out of view. |
| MessageScrollerButton.behavior | ScrollBehavior | "smooth", or "auto" under reduced motion | How the button scrolls; a value passed here is used whatever the reader asked for. |
| useMessageScroller | () => { scrollToEnd; scrollToStart; scrollToMessage } | — | Scrolls the pane from code — after sending, say. |
Dependencies
Source
"use client"
import * as React from "react"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
import { cn } from "@/lib/utils"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { ArrowDownIcon } from "lucide-react"
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",
behavior,
className,
children,
render,
variant = "secondary",
size = "icon-sm",
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
const reducedMotion = useReducedMotion()
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
// A smooth scroll is motion the reader set off; asked for less, they are
// taken there at once. A behaviour the caller chose is kept.
behavior={behavior ?? (reducedMotion ? "auto" : "smooth")}
className={cn(
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-(--duration-base) 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-(--duration-slow) data-[active=false]:ease-(--ease-exit) data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-(--ease-emphasized) 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,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}