Installation
Complete project setup first. Your configuration
must select react/base and include your Design System ID.
pnpm exec frontfriend download message-scroller -f react --primitive baseReview dependencies reported by the downloader and keep your source changes in version control.
Usage
import { Button } from "@/components/ui/button";
import {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
} from "@/components/ui/message-scroller";
import {
Message,
MessageAvatar,
MessageContent,
} from "@/components/ui/message";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import * as React from "react";
type PreviewProps = {
defaultScrollPosition?: "start" | "end" | "last-anchor";
autoScroll?: boolean;
height?: number;
streaming?: boolean;
history?: boolean;
};
type MessageData = {
id: string;
user: boolean;
text: string;
};
const reply =
"Use the component source for your selected target. The design system supplies the styles. Keep message IDs stable when you load history or update a reply. ";
const initial: MessageData[] = Array.from({ length: 12 }, (_, index) => ({
id: `message-${index}`,
user: index % 2 === 0,
text:
index % 2 === 0
? `Question ${index / 2 + 1}: How do I build this conversation?`
: reply.repeat(index === 11 ? 10 : 2),
}));
function Transcript({ messages }: { messages: MessageData[] }) {
return (
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.user}
>
<div className="px-4 py-2">
<Message align={message.user ? "end" : undefined}>
<MessageAvatar>
<Avatar>
<AvatarFallback>{message.user ? "ME" : "AI"}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<Bubble variant={message.user ? undefined : "muted"}>
<BubbleContent>{message.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</div>
</MessageScrollerItem>
))}
</MessageScrollerContent>
);
}
function ScrollerPreview({
defaultScrollPosition = "end",
autoScroll = false,
height = 420,
streaming = false,
history = false,
}: PreviewProps) {
const [messages, setMessages] = React.useState(initial);
const [updates, setUpdates] = React.useState(0);
function append() {
setMessages((current) =>
current.map((message, index) =>
index === current.length - 1
? { ...message, text: message.text + reply }
: message,
),
);
setUpdates((count) => count + 1);
}
function prepend() {
setMessages((current) => [
{
id: `history-${current.length}`,
user: false,
text: "Earlier message. " + reply.repeat(2),
},
...current,
]);
setUpdates((count) => count + 1);
}
return (
<div className="flex w-[420px] max-w-[calc(100vw-2rem)] flex-col gap-3 text-neutral-strong">
{(streaming || history) && (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={streaming ? append : prepend}
>
{streaming ? "Append response" : "Load earlier messages"}
</Button>
<span role="status" className="text-sm text-neutral-mid">
Updates: {updates}
</span>
</div>
)}
<div
className="w-full overflow-hidden rounded-xl border border-neutral-subtle"
style={{ height }}
>
<MessageScrollerProvider
defaultScrollPosition={defaultScrollPosition}
autoScroll={autoScroll}
>
<MessageScroller>
<MessageScrollerViewport>
<Transcript messages={messages} />
</MessageScrollerViewport>
<MessageScrollerButton direction="end" />
<MessageScrollerButton direction="start" />
</MessageScroller>
</MessageScrollerProvider>
</div>
</div>
);
}
<ScrollerPreview autoScroll defaultScrollPosition="end" height={300} />; The public useMessageScroller, useMessageScrollerScrollable and useMessageScrollerVisibility hooks provide scroll commands, scroll-edge state and visible message IDs. Call them within the provider. Vue exposes the same three composables, with reactive refs for state and visibility.
Examples
Default
import { Button } from "@/components/ui/button";
import {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
} from "@/components/ui/message-scroller";
import {
Message,
MessageAvatar,
MessageContent,
} from "@/components/ui/message";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import * as React from "react";
type PreviewProps = {
defaultScrollPosition?: "start" | "end" | "last-anchor";
autoScroll?: boolean;
height?: number;
streaming?: boolean;
history?: boolean;
};
type MessageData = {
id: string;
user: boolean;
text: string;
};
const reply =
"Use the component source for your selected target. The design system supplies the styles. Keep message IDs stable when you load history or update a reply. ";
const initial: MessageData[] = Array.from({ length: 12 }, (_, index) => ({
id: `message-${index}`,
user: index % 2 === 0,
text:
index % 2 === 0
? `Question ${index / 2 + 1}: How do I build this conversation?`
: reply.repeat(index === 11 ? 10 : 2),
}));
function Transcript({ messages }: { messages: MessageData[] }) {
return (
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.user}
>
<div className="px-4 py-2">
<Message align={message.user ? "end" : undefined}>
<MessageAvatar>
<Avatar>
<AvatarFallback>{message.user ? "ME" : "AI"}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<Bubble variant={message.user ? undefined : "muted"}>
<BubbleContent>{message.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</div>
</MessageScrollerItem>
))}
</MessageScrollerContent>
);
}
function ScrollerPreview({
defaultScrollPosition = "end",
autoScroll = false,
height = 420,
streaming = false,
history = false,
}: PreviewProps) {
const [messages, setMessages] = React.useState(initial);
const [updates, setUpdates] = React.useState(0);
function append() {
setMessages((current) =>
current.map((message, index) =>
index === current.length - 1
? { ...message, text: message.text + reply }
: message,
),
);
setUpdates((count) => count + 1);
}
function prepend() {
setMessages((current) => [
{
id: `history-${current.length}`,
user: false,
text: "Earlier message. " + reply.repeat(2),
},
...current,
]);
setUpdates((count) => count + 1);
}
return (
<div className="flex w-[420px] max-w-[calc(100vw-2rem)] flex-col gap-3 text-neutral-strong">
{(streaming || history) && (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={streaming ? append : prepend}
>
{streaming ? "Append response" : "Load earlier messages"}
</Button>
<span role="status" className="text-sm text-neutral-mid">
Updates: {updates}
</span>
</div>
)}
<div
className="w-full overflow-hidden rounded-xl border border-neutral-subtle"
style={{ height }}
>
<MessageScrollerProvider
defaultScrollPosition={defaultScrollPosition}
autoScroll={autoScroll}
>
<MessageScroller>
<MessageScrollerViewport>
<Transcript messages={messages} />
</MessageScrollerViewport>
<MessageScrollerButton direction="end" />
<MessageScrollerButton direction="start" />
</MessageScroller>
</MessageScrollerProvider>
</div>
</div>
);
}
<ScrollerPreview />; Initial Start
import { Button } from "@/components/ui/button";
import {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
} from "@/components/ui/message-scroller";
import {
Message,
MessageAvatar,
MessageContent,
} from "@/components/ui/message";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import * as React from "react";
type PreviewProps = {
defaultScrollPosition?: "start" | "end" | "last-anchor";
autoScroll?: boolean;
height?: number;
streaming?: boolean;
history?: boolean;
};
type MessageData = {
id: string;
user: boolean;
text: string;
};
const reply =
"Use the component source for your selected target. The design system supplies the styles. Keep message IDs stable when you load history or update a reply. ";
const initial: MessageData[] = Array.from({ length: 12 }, (_, index) => ({
id: `message-${index}`,
user: index % 2 === 0,
text:
index % 2 === 0
? `Question ${index / 2 + 1}: How do I build this conversation?`
: reply.repeat(index === 11 ? 10 : 2),
}));
function Transcript({ messages }: { messages: MessageData[] }) {
return (
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.user}
>
<div className="px-4 py-2">
<Message align={message.user ? "end" : undefined}>
<MessageAvatar>
<Avatar>
<AvatarFallback>{message.user ? "ME" : "AI"}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<Bubble variant={message.user ? undefined : "muted"}>
<BubbleContent>{message.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</div>
</MessageScrollerItem>
))}
</MessageScrollerContent>
);
}
function ScrollerPreview({
defaultScrollPosition = "end",
autoScroll = false,
height = 420,
streaming = false,
history = false,
}: PreviewProps) {
const [messages, setMessages] = React.useState(initial);
const [updates, setUpdates] = React.useState(0);
function append() {
setMessages((current) =>
current.map((message, index) =>
index === current.length - 1
? { ...message, text: message.text + reply }
: message,
),
);
setUpdates((count) => count + 1);
}
function prepend() {
setMessages((current) => [
{
id: `history-${current.length}`,
user: false,
text: "Earlier message. " + reply.repeat(2),
},
...current,
]);
setUpdates((count) => count + 1);
}
return (
<div className="flex w-[420px] max-w-[calc(100vw-2rem)] flex-col gap-3 text-neutral-strong">
{(streaming || history) && (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={streaming ? append : prepend}
>
{streaming ? "Append response" : "Load earlier messages"}
</Button>
<span role="status" className="text-sm text-neutral-mid">
Updates: {updates}
</span>
</div>
)}
<div
className="w-full overflow-hidden rounded-xl border border-neutral-subtle"
style={{ height }}
>
<MessageScrollerProvider
defaultScrollPosition={defaultScrollPosition}
autoScroll={autoScroll}
>
<MessageScroller>
<MessageScrollerViewport>
<Transcript messages={messages} />
</MessageScrollerViewport>
<MessageScrollerButton direction="end" />
<MessageScrollerButton direction="start" />
</MessageScroller>
</MessageScrollerProvider>
</div>
</div>
);
}
<ScrollerPreview defaultScrollPosition="start" />; Last Anchor
import { Button } from "@/components/ui/button";
import {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
} from "@/components/ui/message-scroller";
import {
Message,
MessageAvatar,
MessageContent,
} from "@/components/ui/message";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import * as React from "react";
type PreviewProps = {
defaultScrollPosition?: "start" | "end" | "last-anchor";
autoScroll?: boolean;
height?: number;
streaming?: boolean;
history?: boolean;
};
type MessageData = {
id: string;
user: boolean;
text: string;
};
const reply =
"Use the component source for your selected target. The design system supplies the styles. Keep message IDs stable when you load history or update a reply. ";
const initial: MessageData[] = Array.from({ length: 12 }, (_, index) => ({
id: `message-${index}`,
user: index % 2 === 0,
text:
index % 2 === 0
? `Question ${index / 2 + 1}: How do I build this conversation?`
: reply.repeat(index === 11 ? 10 : 2),
}));
function Transcript({ messages }: { messages: MessageData[] }) {
return (
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.user}
>
<div className="px-4 py-2">
<Message align={message.user ? "end" : undefined}>
<MessageAvatar>
<Avatar>
<AvatarFallback>{message.user ? "ME" : "AI"}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<Bubble variant={message.user ? undefined : "muted"}>
<BubbleContent>{message.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</div>
</MessageScrollerItem>
))}
</MessageScrollerContent>
);
}
function ScrollerPreview({
defaultScrollPosition = "end",
autoScroll = false,
height = 420,
streaming = false,
history = false,
}: PreviewProps) {
const [messages, setMessages] = React.useState(initial);
const [updates, setUpdates] = React.useState(0);
function append() {
setMessages((current) =>
current.map((message, index) =>
index === current.length - 1
? { ...message, text: message.text + reply }
: message,
),
);
setUpdates((count) => count + 1);
}
function prepend() {
setMessages((current) => [
{
id: `history-${current.length}`,
user: false,
text: "Earlier message. " + reply.repeat(2),
},
...current,
]);
setUpdates((count) => count + 1);
}
return (
<div className="flex w-[420px] max-w-[calc(100vw-2rem)] flex-col gap-3 text-neutral-strong">
{(streaming || history) && (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={streaming ? append : prepend}
>
{streaming ? "Append response" : "Load earlier messages"}
</Button>
<span role="status" className="text-sm text-neutral-mid">
Updates: {updates}
</span>
</div>
)}
<div
className="w-full overflow-hidden rounded-xl border border-neutral-subtle"
style={{ height }}
>
<MessageScrollerProvider
defaultScrollPosition={defaultScrollPosition}
autoScroll={autoScroll}
>
<MessageScroller>
<MessageScrollerViewport>
<Transcript messages={messages} />
</MessageScrollerViewport>
<MessageScrollerButton direction="end" />
<MessageScrollerButton direction="start" />
</MessageScroller>
</MessageScrollerProvider>
</div>
</div>
);
}
<ScrollerPreview defaultScrollPosition="last-anchor" />; Streaming
import { Button } from "@/components/ui/button";
import {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
} from "@/components/ui/message-scroller";
import {
Message,
MessageAvatar,
MessageContent,
} from "@/components/ui/message";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import * as React from "react";
type PreviewProps = {
defaultScrollPosition?: "start" | "end" | "last-anchor";
autoScroll?: boolean;
height?: number;
streaming?: boolean;
history?: boolean;
};
type MessageData = {
id: string;
user: boolean;
text: string;
};
const reply =
"Use the component source for your selected target. The design system supplies the styles. Keep message IDs stable when you load history or update a reply. ";
const initial: MessageData[] = Array.from({ length: 12 }, (_, index) => ({
id: `message-${index}`,
user: index % 2 === 0,
text:
index % 2 === 0
? `Question ${index / 2 + 1}: How do I build this conversation?`
: reply.repeat(index === 11 ? 10 : 2),
}));
function Transcript({ messages }: { messages: MessageData[] }) {
return (
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.user}
>
<div className="px-4 py-2">
<Message align={message.user ? "end" : undefined}>
<MessageAvatar>
<Avatar>
<AvatarFallback>{message.user ? "ME" : "AI"}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<Bubble variant={message.user ? undefined : "muted"}>
<BubbleContent>{message.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</div>
</MessageScrollerItem>
))}
</MessageScrollerContent>
);
}
function ScrollerPreview({
defaultScrollPosition = "end",
autoScroll = false,
height = 420,
streaming = false,
history = false,
}: PreviewProps) {
const [messages, setMessages] = React.useState(initial);
const [updates, setUpdates] = React.useState(0);
function append() {
setMessages((current) =>
current.map((message, index) =>
index === current.length - 1
? { ...message, text: message.text + reply }
: message,
),
);
setUpdates((count) => count + 1);
}
function prepend() {
setMessages((current) => [
{
id: `history-${current.length}`,
user: false,
text: "Earlier message. " + reply.repeat(2),
},
...current,
]);
setUpdates((count) => count + 1);
}
return (
<div className="flex w-[420px] max-w-[calc(100vw-2rem)] flex-col gap-3 text-neutral-strong">
{(streaming || history) && (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={streaming ? append : prepend}
>
{streaming ? "Append response" : "Load earlier messages"}
</Button>
<span role="status" className="text-sm text-neutral-mid">
Updates: {updates}
</span>
</div>
)}
<div
className="w-full overflow-hidden rounded-xl border border-neutral-subtle"
style={{ height }}
>
<MessageScrollerProvider
defaultScrollPosition={defaultScrollPosition}
autoScroll={autoScroll}
>
<MessageScroller>
<MessageScrollerViewport>
<Transcript messages={messages} />
</MessageScrollerViewport>
<MessageScrollerButton direction="end" />
<MessageScrollerButton direction="start" />
</MessageScroller>
</MessageScrollerProvider>
</div>
</div>
);
}
<ScrollerPreview autoScroll streaming />; Prepended History
import { Button } from "@/components/ui/button";
import {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
} from "@/components/ui/message-scroller";
import {
Message,
MessageAvatar,
MessageContent,
} from "@/components/ui/message";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import * as React from "react";
type PreviewProps = {
defaultScrollPosition?: "start" | "end" | "last-anchor";
autoScroll?: boolean;
height?: number;
streaming?: boolean;
history?: boolean;
};
type MessageData = {
id: string;
user: boolean;
text: string;
};
const reply =
"Use the component source for your selected target. The design system supplies the styles. Keep message IDs stable when you load history or update a reply. ";
const initial: MessageData[] = Array.from({ length: 12 }, (_, index) => ({
id: `message-${index}`,
user: index % 2 === 0,
text:
index % 2 === 0
? `Question ${index / 2 + 1}: How do I build this conversation?`
: reply.repeat(index === 11 ? 10 : 2),
}));
function Transcript({ messages }: { messages: MessageData[] }) {
return (
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.user}
>
<div className="px-4 py-2">
<Message align={message.user ? "end" : undefined}>
<MessageAvatar>
<Avatar>
<AvatarFallback>{message.user ? "ME" : "AI"}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<Bubble variant={message.user ? undefined : "muted"}>
<BubbleContent>{message.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</div>
</MessageScrollerItem>
))}
</MessageScrollerContent>
);
}
function ScrollerPreview({
defaultScrollPosition = "end",
autoScroll = false,
height = 420,
streaming = false,
history = false,
}: PreviewProps) {
const [messages, setMessages] = React.useState(initial);
const [updates, setUpdates] = React.useState(0);
function append() {
setMessages((current) =>
current.map((message, index) =>
index === current.length - 1
? { ...message, text: message.text + reply }
: message,
),
);
setUpdates((count) => count + 1);
}
function prepend() {
setMessages((current) => [
{
id: `history-${current.length}`,
user: false,
text: "Earlier message. " + reply.repeat(2),
},
...current,
]);
setUpdates((count) => count + 1);
}
return (
<div className="flex w-[420px] max-w-[calc(100vw-2rem)] flex-col gap-3 text-neutral-strong">
{(streaming || history) && (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={streaming ? append : prepend}
>
{streaming ? "Append response" : "Load earlier messages"}
</Button>
<span role="status" className="text-sm text-neutral-mid">
Updates: {updates}
</span>
</div>
)}
<div
className="w-full overflow-hidden rounded-xl border border-neutral-subtle"
style={{ height }}
>
<MessageScrollerProvider
defaultScrollPosition={defaultScrollPosition}
autoScroll={autoScroll}
>
<MessageScroller>
<MessageScrollerViewport>
<Transcript messages={messages} />
</MessageScrollerViewport>
<MessageScrollerButton direction="end" />
<MessageScrollerButton direction="start" />
</MessageScroller>
</MessageScrollerProvider>
</div>
</div>
);
}
<ScrollerPreview defaultScrollPosition="start" history />; API reference
Frontfriend-owned props. Native HTML and inherited primitive props remain documented by Base UI.
This component adds no props of its own to Base UI.
Configuration determines the actual classes and available styling hooks. See Theming & component configuration before introducing a repeated local override.