@cf/message-scroller · beta
Message Scroller
The scrolling container for a chat thread, which follows new messages, anchors a turn to the top and offers a jump-to-end button.
Add a turn and the thread follows it. Scroll up and the jump-to-end button slides in.
Styling over @shadcn/react/message-scroller, which does the scroll work. Items use content-visibility: auto with a 10rem intrinsic size, so long threads stay cheap to render, and the viewport hides itself while a scroll is pending so there's no jump on load.
MessageScrollerButton slides in from the edge when you're away from the end, and flips its arrow for direction="start". The chat panel passes autoScroll and a scrollMargin for the header to the provider. I've marked it beta because the primitive underneath is new and I'm the only user.
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller";
<MessageScrollerProvider autoScroll>
<MessageScroller className="flex-1">
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((m) => (
<MessageScrollerItem key={m.id} messageId={m.id} scrollAnchor={m.role === "user"}>
<ChatRow message={m} />
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>Props
| Prop | Type | Default |
|---|---|---|
| MessageScrollerButton variant | Button variant | secondary |
| MessageScrollerButton size | Button size | icon-sm |
Rules
The same rules ship in DESIGN.md and the MCP server, for the agents building with this.
- must
Nest
MessageScrollerProvider>MessageScroller>MessageScrollerViewport>MessageScrollerContent>MessageScrollerItem, withMessageScrollerButtoninsideMessageScrollerbut outside the viewport.full-structure
- must
Give every
MessageScrollerItema stablemessageId, including placeholder rows like a pending reply.item-per-message
- should
Set
scrollAnchoronly on the turn that should pin to the top when it arrives (the person's own message), and leave itfalseon everything else.anchor-sparingly
- must
Give
MessageScrollera parent with a bounded height (for exampleflex-1in a flex column), because it fills its container withsize-full min-h-0.bounded-height
Install
bunx shadcn@latest add @cf/message-scrollerNeeds the @cf registry in your components.json once. The theme and any sibling components come along automatically.
Source
message-scroller.tsxShowHide
"use client";
import { ArrowDownIcon } from "@phosphor-icons/react";
import { MessageScroller as MessageScrollerPrimitive } from "@shadcn/react/message-scroller";
import { cn } from "cn";
import type * as React from "react";
import { Button } from "./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
className={cn(
"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className
)}
data-slot="message-scroller"
{...props}
/>
);
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
className={cn(
"scroll-fade-b scrollbar-thin scrollbar-gutter-stable data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain contain-content data-pending-scroll:invisible",
className
)}
data-slot="message-scroller-viewport"
{...props}
/>
);
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
className={cn("flex h-max min-h-full flex-col gap-6", className)}
data-slot="message-scroller-content"
{...props}
/>
);
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
className={cn(
"min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className
)}
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
{...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
className={cn(
"absolute inset-s-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-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:data-[active=false]:-translate-y-full data-[active=false]:pointer-events-none data-[direction=start]:top-4 data-[direction=end]:bottom-4 data-[active=true]:translate-y-0 data-[active=false]:scale-95 data-[active=true]:scale-100 data-[active=false]:opacity-0 data-[active=true]:opacity-100 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className
)}
data-direction={direction}
data-size={size}
data-slot="message-scroller-button"
data-variant={variant}
direction={direction}
render={render ?? <Button size={size} variant={variant} />}
{...props}
>
{children ?? (
<>
<ArrowDownIcon />
<span className="sr-only">
{direction === "end" ? "Scroll to end" : "Scroll to start"}
</span>
</>
)}
</MessageScrollerPrimitive.Button>
);
}
export {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
};