Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new
DocumentationMessageScroller

MessageScroller

A chat container with initial positioning, streaming and history loading. Set a bounded height. The default position is end; start opens at the first message, and last-anchor opens at the final user turn when that turn is taller than the viewport. End and last-anchor stay hidden until initial positioning completes, including server rendering and hydration. An empty transcript becomes visible after mounting. Auto-scroll follows growing replies until the reader scrolls away. Prepending history keeps the visible message in place.

Interactive preview

Rendered from the shared Frontfriend stories with React Base, the same preview Studio shows. Styling comes from your Design System; the code below is native to Base UI.

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 base

Review 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.