Message Scroller
Chat transcript scroller from @shadcn/react with provider, viewport, content and item parts. Keeps the view pinned while messages stream in and fades the bottom edge, for AI chat interfaces.
pnpm dlx shadcn add @01orbital/message-scroller"use client"
import * as React from "react"
import { ArrowDownIcon } from "@phosphor-icons/react"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
import { cn } from "cn"
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 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-8", 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 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-[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,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}
"use client"
import * as React from "react"
import { ArrowClockwiseIcon } from "@phosphor-icons/react/dist/ssr"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Marker, MarkerContent } from "@/components/ui/marker"
import {
Message,
MessageContent,
MessageHeader,
} from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
// The copy: change text here, not in the markup.
const COPY = {
title: "Group Chat",
description:
"A group chat with several participants and an assistant. The Marker is marked as a turn.",
resetLabel: "Reset conversation",
resetTooltip: "Reset",
footnote:
"When a user joins, a marker is created. scrollAnchor on the marker marks it as the next turn",
addRocky: "Add Rocky",
sendAsRocky: "Send Message as Rocky",
anchorHint: "This will create a marker and make it the anchor",
replyHint: "Now send Rocky's reply into the conversation",
} as const
const CURRENT_USER = "Grace"
const MESSAGES = [
{
id: "group-1",
type: "message",
sender: "Grace",
role: "participant",
text: "@mary, the astrophage line keeps matching Venus energy output. Can you check my math?",
},
{
id: "group-2",
type: "message",
sender: "Mary (Agent)",
role: "assistant",
text: "Yes. Confirmed. The curve points to a microorganism harvesting stellar energy and breeding near carbon dioxide. If @rocky agrees, this is the clue we need.",
},
{
id: "group-3",
type: "message",
sender: "Grace",
role: "participant",
text: "ping @rocky",
scrollAnchor: true,
},
] satisfies GroupChatItem[]
const ROCKY_JOINED = {
id: "group-4",
type: "event",
text: "Rocky has joined the chat",
scrollAnchor: true,
} satisfies GroupChatItem
const ROCKY_REPLY = {
id: "group-5",
type: "message",
sender: "Rocky",
role: "participant",
text: "Amaze. Astrophage eats light, makes heat, goes to carbon dioxide. Rocky has fuel model. Grace is smart.",
} satisfies GroupChatItem
type GroupChatItem =
| {
id: string
type: "event"
text: string
scrollAnchor?: boolean
}
| {
id: string
type: "message"
sender: string
role: "assistant" | "participant"
text: string
scrollAnchor?: boolean
}
/** Message Scroller, after shadcn's example. */
export default function MessageScrollerGroupChat() {
const [demoKey, setDemoKey] = React.useState(0)
const [rockyTurn, setRockyTurn] = React.useState<
"idle" | "marker" | "message"
>("idle")
const items =
rockyTurn === "message"
? [...MESSAGES, ROCKY_JOINED, ROCKY_REPLY]
: rockyTurn === "marker"
? [...MESSAGES, ROCKY_JOINED]
: MESSAGES
const buttonLabel = rockyTurn === "idle" ? COPY.addRocky : COPY.sendAsRocky
const isComplete = rockyTurn === "message"
return (
<MessageScrollerProvider>
<div className="relative flex flex-col gap-4">
<Card className="mx-auto h-140 w-full max-w-sm gap-0">
<CardHeader className="gap-1 border-b">
<CardTitle>{COPY.title}</CardTitle>
<CardDescription>{COPY.description}</CardDescription>
<CardAction>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
aria-label={COPY.resetLabel}
disabled={rockyTurn === "idle"}
onClick={() => {
setRockyTurn("idle")
setDemoKey((key) => key + 1)
}}
/>
}
>
<ArrowClockwiseIcon />
</TooltipTrigger>
<TooltipContent>
<p>{COPY.resetTooltip}</p>
</TooltipContent>
</Tooltip>
</CardAction>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<MessageScrollerProvider>
<MessageScroller key={demoKey}>
<MessageScrollerViewport>
<MessageScrollerContent className="p-(--card-spacing)">
{items.map((item) =>
item.type === "message" ? (
<GroupChatMessage key={item.id} item={item} />
) : (
<GroupChatMarker
key={item.id}
item={item}
scrollAnchor={item.scrollAnchor}
/>
)
)}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</CardContent>
<CardFooter className="flex flex-col items-center gap-2 border-t">
<Button
type="button"
disabled={isComplete}
onClick={() =>
setRockyTurn((turn) => (turn === "idle" ? "marker" : "message"))
}
className="w-full"
variant="secondary"
>
{buttonLabel}
</Button>
<p className="text-xs text-muted-foreground">
{rockyTurn === "idle" ? COPY.anchorHint : COPY.replyHint}
</p>
</CardFooter>
</Card>
<div className="mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-muted-foreground">
{COPY.footnote}
</div>
</div>
</MessageScrollerProvider>
)
}
function GroupChatMessage({
item,
}: {
item: Extract<GroupChatItem, { type: "message" }>
}) {
const isCurrentUser = item.sender === CURRENT_USER
const variant = isCurrentUser
? "muted"
: item.role === "assistant"
? "ghost"
: "tinted"
return (
<MessageScrollerItem messageId={item.id} scrollAnchor={item.scrollAnchor}>
<Message align={isCurrentUser ? "end" : "start"}>
<MessageContent>
{!isCurrentUser && <MessageHeader>{item.sender}</MessageHeader>}
<Bubble variant={variant}>
<BubbleContent>{item.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
)
}
function GroupChatMarker({
item,
scrollAnchor = false,
}: {
item: Extract<GroupChatItem, { type: "event" }>
scrollAnchor?: boolean
}) {
return (
<MessageScrollerItem scrollAnchor={scrollAnchor}>
<Marker variant="separator">
<MarkerContent>{item.text}</MarkerContent>
</Marker>
</MessageScrollerItem>
)
}
Dependencies
npm: @phosphor-icons/react, @shadcn/react, cn. The CLI installs them for you.
Registry pieces it installs with it: