Blode UI
GitHub

Message Scroller

A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.

Loading
Loading...
"use client";
 
import * as React from "react";
 
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller";
 
interface ChatMessage {
  id: string;
  role: "user" | "assistant";
  text: string;
}
 
const initialMessages: ChatMessage[] = [
  { id: "m-1", role: "user", text: "What makes a good streaming chat feel calm?" },
  {
    id: "m-2",
    role: "assistant",
    text: "It only moves when you ask it to. If you're at the live edge it follows the reply, and the moment you scroll away it holds your place.",
  },
  { id: "m-3", role: "user", text: "Show me what streaming looks like." },
];
 
const reply =
  "Each new turn anchors near the top, then the answer streams in below it. New chunks arrive without yanking you around, so you can read at your own pace while the response keeps growing token by token.";
 
export function MessageScrollerDemo() {
  const [messages, setMessages] = React.useState(initialMessages);
  const [isStreaming, setIsStreaming] = React.useState(false);
 
  const startStream = () => {
    if (isStreaming) {
      return;
    }
    setIsStreaming(true);
    const id = `m-${Date.now()}`;
    setMessages((prev) => [...prev, { id, role: "assistant", text: "" }]);
 
    const tokens = reply.split(" ");
    let index = 0;
    const interval = setInterval(() => {
      index += 1;
      setMessages((prev) =>
        prev.map((message) =>
          message.id === id ? { ...message, text: tokens.slice(0, index).join(" ") } : message,
        ),
      );
      if (index >= tokens.length) {
        clearInterval(interval);
        setIsStreaming(false);
      }
    }, 90);
  };
 
  return (
    <div className="flex h-140 w-full max-w-md flex-col gap-3">
      <MessageScrollerProvider autoScroll>
        <MessageScroller className="flex-1 rounded-xl border bg-card">
          <MessageScrollerViewport>
            <MessageScrollerContent className="gap-4 p-4">
              {messages.map((message) => {
                const isUser = message.role === "user";
                return (
                  <MessageScrollerItem
                    key={message.id}
                    messageId={message.id}
                    scrollAnchor={isUser}
                  >
                    <Message align={isUser ? "end" : "start"}>
                      <MessageContent>
                        <Bubble variant={isUser ? "default" : "muted"}>
                          <BubbleContent>{message.text || "…"}</BubbleContent>
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                );
              })}
            </MessageScrollerContent>
          </MessageScrollerViewport>
          <MessageScrollerButton />
        </MessageScroller>
      </MessageScrollerProvider>
      <Button
        className="w-full"
        disabled={isStreaming}
        onClick={startStream}
        type="button"
        variant="secondary"
      >
        {isStreaming ? "Streaming…" : "Stream a reply"}
      </Button>
    </div>
  );
}

About

Streaming breaks the simple "append at the bottom and scroll" model. Messages arrive in chunks while you may still be reading, scrolling, or looking somewhere else. The challenge is preserving the reader's place while the conversation keeps changing. Get it wrong and the experience feels jumpy: people are pulled to the bottom, lose context, and have to find their way back.

MessageScroller is a chat transcript scroller built for these behaviors. MessageScrollerProvider owns the scroll state and transcript-row behavior: opening position, streamed output, new-turn anchoring, prepended history, visibility, and scroll controls. MessageScroller is the styled frame that renders inside it.

It is scoped to the scroll viewport. It does not own messages, AI state, transport, persistence, branching, or model state. Your product code stays focused on composing messages, markers, tools, attachments, and prompt inputs.

Installation

npx shadcn@latest add "https://ui.blode.co/r/styles/default/message-scroller"

Usage

import { Message } from "@/components/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller";
<MessageScrollerProvider autoScroll>
  <MessageScroller>
    <MessageScrollerViewport>
      <MessageScrollerContent>
        {messages.map((message) => (
          <MessageScrollerItem
            key={message.id}
            messageId={message.id}
            scrollAnchor={message.role === "user"}
          >
            <Message />
          </MessageScrollerItem>
        ))}
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton />
  </MessageScroller>
</MessageScrollerProvider>

MessageScroller fills its parent, so place it inside a height-constrained container.

<div className="flex h-screen flex-col">
  <MessageScrollerProvider>
    <MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
  </MessageScrollerProvider>
</div>

Composition

  • MessageScrollerProvider — the headless root. Owns scroll state and the behavior props for opening position, auto-scroll, anchoring, scroll commands, and visibility tracking.
  • MessageScroller — the styled frame. Lays out the viewport, content, and controls inside the provider.
  • MessageScrollerViewport — the scrollable element. Receives native scroll events and preserves the visible row when older messages are prepended.
  • MessageScrollerContent — the transcript container. Holds the rows and provides the live-region defaults for new messages.
  • MessageScrollerItem — the transcript row boundary. Wrap every direct child of the content so the scroller can measure, anchor, preserve position, track visibility, and jump to it.
  • MessageScrollerButton — the scroll control. Scrolls to the start or end of the transcript and is inert until there is content in its direction.

Examples

Group Chat

In a group chat, the turn boundary is often a marker like "Rocky joined the chat" rather than the user message. Because anchoring is role-independent, you can mark any row with scrollAnchor, including a marker.

Loading
Loading...
"use client";
 
import { ArrowRotateClockwiseIcon } from "blode-icons-react";
import * as React from "react";
 
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";
 
const currentUser = "Grace";
 
type GroupChatItem =
  | {
      id: string;
      type: "event";
      text: string;
      scrollAnchor?: boolean;
    }
  | {
      id: string;
      type: "message";
      sender: string;
      role: "assistant" | "participant";
      text: string;
      scrollAnchor?: boolean;
    };
 
const initialItems = [
  {
    id: "group-1",
    role: "participant",
    sender: "Grace",
    text: "@mary, the astrophage line keeps matching Venus energy output. Can you check my math?",
    type: "message",
  },
  {
    id: "group-2",
    role: "assistant",
    sender: "Mary (Agent)",
    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.",
    type: "message",
  },
  {
    id: "group-3",
    role: "participant",
    scrollAnchor: true,
    sender: "Grace",
    text: "ping @rocky",
    type: "message",
  },
] satisfies GroupChatItem[];
 
const rockyMarker = {
  id: "group-4",
  scrollAnchor: true,
  text: "Rocky has joined the chat",
  type: "event",
} satisfies GroupChatItem;
 
const rockyMessage = {
  id: "group-5",
  role: "participant",
  sender: "Rocky",
  text: "Amaze. Astrophage eats light, makes heat, goes to carbon dioxide. Rocky has fuel model. Grace is smart.",
  type: "message",
} satisfies GroupChatItem;
 
const GroupChatMessage = ({ item }: { item: Extract<GroupChatItem, { type: "message" }> }) => {
  const isCurrentUser = item.sender === currentUser;
  let variant: "muted" | "ghost" | "tinted" = "tinted";
  if (isCurrentUser) {
    variant = "muted";
  } else if (item.role === "assistant") {
    variant = "ghost";
  }
 
  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>
  );
};
 
const GroupChatMarker = ({
  item,
  scrollAnchor = false,
}: {
  item: Extract<GroupChatItem, { type: "event" }>;
  scrollAnchor?: boolean;
}) => (
  <MessageScrollerItem scrollAnchor={scrollAnchor}>
    <Marker variant="separator">
      <MarkerContent>{item.text}</MarkerContent>
    </Marker>
  </MessageScrollerItem>
);
 
export function MessageScrollerGroupChat() {
  const [demoKey, setDemoKey] = React.useState(0);
  const [rockyTurn, setRockyTurn] = React.useState<"idle" | "marker" | "message">("idle");
 
  let items: GroupChatItem[] = initialItems;
  if (rockyTurn === "message") {
    items = [...initialItems, rockyMarker, rockyMessage];
  } else if (rockyTurn === "marker") {
    items = [...initialItems, rockyMarker];
  }
 
  const buttonLabel = rockyTurn === "idle" ? "Add Rocky" : "Send Message as Rocky";
  const isComplete = rockyTurn === "message";
 
  return (
    <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>Group Chat</CardTitle>
          <CardDescription>
            A group chat with several participants and an assistant. The Marker is marked as a turn.
          </CardDescription>
          <CardAction>
            <Tooltip>
              <TooltipTrigger
                render={
                  <Button
                    aria-label="Reset conversation"
                    disabled={rockyTurn === "idle"}
                    onClick={() => {
                      setRockyTurn("idle");
                      setDemoKey((key) => key + 1);
                    }}
                    size="icon-sm"
                    type="button"
                    variant="outline"
                  />
                }
              >
                <ArrowRotateClockwiseIcon />
              </TooltipTrigger>
              <TooltipContent>
                <p>Reset</p>
              </TooltipContent>
            </Tooltip>
          </CardAction>
        </CardHeader>
        <CardContent className="min-h-0 flex-1 p-0">
          <MessageScrollerProvider>
            <MessageScroller key={demoKey}>
              <MessageScrollerViewport>
                <MessageScrollerContent className="p-4">
                  {items.map((item) =>
                    item.type === "message" ? (
                      <GroupChatMessage item={item} key={item.id} />
                    ) : (
                      <GroupChatMarker item={item} key={item.id} scrollAnchor={item.scrollAnchor} />
                    ),
                  )}
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </MessageScrollerProvider>
        </CardContent>
        <CardFooter className="flex flex-col items-center gap-2 border-t">
          <Button
            className="w-full"
            disabled={isComplete}
            onClick={() => setRockyTurn((turn) => (turn === "idle" ? "marker" : "message"))}
            type="button"
            variant="secondary"
          >
            {buttonLabel}
          </Button>
          <p className="text-muted-foreground text-xs">
            {rockyTurn === "idle"
              ? "This will create a marker and make it the anchor"
              : "Now send Rocky's reply into the conversation"}
          </p>
        </CardFooter>
      </Card>
      <div className="mx-auto max-w-sm text-balance px-0.5 text-center text-muted-foreground text-xs">
        When a user joins, a marker is created. scrollAnchor on the marker marks it as the next turn
      </div>
    </div>
  );
}

Opening Position

Reopening a saved thread at the absolute end often drops the reader in without enough context. defaultScrollPosition="last-anchor" shows the last meaningful turn instead, keyed on scrollAnchor rather than message role.

Loading
Loading...
"use client";
 
import * as React from "react";
 
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Message, MessageContent } from "@/components/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/ui/message-scroller";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
 
const messages = [
  {
    id: "open-1",
    role: "user",
    text: "This is the first message the user sent in the conversation.",
  },
  {
    id: "open-2",
    role: "assistant",
    text: "Workspace creation rose 8%, but first invite completion only rose 2%.",
  },
  {
    id: "open-3",
    role: "user",
    text: "This is the last message the user sent in the conversation.",
  },
  {
    id: "open-4",
    role: "assistant",
    text: "Start with the invite step. Teams are creating workspaces but waiting to add collaborators.\n\nRecommended follow-up:\n\n1. Compare invite drop-off by account size.\n2. Check whether users who skip invites still return within 24 hours.\n3. Review the empty-state copy on the first project screen.\n4. Segment activation by template, since template users may not need invites right away.\n\nIf that pattern holds, the next experiment should make collaboration useful earlier instead of prompting for invites harder.",
  },
] satisfies {
  id: string;
  role: "user" | "assistant";
  text: string;
}[];
 
const positions = [
  { label: "start", value: "start" },
  { label: "end", value: "end" },
  { label: "last-anchor", value: "last-anchor" },
] satisfies {
  value: "start" | "end" | "last-anchor";
  label: string;
}[];
 
const OpeningPositionScroller = ({
  position,
  positionKey,
}: {
  position: "start" | "end" | "last-anchor";
  positionKey: number;
}) => {
  const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScroller();
 
  React.useLayoutEffect(() => {
    const frame = requestAnimationFrame(() => {
      if (position === "start") {
        scrollToStart({ behavior: "auto" });
        return;
      }
 
      if (position === "end") {
        scrollToEnd({ behavior: "auto" });
        return;
      }
 
      scrollToMessage("open-3", {
        align: "start",
        behavior: "auto",
        scrollMargin: 64,
      });
    });
 
    return () => {
      cancelAnimationFrame(frame);
    };
  }, [position, positionKey, scrollToEnd, scrollToMessage, scrollToStart]);
 
  return (
    <MessageScroller>
      <MessageScrollerViewport>
        <MessageScrollerContent className="p-4">
          {messages.map((message) => {
            const isUserMessage = message.role === "user";
 
            return (
              <MessageScrollerItem
                key={message.id}
                messageId={message.id}
                scrollAnchor={isUserMessage}
              >
                <Message align={isUserMessage ? "end" : "start"}>
                  <MessageContent>
                    <Bubble variant={isUserMessage ? "muted" : "ghost"}>
                      <BubbleContent className="space-y-2">
                        {message.text
                          .split(/\n\s*\n/u)
                          .map((paragraph) => paragraph.trim())
                          .filter(Boolean)
                          .map((paragraph) => (
                            <p className="whitespace-pre-wrap" key={paragraph}>
                              {paragraph}
                            </p>
                          ))}
                      </BubbleContent>
                    </Bubble>
                  </MessageContent>
                </Message>
              </MessageScrollerItem>
            );
          })}
        </MessageScrollerContent>
      </MessageScrollerViewport>
      <MessageScrollerButton />
    </MessageScroller>
  );
};
 
export function MessageScrollerOpeningPosition() {
  const [positionKey, setPositionKey] = React.useState(0);
  const [position, setPosition] = React.useState<"start" | "end" | "last-anchor">("last-anchor");
 
  return (
    <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>Opening Position</CardTitle>
          <CardDescription>Choose where a saved transcript opens.</CardDescription>
        </CardHeader>
        <CardContent className="flex-1 overflow-hidden p-0">
          <MessageScrollerProvider>
            <OpeningPositionScroller position={position} positionKey={positionKey} />
          </MessageScrollerProvider>
        </CardContent>
        <CardFooter className="flex items-center justify-center border-t">
          <Tabs
            className="w-full"
            onValueChange={(value) => {
              if (value === "start" || value === "end" || value === "last-anchor") {
                setPosition(value);
                setPositionKey((key) => key + 1);
              }
            }}
            value={position}
          >
            <TabsList className="w-full">
              {positions.map((option) => (
                <TabsTrigger key={option.value} value={option.value}>
                  {option.label}
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>
        </CardFooter>
      </Card>
      <div className="mx-auto max-w-sm px-0.5 text-center text-muted-foreground text-xs">
        Toggle the defaultScrollPosition to see where the transcript starts when you open the thread
      </div>
    </div>
  );
}

Scroll State

Use useMessageScrollerScrollable when you need scroll state in JavaScript, such as a status indicator or a custom jump-to-latest control. It reports which edges the viewport can still scroll toward.

Loading
Loading...
"use client";
 
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Message, MessageContent } from "@/components/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScrollerScrollable,
} from "@/components/ui/message-scroller";
 
const messages = Array.from({ length: 12 }, (_, index) => ({
  id: `state-${index + 1}`,
  role: index % 2 === 0 ? "user" : "assistant",
  text:
    index % 2 === 0
      ? `Check section ${index + 1} of the transcript.`
      : `Section ${index + 1} is ready. Scroll state updates without rerendering the rows.`,
})) satisfies {
  id: string;
  role: "user" | "assistant";
  text: string;
}[];
 
const StatusBar = () => {
  const { start, end } = useMessageScrollerScrollable();
  const states = [
    { label: "At top", on: !start },
    { label: "At bottom", on: !end },
    { label: "Older above", on: start },
    { label: "Newer below", on: end },
  ];
 
  return (
    <div className="pointer-events-none absolute inset-x-3 top-3 z-10 flex flex-wrap gap-1.5">
      {states.map((state) => (
        <span
          className="rounded-full border bg-background px-2 py-0.5 text-muted-foreground text-xs data-[on=true]:border-transparent data-[on=true]:bg-primary data-[on=true]:text-primary-foreground"
          data-on={state.on}
          key={state.label}
        >
          {state.label}
        </span>
      ))}
    </div>
  );
};
 
export function MessageScrollerState() {
  return (
    <Card className="mx-auto h-112 w-full max-w-md gap-0">
      <CardHeader className="border-b">
        <CardTitle>Scroll State</CardTitle>
        <CardDescription>Read scroll state in JavaScript with the state hook.</CardDescription>
      </CardHeader>
      <CardContent className="min-h-0 flex-1 p-0">
        <MessageScrollerProvider defaultScrollPosition="start">
          <MessageScroller>
            <StatusBar />
            <MessageScrollerViewport>
              <MessageScrollerContent className="gap-4 p-4 pt-12">
                {messages.map((message) => (
                  <MessageScrollerItem
                    key={message.id}
                    messageId={message.id}
                    scrollAnchor={message.role === "user"}
                  >
                    <Message align={message.role === "user" ? "end" : "start"}>
                      <MessageContent>
                        <Bubble variant={message.role === "user" ? "default" : "muted"}>
                          <BubbleContent>{message.text}</BubbleContent>
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                ))}
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </MessageScrollerProvider>
      </CardContent>
    </Card>
  );
}

API Reference

useMessageScroller

Imperative transcript controls.

MethodTypeDescription
scrollToMessage(messageId: string, options?) => booleanScroll to a mounted message id.
scrollToEnd(options?) => booleanScroll to the latest message.
scrollToStart(options?) => booleanScroll to the top.

All commands return false when the command could not be applied. scrollToStart and scrollToEnd return false only when the viewport is not mounted yet. scrollToMessage returns false when the target is not mounted and cannot be queued.

Command options:

OptionTypeDefaultDescription
align"start" | "center" | "end" | "nearest""start"How a message target aligns in the viewport.
behaviorScrollBehavior"auto"Native scroll behavior for the command.
scrollMarginnumberprovider scrollMarginMargin applied to the aligned edge for this command.

useMessageScrollerScrollable

Which edges the viewport can scroll toward, for sibling UI that needs the values in JavaScript. Prefer the data-scrollable attribute for styling the scroller itself.

ValueTypeDescription
startbooleanWhether the viewport can scroll toward the start. Content is hidden above (!start means at the top).
endbooleanWhether the viewport can scroll toward the end. Content is hidden below (!end means at the bottom).

useMessageScrollerVisibility

Visibility state for outline, search, and active-turn UI. It subscribes separately from useMessageScrollerScrollable, so visibility work is only paid for when a consumer needs it.

ValueTypeDescription
currentAnchorIdstring | nullThe current anchored turn, based on the last scrollAnchor item at or above the reading line.
visibleMessageIdsstring[]Message ids intersecting the viewport, in document order.

Filter visibleMessageIds in your app when you need a narrower outline, such as user messages, anchored turns, or search hits.