codefast/ui

Command Palette

Search for a command to run...

Source
Layout

Message Scroller

Scroll manager for chat transcripts: follow-the-bottom, prepend preservation, anchored turns, and a scroll-to-edge button.

Examples

Streaming messages

autoScroll pins the viewport to the live edge as replies arrive, and MessageScrollerButton offers a way back once the reader scrolls up.

Streaming Messages
Auto-scroll follows the live edge of the conversation.
Ready to Stream

Press send to reveal a scripted conversation.

I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
Streaming is simulated. autoScroll is enabled.
222 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@codefast/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@codefast/ui/dropdown-menu";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@codefast/ui/empty";
import { InputGroup, InputGroupAddon, InputGroupButton } from "@codefast/ui/input-group";
import { Message, MessageContent } from "@codefast/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@codefast/ui/message-scroller";
import { Tooltip, TooltipContent, TooltipTrigger } from "@codefast/ui/tooltip";
import {
  ArrowUpIcon,
  GlobeIcon,
  ImageIcon,
  MessageCircleDashedIcon,
  PaperclipIcon,
  PlusIcon,
  RotateCwIcon,
  TelescopeIcon,
} from "lucide-react";
import * as React from "react";

interface Turn {
  id: string;
  role: "user" | "assistant";
  text: string;
}

const TRANSCRIPT: Array<Turn> = [
  {
    id: "stream-jarring",
    role: "user",
    text: "I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.",
  },
  {
    id: "stream-jarring-a",
    role: "assistant",
    text: "That's the classic streaming scroll problem. Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\n\nThe important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved.",
  },
  {
    id: "stream-reload",
    role: "user",
    text: "Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.",
  },
  {
    id: "stream-reload-a",
    role: "assistant",
    text: "MessageScrollerItem fixes that with turn anchoring. Set isScrollAnchor on the turn that should settle near the top instead of blindly snapping to the document bottom.\n\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost.",
  },
  {
    id: "stream-scrolled-up",
    role: "user",
    text: "And if they've scrolled up to re-read an older answer? I don't want to yank them back down.",
  },
  {
    id: "stream-scrolled-up-a",
    role: "assistant",
    text: "You won't. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out — their place in the thread stays put even as new tokens keep arriving below.\n\nWhen there is content they haven't seen yet, MessageScrollerButton appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll.",
  },
];

export function MessageScrollerStreaming() {
  const [count, setCount] = React.useState(0);
  const messages = TRANSCRIPT.slice(0, count);
  const nextMessage = TRANSCRIPT[count];

  return (
    <MessageScrollerProvider autoScroll>
      <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>Streaming Messages</CardTitle>
            <CardDescription>Auto-scroll follows the live edge of the conversation.</CardDescription>
            <CardAction>
              <Tooltip>
                <TooltipTrigger asChild>
                  <Button
                    aria-label="Reset stream"
                    disabled={count === 0}
                    size="icon"
                    variant="outline"
                    onClick={() => {
                      setCount(0);
                    }}
                  >
                    <RotateCwIcon />
                  </Button>
                </TooltipTrigger>
                <TooltipContent>
                  <p>Reset</p>
                </TooltipContent>
              </Tooltip>
            </CardAction>
          </CardHeader>
          <CardContent className="flex-1 overflow-hidden p-0">
            {messages.length === 0 ? (
              <Empty className="h-full">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <MessageCircleDashedIcon />
                  </EmptyMedia>
                  <EmptyTitle>Ready to Stream</EmptyTitle>
                  <EmptyDescription>Press send to reveal a scripted conversation.</EmptyDescription>
                </EmptyHeader>
              </Empty>
            ) : (
              <MessageScroller>
                <MessageScrollerViewport>
                  <MessageScrollerContent className="p-6">
                    {messages.map((message) => {
                      const isUser = message.role === "user";

                      return (
                        <MessageScrollerItem key={message.id} messageId={message.id} isScrollAnchor={isUser}>
                          <Message align={isUser ? "end" : "start"}>
                            <MessageContent>
                              <Bubble align={isUser ? "end" : "start"} variant={isUser ? "muted" : "ghost"}>
                                <BubbleContent className="space-y-2">
                                  {message.text
                                    .split(/\n\s*\n/)
                                    .map((paragraph) => paragraph.trim())
                                    .filter(Boolean)
                                    .map((paragraph) => (
                                      <p key={paragraph} className="whitespace-pre-wrap">
                                        {paragraph}
                                      </p>
                                    ))}
                                </BubbleContent>
                              </Bubble>
                            </MessageContent>
                          </Message>
                        </MessageScrollerItem>
                      );
                    })}
                  </MessageScrollerContent>
                </MessageScrollerViewport>
                <MessageScrollerButton />
              </MessageScroller>
            )}
          </CardContent>
          <CardFooter className="flex-col gap-2">
            <form
              className="w-full"
              onSubmit={(event) => {
                event.preventDefault();

                if (!nextMessage) {
                  return;
                }

                setCount((value) => value + 1);
              }}
            >
              <InputGroup>
                <div className="h-14 w-full px-3 py-2.5">
                  <span className="line-clamp-2 text-ui-fg">
                    {nextMessage ? (
                      nextMessage.text
                    ) : (
                      <span className="text-ui-muted">No messages queued. Reset the stream.</span>
                    )}
                  </span>
                </div>
                <InputGroupAddon align="block-end" className="pt-1">
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <InputGroupButton aria-label="Add files" size="icon-sm" type="button" variant="outline">
                        <PlusIcon />
                      </InputGroupButton>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="start" className="w-44" side="top">
                      <DropdownMenuItem>
                        <PaperclipIcon />
                        Add Photos &amp; Files
                      </DropdownMenuItem>
                      <DropdownMenuSeparator />
                      <DropdownMenuItem>
                        <ImageIcon />
                        Create Image
                      </DropdownMenuItem>
                      <DropdownMenuItem>
                        <TelescopeIcon />
                        Deep Research
                      </DropdownMenuItem>
                      <DropdownMenuItem>
                        <GlobeIcon />
                        Web Search
                      </DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                  <InputGroupButton
                    className="ml-auto"
                    disabled={!nextMessage}
                    size="icon-sm"
                    type="submit"
                    variant="default"
                  >
                    <ArrowUpIcon />
                    <span className="sr-only">Send</span>
                  </InputGroupButton>
                </InputGroupAddon>
              </InputGroup>
            </form>
          </CardFooter>
        </Card>
        <div className="px-0.5 text-center text-xs text-ui-muted">Streaming is simulated. autoScroll is enabled.</div>
      </div>
    </MessageScrollerProvider>
  );
}

Anchoring turns

isScrollAnchor marks which role settles near the top edge — toggle between user and assistant to compare where each new turn lands.

Anchoring Turns
Choose which role settles near the top edge.
No anchored messages yet

Send the first message to see the selected role anchor.

Toggle the anchor role, then send messages to compare where turns settle.
170 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@codefast/ui/card";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@codefast/ui/empty";
import { Message, MessageContent } from "@codefast/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@codefast/ui/message-scroller";
import { ToggleGroup, ToggleGroupItem } from "@codefast/ui/toggle-group";
import { ArrowUpIcon, MessageCircleDashedIcon, RotateCwIcon } from "lucide-react";
import * as React from "react";

type AnchorRole = "user" | "assistant";

interface ChatMessage {
  id: string;
  role: AnchorRole;
  text: string;
}

const SCRIPTED_MESSAGES: Array<ChatMessage> = [
  {
    id: "anchor-1-user",
    role: "user",
    text: "Can you show me how anchoring behaves when a new prompt starts the turn?",
  },
  {
    id: "anchor-1-assistant",
    role: "assistant",
    text: "Append the user prompt first, then append the assistant response. With User selected, the prompt settles near the top and the assistant response fills in below it.",
  },
  {
    id: "anchor-2-user",
    role: "user",
    text: "What changes when assistant messages are the anchor?",
  },
  {
    id: "anchor-2-assistant",
    role: "assistant",
    text: "Now each assistant response is the item MessageScroller keeps in view. This is useful when the reply is the moment you want readers to land on after each turn.",
  },
  {
    id: "anchor-3-user",
    role: "user",
    text: "Can I switch roles and keep adding turns?",
  },
  {
    id: "anchor-3-assistant",
    role: "assistant",
    text: "Yes. The next appended message with the selected role becomes the anchor, so you can compare user and assistant anchoring without resetting the demo.",
  },
];

export function MessageScrollerAnchoring() {
  const [anchorRole, setAnchorRole] = React.useState<AnchorRole>("user");
  const [count, setCount] = React.useState(0);
  const messages = SCRIPTED_MESSAGES.slice(0, count);
  const nextMessage = SCRIPTED_MESSAGES[count];

  return (
    <div className="relative flex flex-col gap-4">
      <Card className="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader className="border-b">
          <CardTitle>Anchoring Turns</CardTitle>
          <CardDescription>Choose which role settles near the top edge.</CardDescription>
          <CardAction>
            <Button
              aria-label="Reset anchored turns"
              disabled={count === 0}
              size="icon"
              type="button"
              variant="outline"
              onClick={() => {
                setCount(0);
              }}
            >
              <RotateCwIcon />
            </Button>
          </CardAction>
        </CardHeader>
        <CardContent className="min-h-0 flex-1 overflow-hidden p-0">
          {messages.length === 0 ? (
            <Empty className="h-full">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <MessageCircleDashedIcon />
                </EmptyMedia>
                <EmptyTitle>No anchored messages yet</EmptyTitle>
                <EmptyDescription>Send the first message to see the selected role anchor.</EmptyDescription>
              </EmptyHeader>
            </Empty>
          ) : (
            <MessageScrollerProvider>
              <MessageScroller>
                <MessageScrollerViewport>
                  <MessageScrollerContent className="p-6">
                    {messages.map((message) => {
                      const isUser = message.role === "user";

                      return (
                        <MessageScrollerItem
                          key={message.id}
                          messageId={message.id}
                          isScrollAnchor={message.role === anchorRole}
                        >
                          <Message align={isUser ? "end" : "start"}>
                            <MessageContent>
                              <Bubble align={isUser ? "end" : "start"} variant={isUser ? "muted" : "ghost"}>
                                <BubbleContent>{message.text}</BubbleContent>
                              </Bubble>
                            </MessageContent>
                          </Message>
                        </MessageScrollerItem>
                      );
                    })}
                  </MessageScrollerContent>
                </MessageScrollerViewport>
                <MessageScrollerButton />
              </MessageScroller>
            </MessageScrollerProvider>
          )}
        </CardContent>
        <CardFooter>
          <ToggleGroup
            aria-label="Select scroll anchor role"
            type="single"
            value={anchorRole}
            onValueChange={(value) => {
              if (value === "user" || value === "assistant") {
                setAnchorRole(value);
                setCount(0);
              }
            }}
          >
            <ToggleGroupItem aria-label="Anchor user messages" value="user">
              User
            </ToggleGroupItem>
            <ToggleGroupItem aria-label="Anchor assistant messages" value="assistant">
              Assistant
            </ToggleGroupItem>
          </ToggleGroup>
          <Button
            className="ml-auto"
            disabled={!nextMessage}
            size="icon"
            type="button"
            onClick={() => {
              if (!nextMessage) {
                return;
              }

              setCount((value) => value + 1);
            }}
          >
            <ArrowUpIcon />
            <span className="sr-only">Send Message</span>
          </Button>
        </CardFooter>
      </Card>
      <div className="mx-auto max-w-xs px-0.5 text-center text-xs text-ui-muted">
        Toggle the anchor role, then send messages to compare where turns settle.
      </div>
    </div>
  );
}

Keeping context visible

scrollPreviousItemPeek leaves a slice of the previous reply above a newly anchored turn — adjust the peek and send to see it change.

Keeping Context Visible
New turns keep part of the previous reply in view.

I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.

That's the classic streaming scroll problem. Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.

The important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved.

Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.
64px
Adjust the slider and send. Observe the previous message peek.
222 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@codefast/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@codefast/ui/dropdown-menu";
import { InputGroup, InputGroupAddon, InputGroupButton } from "@codefast/ui/input-group";
import { Message, MessageContent } from "@codefast/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@codefast/ui/message-scroller";
import { Slider } from "@codefast/ui/slider";
import { Tooltip, TooltipContent, TooltipTrigger } from "@codefast/ui/tooltip";
import { ArrowUpIcon, GlobeIcon, ImageIcon, PaperclipIcon, PlusIcon, RotateCwIcon, TelescopeIcon } from "lucide-react";
import * as React from "react";

interface Turn {
  id: string;
  role: "user" | "assistant";
  text: string;
}

const DEFAULT_PEEK = 64;
const INITIAL_COUNT = 2;

const TRANSCRIPT: Array<Turn> = [
  {
    id: "context-jarring",
    role: "user",
    text: "I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.",
  },
  {
    id: "context-jarring-a",
    role: "assistant",
    text: "That's the classic streaming scroll problem. Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\n\nThe important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved.",
  },
  {
    id: "context-reload",
    role: "user",
    text: "Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.",
  },
  {
    id: "context-reload-a",
    role: "assistant",
    text: "MessageScrollerItem fixes that with turn anchoring. Set isScrollAnchor on the turn that should settle near the top instead of blindly snapping to the document bottom.\n\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost.",
  },
  {
    id: "context-scrolled-up",
    role: "user",
    text: "And if they've scrolled up to re-read an older answer? I don't want to yank them back down.",
  },
  {
    id: "context-scrolled-up-a",
    role: "assistant",
    text: "You won't. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out — their place in the thread stays put even as new tokens keep arriving below.\n\nWhen there is content they haven't seen yet, MessageScrollerButton appears at the bottom of the viewport.",
  },
];

export function MessageScrollerPreviousContext() {
  const [demoKey, setDemoKey] = React.useState(0);
  const [peek, setPeek] = React.useState(DEFAULT_PEEK);
  const [count, setCount] = React.useState(INITIAL_COUNT);
  const messages = TRANSCRIPT.slice(0, count);
  const nextMessage = TRANSCRIPT[count];

  return (
    <MessageScrollerProvider key={demoKey} scrollMargin={24} scrollPreviousItemPeek={peek}>
      <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>Keeping Context Visible</CardTitle>
            <CardDescription>New turns keep part of the previous reply in view.</CardDescription>
            <CardAction>
              <Tooltip>
                <TooltipTrigger asChild>
                  <Button
                    aria-label="Reset context example"
                    size="icon"
                    variant="outline"
                    onClick={() => {
                      setCount(INITIAL_COUNT);
                      setPeek(DEFAULT_PEEK);
                      setDemoKey((key) => key + 1);
                    }}
                  >
                    <RotateCwIcon />
                  </Button>
                </TooltipTrigger>
                <TooltipContent>
                  <p>Reset</p>
                </TooltipContent>
              </Tooltip>
            </CardAction>
          </CardHeader>
          <CardContent className="flex-1 overflow-hidden p-0">
            <MessageScroller>
              <MessageScrollerViewport>
                <MessageScrollerContent className="p-6">
                  {messages.map((message) => {
                    const isUser = message.role === "user";

                    return (
                      <MessageScrollerItem key={message.id} messageId={message.id} isScrollAnchor={isUser}>
                        <Message align={isUser ? "end" : "start"}>
                          <MessageContent>
                            <Bubble align={isUser ? "end" : "start"} variant={isUser ? "muted" : "ghost"}>
                              <BubbleContent className="space-y-2">
                                {message.text
                                  .split(/\n\s*\n/)
                                  .map((paragraph) => paragraph.trim())
                                  .filter(Boolean)
                                  .map((paragraph) => (
                                    <p key={paragraph} className="whitespace-pre-wrap">
                                      {paragraph}
                                    </p>
                                  ))}
                              </BubbleContent>
                            </Bubble>
                          </MessageContent>
                        </Message>
                      </MessageScrollerItem>
                    );
                  })}
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </CardContent>
          <CardFooter className="flex-col gap-2">
            <form
              className="w-full"
              onSubmit={(event) => {
                event.preventDefault();

                if (!nextMessage) {
                  return;
                }

                setCount((value) => value + 1);
              }}
            >
              <InputGroup>
                <div className="h-14 w-full px-3 py-2.5">
                  <span className="line-clamp-2 text-ui-fg">
                    {nextMessage ? (
                      nextMessage.text
                    ) : (
                      <span className="text-ui-muted">No messages queued. Reset the context.</span>
                    )}
                  </span>
                </div>
                <InputGroupAddon align="block-end" className="pt-1">
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <InputGroupButton aria-label="Add files" size="icon-sm" type="button" variant="outline">
                        <PlusIcon />
                      </InputGroupButton>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="start" className="w-44" side="top">
                      <DropdownMenuItem>
                        <PaperclipIcon />
                        Add Photos &amp; Files
                      </DropdownMenuItem>
                      <DropdownMenuSeparator />
                      <DropdownMenuItem>
                        <ImageIcon />
                        Create Image
                      </DropdownMenuItem>
                      <DropdownMenuItem>
                        <TelescopeIcon />
                        Deep Research
                      </DropdownMenuItem>
                      <DropdownMenuItem>
                        <GlobeIcon />
                        Web Search
                      </DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                  <div className="flex w-28 items-center gap-2">
                    <span className="text-xs text-ui-muted tabular-nums">{peek}px</span>
                    <Slider
                      aria-label="Previous context peek"
                      max={128}
                      min={64}
                      step={1}
                      value={[peek]}
                      onValueChange={(value) => {
                        setPeek(value[0] ?? DEFAULT_PEEK);
                      }}
                    />
                  </div>
                  <InputGroupButton
                    className="ml-auto"
                    disabled={!nextMessage}
                    size="icon-sm"
                    type="submit"
                    variant="default"
                  >
                    <ArrowUpIcon />
                    <span className="sr-only">Send</span>
                  </InputGroupButton>
                </InputGroupAddon>
              </InputGroup>
            </form>
          </CardFooter>
        </Card>
        <div className="px-0.5 text-center text-xs text-ui-muted">
          Adjust the slider and send. Observe the previous message peek.
        </div>
      </div>
    </MessageScrollerProvider>
  );
}

Opening position

defaultScrollPosition decides where a saved transcript opens — start, end, or the last anchored turn.

Opening Position
Choose where a saved transcript opens.

This is the first message the user sent in the conversation.

Workspace creation rose 8%, but first invite completion only rose 2%.

This is the last message the user sent in the conversation.

Start with the invite step. Teams are creating workspaces but waiting to add collaborators.

Recommended follow-up:

1. Compare invite drop-off by account size. 2. Check whether users who skip invites still return within 24 hours. 3. Review the empty-state copy on the first project screen. 4. Segment activation by template, since template users may not need invites right away.

If that pattern holds, the next experiment should make collaboration useful earlier instead of prompting for invites harder.

Toggle the defaultScrollPosition to see where the transcript starts when you open the thread.
156 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@codefast/ui/card";
import { Message, MessageContent } from "@codefast/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@codefast/ui/message-scroller";
import { Tabs, TabsList, TabsTrigger } from "@codefast/ui/tabs";
import * as React from "react";

type Position = "start" | "end" | "last-anchor";

interface Turn {
  id: string;
  role: "user" | "assistant";
  text: string;
}

const MESSAGES: Array<Turn> = [
  {
    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.",
  },
];

const POSITIONS: Array<{ value: Position; label: string }> = [
  { value: "start", label: "start" },
  { value: "end", label: "end" },
  { value: "last-anchor", label: "last-anchor" },
];

function OpeningPositionScroller({ position, positionKey }: { position: Position; 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-6">
          {MESSAGES.map((message) => {
            const isUser = message.role === "user";

            return (
              <MessageScrollerItem key={message.id} messageId={message.id} isScrollAnchor={isUser}>
                <Message align={isUser ? "end" : "start"}>
                  <MessageContent>
                    <Bubble align={isUser ? "end" : "start"} variant={isUser ? "muted" : "ghost"}>
                      <BubbleContent className="space-y-2">
                        {message.text
                          .split(/\n\s*\n/)
                          .map((paragraph) => paragraph.trim())
                          .filter(Boolean)
                          .map((paragraph) => (
                            <p key={paragraph} className="whitespace-pre-wrap">
                              {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<Position>("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"
            value={position}
            onValueChange={(value) => {
              if (value === "start" || value === "end" || value === "last-anchor") {
                setPosition(value);
                setPositionKey((key) => key + 1);
              }
            }}
          >
            <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-xs text-ui-muted">
        Toggle the defaultScrollPosition to see where the transcript starts when you open the thread.
      </div>
    </div>
  );
}

Load history

preserveScrollOnPrepend keeps the first visible row stable when earlier messages are prepended, so loading history never yanks the reader.

Load History
Prepended messages keep your place.

Only the export queue worker changed. The deploy moved large CSV jobs onto the shared retry policy, which made each failed attempt hold a worker slot longer than before.

The app deploy did not include checkout, pricing, or billing API changes.

Do we need to roll back?

Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.

Keep rollback ready if the queue starts climbing again, but the current trend points toward recovery.

Keep watching for customer-visible issues.

I will watch the queue and support tags for another 15 minutes. I am tracking export failures, delayed download requests, and any support thread that mentions missing reports.

If those stay quiet through the next batch window, we can close this as an internal degradation.

End of Conversation

Restore earlier messages while keeping your place.

Click Load History to load the entire conversation.
159 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@codefast/ui/card";
import { Marker, MarkerContent } from "@codefast/ui/marker";
import { Message, MessageContent } from "@codefast/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@codefast/ui/message-scroller";
import { toast } from "@codefast/ui/sonner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@codefast/ui/tooltip";
import { RotateCwIcon } from "lucide-react";
import * as React from "react";

interface Turn {
  id: string;
  role: "user" | "assistant";
  text: string;
}

const HISTORY: Array<Turn> = [
  { id: "history-summary", role: "user", text: "Can you summarize the incident channel?" },
  {
    id: "history-summary-a",
    role: "assistant",
    text: "The first alert was a delayed export job. It started backing up around 09:42 UTC and triggered the warning once the retry queue crossed the threshold.\n\nNo customer-facing checkout paths were affected, but exports for larger workspaces were running about 12 minutes behind.",
  },
  { id: "history-checkout", role: "user", text: "Was checkout affected?" },
  {
    id: "history-checkout-a",
    role: "assistant",
    text: "No checkout errors were reported. Payment authorization, order creation, and confirmation emails stayed inside their normal latency bands.\n\nThe only elevated metric was export queue depth, which maps to analytics downloads instead of checkout.",
  },
  { id: "history-deploy", role: "user", text: "What changed in the last deploy?" },
  {
    id: "history-deploy-a",
    role: "assistant",
    text: "Only the export queue worker changed. The deploy moved large CSV jobs onto the shared retry policy, which made each failed attempt hold a worker slot longer than before.\n\nThe app deploy did not include checkout, pricing, or billing API changes.",
  },
  { id: "history-rollback", role: "user", text: "Do we need to roll back?" },
  {
    id: "history-rollback-a",
    role: "assistant",
    text: "Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.\n\nKeep rollback ready if the queue starts climbing again, but the current trend points toward recovery.",
  },
  { id: "history-watch", role: "user", text: "Keep watching for customer-visible issues." },
  {
    id: "history-watch-a",
    role: "assistant",
    text: "I will watch the queue and support tags for another 15 minutes. I am tracking export failures, delayed download requests, and any support thread that mentions missing reports.\n\nIf those stay quiet through the next batch window, we can close this as an internal degradation.",
  },
];

const INITIAL_VISIBLE_COUNT = 5;

export function MessageScrollerLoadHistory() {
  const [demoKey, setDemoKey] = React.useState(0);
  const [visibleCount, setVisibleCount] = React.useState(INITIAL_VISIBLE_COUNT);
  const visibleMessages = HISTORY.slice(-visibleCount);
  const canLoadHistory = visibleCount < HISTORY.length;

  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>Load History</CardTitle>
          <CardDescription>Prepended messages keep your place.</CardDescription>
          <CardAction>
            <Tooltip>
              <TooltipTrigger asChild>
                <Button
                  aria-label="Reset loaded messages"
                  disabled={visibleCount === INITIAL_VISIBLE_COUNT}
                  size="icon"
                  type="button"
                  variant="outline"
                  onClick={() => {
                    setVisibleCount(INITIAL_VISIBLE_COUNT);
                    setDemoKey((key) => key + 1);
                  }}
                >
                  <RotateCwIcon />
                </Button>
              </TooltipTrigger>
              <TooltipContent>
                <p>Reset</p>
              </TooltipContent>
            </Tooltip>
          </CardAction>
        </CardHeader>
        <CardContent className="flex-1 overflow-hidden p-0">
          <MessageScrollerProvider>
            <MessageScroller key={demoKey}>
              <MessageScrollerViewport>
                <MessageScrollerContent className="p-6">
                  {visibleMessages.map((message) => {
                    const isUser = message.role === "user";

                    return (
                      <MessageScrollerItem key={message.id} messageId={message.id}>
                        <Message align={isUser ? "end" : "start"}>
                          <MessageContent>
                            <Bubble align={isUser ? "end" : "start"} variant={isUser ? "muted" : "ghost"}>
                              <BubbleContent className="space-y-2">
                                {message.text
                                  .split(/\n\s*\n/)
                                  .map((paragraph) => paragraph.trim())
                                  .filter(Boolean)
                                  .map((paragraph) => (
                                    <p key={paragraph} className="whitespace-pre-wrap">
                                      {paragraph}
                                    </p>
                                  ))}
                              </BubbleContent>
                            </Bubble>
                          </MessageContent>
                        </Message>
                      </MessageScrollerItem>
                    );
                  })}
                  <MessageScrollerItem isScrollAnchor={false}>
                    <Marker variant="separator">
                      <MarkerContent>End of Conversation</MarkerContent>
                    </Marker>
                  </MessageScrollerItem>
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </MessageScrollerProvider>
        </CardContent>
        <CardFooter className="flex flex-col items-center gap-2 border-t">
          <Button
            className="w-full"
            disabled={!canLoadHistory}
            type="button"
            variant="secondary"
            onClick={() => {
              setVisibleCount(HISTORY.length);
              toast("History loaded", {
                description: "Scroll up to see earlier messages.",
              });
            }}
          >
            {canLoadHistory ? "Load History" : "History Loaded"}
          </Button>
          <p className="text-xs text-ui-muted">Restore earlier messages while keeping your place.</p>
        </CardFooter>
      </Card>
      <div className="mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-ui-muted">
        Click Load History to load the entire conversation.
      </div>
    </div>
  );
}

Group chat

Mix Message rows and Marker events in one transcript; a marker with isScrollAnchor becomes the next turn boundary when a participant joins.

Group Chat
A group chat with several participants and an assistant. The Marker is marked as a turn.
@mary, the astrophage line keeps matching Venus energy output. Can you check my math?
Mary (Agent)
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.
ping @rocky

This will create a marker and make it the anchor

When a user joins, a marker is created. isScrollAnchor on the marker marks it as the next turn.
195 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@codefast/ui/card";
import { Marker, MarkerContent } from "@codefast/ui/marker";
import { Message, MessageContent, MessageHeader } from "@codefast/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@codefast/ui/message-scroller";
import { Tooltip, TooltipContent, TooltipTrigger } from "@codefast/ui/tooltip";
import { RotateCwIcon } from "lucide-react";
import * as React from "react";

type GroupChatItem =
  | {
      id: string;
      type: "event";
      text: string;
      isScrollAnchor?: boolean | undefined;
    }
  | {
      id: string;
      type: "message";
      sender: string;
      role: "assistant" | "participant";
      text: string;
      isScrollAnchor?: boolean | undefined;
    };

const CURRENT_USER = "Grace";

const INITIAL_ITEMS: Array<GroupChatItem> = [
  {
    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",
    isScrollAnchor: true,
  },
];

const ROCKY_MARKER: GroupChatItem = {
  id: "group-4",
  type: "event",
  text: "Rocky has joined the chat",
  isScrollAnchor: true,
};

const ROCKY_MESSAGE: GroupChatItem = {
  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.",
};

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} isScrollAnchor={item.isScrollAnchor ?? false}>
      <Message align={isCurrentUser ? "end" : "start"}>
        <MessageContent>
          {!isCurrentUser && <MessageHeader>{item.sender}</MessageHeader>}
          <Bubble align={isCurrentUser ? "end" : "start"} variant={variant}>
            <BubbleContent>{item.text}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  );
}

function GroupChatMarker({
  item,
  isScrollAnchor = false,
}: {
  item: Extract<GroupChatItem, { type: "event" }>;
  isScrollAnchor?: boolean | undefined;
}) {
  return (
    <MessageScrollerItem isScrollAnchor={isScrollAnchor}>
      <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");
  const items =
    rockyTurn === "message"
      ? [...INITIAL_ITEMS, ROCKY_MARKER, ROCKY_MESSAGE]
      : rockyTurn === "marker"
        ? [...INITIAL_ITEMS, ROCKY_MARKER]
        : INITIAL_ITEMS;
  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 asChild>
                <Button
                  aria-label="Reset conversation"
                  disabled={rockyTurn === "idle"}
                  size="icon"
                  type="button"
                  variant="outline"
                  onClick={() => {
                    setRockyTurn("idle");
                    setDemoKey((key) => key + 1);
                  }}
                >
                  <RotateCwIcon />
                </Button>
              </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-6">
                  {items.map((item) =>
                    item.type === "message" ? (
                      <GroupChatMessage key={item.id} item={item} />
                    ) : (
                      <GroupChatMarker key={item.id} item={item} isScrollAnchor={item.isScrollAnchor} />
                    ),
                  )}
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </MessageScrollerProvider>
        </CardContent>
        <CardFooter className="flex flex-col items-center gap-2 border-t">
          <Button
            className="w-full"
            disabled={isComplete}
            type="button"
            variant="secondary"
            onClick={() => {
              setRockyTurn((turn) => (turn === "idle" ? "marker" : "message"));
            }}
          >
            {buttonLabel}
          </Button>
          <p className="text-xs text-ui-muted">
            {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 px-0.5 text-center text-xs text-balance text-ui-muted">
        When a user joins, a marker is created. isScrollAnchor on the marker marks it as the next turn.
      </div>
    </div>
  );
}

Animation

Animate the entrance of user turns with transform and opacity presets while keeping the measured row predictable for anchoring.

Animation
Choose how user messages are animated when they are added to the conversation.
No Messages Yet

Click the button below to send the first message.

Select an animation then click send to see it in action.
197 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@codefast/ui/card";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@codefast/ui/empty";
import { cn } from "@codefast/ui/lib/utils";
import { Message, MessageContent } from "@codefast/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@codefast/ui/message-scroller";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@codefast/ui/select";
import { ArrowUpIcon, MessageCircleDashedIcon, RotateCwIcon } from "lucide-react";
import * as React from "react";

type AnimationId = "fade" | "pop" | "tilt";

interface AnimationPreset {
  id: AnimationId;
  name: string;
}

interface ChatMessage {
  id: string;
  role: "user" | "assistant";
  text: string;
}

const PRESETS: Array<AnimationPreset> = [
  { id: "fade", name: "Fade" },
  { id: "pop", name: "Pop" },
  { id: "tilt", name: "Tilt" },
];

const SCRIPTED_MESSAGES: Array<ChatMessage> = [
  {
    id: "animation-1-user",
    role: "user",
    text: "Can user messages pop in like iMessage without breaking anchoring?",
  },
  {
    id: "animation-1-assistant",
    role: "assistant",
    text: "Yes. Animate the user row with transform and opacity, and let the assistant response settle normally below it. That keeps the row measurement predictable while still giving the newly sent bubble a more tactile entrance.",
  },
  {
    id: "animation-2-user",
    role: "user",
    text: "What makes the animation feel more like iMessage?",
  },
  {
    id: "animation-2-assistant",
    role: "assistant",
    text: "Use a quick spring from the trailing edge: a little scale, a small upward move, and no layout animation. The bubble feels tactile, but the measured row stays predictable, so anchoring and auto-scroll do not have to fight a changing layout.",
  },
  {
    id: "animation-3-user",
    role: "user",
    text: "Can I switch between presets while testing the same thread?",
  },
  {
    id: "animation-3-assistant",
    role: "assistant",
    text: "Yes. Keep the conversation in place while you change the preset, then send the next message to compare the new entrance against the same context.",
  },
];

function getAnimationClassName(preset: AnimationId): string {
  if (preset === "pop") {
    return "animate-in fade-in zoom-in-95 slide-in-from-bottom-2 duration-300 ease-out";
  }

  if (preset === "tilt") {
    return "animate-in fade-in slide-in-from-bottom-4 spin-in-1 duration-500 ease-out";
  }

  return "animate-in fade-in duration-500 ease-out";
}

export function MessageScrollerAnimation() {
  const [presetId, setPresetId] = React.useState<AnimationId>("fade");
  const [count, setCount] = React.useState(0);
  const messages = SCRIPTED_MESSAGES.slice(0, count);
  const nextMessage = SCRIPTED_MESSAGES[count];
  const preset = PRESETS.find((item) => item.id === presetId) ?? PRESETS[0]!;

  return (
    <div className="relative flex flex-col gap-4">
      <Card className="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader className="border-b">
          <CardTitle>Animation</CardTitle>
          <CardDescription>
            Choose how user messages are animated when they are added to the conversation.
          </CardDescription>
          <CardAction className="flex items-center gap-2">
            <Button
              aria-label="Reset animated messages"
              disabled={count === 0}
              size="icon"
              type="button"
              variant="outline"
              onClick={() => {
                setCount(0);
              }}
            >
              <RotateCwIcon />
            </Button>
          </CardAction>
        </CardHeader>
        <CardContent className="min-h-0 flex-1 overflow-hidden p-0">
          {messages.length === 0 ? (
            <Empty className="h-full">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <MessageCircleDashedIcon />
                </EmptyMedia>
                <EmptyTitle>No Messages Yet</EmptyTitle>
                <EmptyDescription>Click the button below to send the first message.</EmptyDescription>
              </EmptyHeader>
            </Empty>
          ) : (
            <MessageScrollerProvider>
              <MessageScroller>
                <MessageScrollerViewport>
                  <MessageScrollerContent className="p-6">
                    {messages.map((message) => {
                      const isUser = message.role === "user";

                      return (
                        <MessageScrollerItem key={message.id} messageId={message.id} isScrollAnchor={isUser}>
                          <Message
                            align={isUser ? "end" : "start"}
                            className={cn(isUser && getAnimationClassName(presetId))}
                          >
                            <MessageContent>
                              <Bubble align={isUser ? "end" : "start"} variant={isUser ? "muted" : "ghost"}>
                                <BubbleContent>{message.text}</BubbleContent>
                              </Bubble>
                            </MessageContent>
                          </Message>
                        </MessageScrollerItem>
                      );
                    })}
                  </MessageScrollerContent>
                </MessageScrollerViewport>
                <MessageScrollerButton />
              </MessageScroller>
            </MessageScrollerProvider>
          )}
        </CardContent>
        <CardFooter className="border-t">
          <Select
            value={presetId}
            onValueChange={(value) => {
              setPresetId(value as AnimationId);
            }}
          >
            <SelectTrigger aria-label="Animation preset">
              <SelectValue>{preset.name}</SelectValue>
            </SelectTrigger>
            <SelectContent align="start" position="popper" side="top">
              <SelectGroup>
                {PRESETS.map((animation) => (
                  <SelectItem key={animation.id} value={animation.id}>
                    {animation.name}
                  </SelectItem>
                ))}
              </SelectGroup>
            </SelectContent>
          </Select>
          <Button
            className="ml-auto"
            disabled={!nextMessage}
            size="icon"
            type="button"
            onClick={() => {
              if (!nextMessage) {
                return;
              }

              setCount((value) => value + 1);
            }}
          >
            <ArrowUpIcon />
            <span className="sr-only">Send Message</span>
          </Button>
        </CardFooter>
      </Card>
      <div className="mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-ui-muted">
        Select an animation then click send to see it in action.
      </div>
    </div>
  );
}

Scrollable state

useMessageScrollerScrollable reports which edges the viewport can still scroll toward — drive custom affordances from it.

Scroll Status
Where the reader can go scroll to based on current scroll position.

Review scroll checkpoint 1.

Checkpoint 2 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 3.

Checkpoint 4 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 5.

Checkpoint 6 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 7.

Checkpoint 8 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 9.

Checkpoint 10 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

Review scroll checkpoint 11.

Checkpoint 12 is synced. The scrollable hook updates as the viewport moves.

When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.

At the latest message, the footer should switch again and only point them back up.

All messages fit in the viewport.
Scroll the transcript to see the footer update.
104 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@codefast/ui/card";
import { Message, MessageContent } from "@codefast/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScrollerScrollable,
} from "@codefast/ui/message-scroller";

interface Turn {
  id: string;
  role: "user" | "assistant";
  text: string;
}

const MESSAGES: Array<Turn> = Array.from({ length: 12 }, (_, index) => ({
  id: `scrollable-${String(index + 1)}`,
  role: index % 2 === 0 ? "user" : "assistant",
  text:
    index % 2 === 0
      ? `Review scroll checkpoint ${String(index + 1)}.`
      : `Checkpoint ${String(index + 1)} is synced. The scrollable hook updates as the viewport moves.\n\nWhen the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.\n\nAt the latest message, the footer should switch again and only point them back up.`,
}));

function getScrollStatus({ start, end }: { start: boolean; end: boolean }): string {
  if (start && end) {
    return "You can scroll both ways.";
  }

  if (end) {
    return "You are at the top. You can only scroll down.";
  }

  if (start) {
    return "You are at the bottom. You can only scroll up.";
  }

  return "All messages fit in the viewport.";
}

function ScrollStateFooter() {
  const { start, end } = useMessageScrollerScrollable();

  return (
    <CardFooter className="justify-center border-t text-center text-sm text-ui-muted">
      {getScrollStatus({ start, end })}
    </CardFooter>
  );
}

export function MessageScrollerScrollable() {
  return (
    <div className="mx-auto flex w-full max-w-sm flex-col gap-4">
      <Card className="h-140 w-full gap-0 overflow-hidden">
        <CardHeader className="gap-1 border-b">
          <CardTitle>Scroll Status</CardTitle>
          <CardDescription>Where the reader can go scroll to based on current scroll position.</CardDescription>
        </CardHeader>
        <MessageScrollerProvider defaultScrollPosition="start">
          <CardContent className="flex-1 overflow-hidden p-0">
            <MessageScroller>
              <MessageScrollerViewport>
                <MessageScrollerContent className="gap-4 p-6">
                  {MESSAGES.map((message) => {
                    const isUser = message.role === "user";

                    return (
                      <MessageScrollerItem key={message.id} messageId={message.id} isScrollAnchor={isUser}>
                        <Message align={isUser ? "end" : "start"}>
                          <MessageContent>
                            <Bubble align={isUser ? "end" : "start"} variant={isUser ? "muted" : "ghost"}>
                              <BubbleContent className="space-y-2">
                                {message.text
                                  .split(/\n\s*\n/)
                                  .map((paragraph) => paragraph.trim())
                                  .filter(Boolean)
                                  .map((paragraph) => (
                                    <p key={paragraph} className="whitespace-pre-wrap">
                                      {paragraph}
                                    </p>
                                  ))}
                              </BubbleContent>
                            </Bubble>
                          </MessageContent>
                        </Message>
                      </MessageScrollerItem>
                    );
                  })}
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </CardContent>
          <ScrollStateFooter />
        </MessageScrollerProvider>
      </Card>
      <div className="px-0.5 text-center text-xs text-ui-muted">Scroll the transcript to see the footer update.</div>
    </div>
  );
}

Imperative commands

useMessageScroller() exposes scrollToMessage/scrollToEnd/scrollToStart — here a 'Jump to' menu drives the transcript from outside.

Commands
Drive the transcript from outside.

We're seeing activation dip after workspace creation. Can you help me find the likely step?

The sharpest drop is between creating the workspace and inviting the first teammate.

Workspace creation is still healthy, but the invite step is where users pause. That suggests the product is asking for collaboration before the user has enough confidence in the workspace.

What should I compare before we change the onboarding flow?

Compare three cohorts:

1. Users who choose a template before inviting teammates. 2. Users who start from a blank workspace. 3. Users who skip invites and return within 24 hours.

If template users invite faster, the fix is probably better first-run guidance rather than a louder invite prompt.

Can you turn that into an experiment?

Yes. Create a variant that shows a short checklist after workspace creation:

- Pick a template. - Add one project detail. - Invite a teammate when the workspace has context.

Measure first invite completion, 24-hour return rate, and whether teams create a second project.

What's the risk if we delay the invite prompt?

The main risk is reducing team creation for accounts that already know who they want to invite.

To protect that path, keep the invite action visible in the header and only change the primary empty-state guidance. That gives confident teams a direct route without forcing uncertain users through the invite step too early.

Use the controls to jump to any message in the conversation.
157 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@codefast/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from "@codefast/ui/dropdown-menu";
import { Message, MessageContent } from "@codefast/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@codefast/ui/message-scroller";

interface Turn {
  id: string;
  role: "user" | "assistant";
  text: string;
}

const TRANSCRIPT: Array<Turn> = [
  {
    id: "command-activation",
    role: "user",
    text: "We're seeing activation dip after workspace creation. Can you help me find the likely step?",
  },
  {
    id: "command-activation-a",
    role: "assistant",
    text: "The sharpest drop is between creating the workspace and inviting the first teammate.\n\nWorkspace creation is still healthy, but the invite step is where users pause. That suggests the product is asking for collaboration before the user has enough confidence in the workspace.",
  },
  {
    id: "command-compare",
    role: "user",
    text: "What should I compare before we change the onboarding flow?",
  },
  {
    id: "command-compare-a",
    role: "assistant",
    text: "Compare three cohorts:\n\n1. Users who choose a template before inviting teammates.\n2. Users who start from a blank workspace.\n3. Users who skip invites and return within 24 hours.\n\nIf template users invite faster, the fix is probably better first-run guidance rather than a louder invite prompt.",
  },
  {
    id: "command-experiment",
    role: "user",
    text: "Can you turn that into an experiment?",
  },
  {
    id: "command-experiment-a",
    role: "assistant",
    text: "Yes. Create a variant that shows a short checklist after workspace creation:\n\n- Pick a template.\n- Add one project detail.\n- Invite a teammate when the workspace has context.\n\nMeasure first invite completion, 24-hour return rate, and whether teams create a second project.",
  },
  {
    id: "command-risk",
    role: "user",
    text: "What's the risk if we delay the invite prompt?",
  },
  {
    id: "command-risk-a",
    role: "assistant",
    text: "The main risk is reducing team creation for accounts that already know who they want to invite.\n\nTo protect that path, keep the invite action visible in the header and only change the primary empty-state guidance. That gives confident teams a direct route without forcing uncertain users through the invite step too early.",
  },
];

const USER_TURNS = TRANSCRIPT.filter((turn) => turn.role === "user");

function trimText(text: string): string {
  return text.length > 42 ? `${text.slice(0, 39)}...` : text;
}

function CommandMenu() {
  const { scrollToMessage } = useMessageScroller();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button type="button" variant="secondary">
          Jump to...
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-64" side="bottom">
        <DropdownMenuLabel>Conversations</DropdownMenuLabel>
        {USER_TURNS.map((turn) => (
          <DropdownMenuItem
            key={turn.id}
            onSelect={() => {
              scrollToMessage(turn.id, { align: "start", behavior: "smooth" });
            }}
          >
            <span className="line-clamp-1 min-w-0">{trimText(turn.text)}</span>
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

export function MessageScrollerCommands() {
  return (
    <MessageScrollerProvider defaultScrollPosition="end">
      <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>Commands</CardTitle>
            <CardDescription>Drive the transcript from outside.</CardDescription>
            <CardAction>
              <CommandMenu />
            </CardAction>
          </CardHeader>
          <CardContent className="flex-1 overflow-hidden p-0">
            <MessageScroller>
              <MessageScrollerViewport>
                <MessageScrollerContent className="p-6">
                  {TRANSCRIPT.map((turn) => {
                    const isUser = turn.role === "user";

                    return (
                      <MessageScrollerItem key={turn.id} messageId={turn.id} isScrollAnchor={isUser}>
                        <Message align={isUser ? "end" : "start"}>
                          <MessageContent>
                            <Bubble align={isUser ? "end" : "start"} variant={isUser ? "muted" : "ghost"}>
                              <BubbleContent className="space-y-2">
                                {turn.text
                                  .split(/\n\s*\n/)
                                  .map((paragraph) => paragraph.trim())
                                  .filter(Boolean)
                                  .map((paragraph) => (
                                    <p key={paragraph} className="whitespace-pre-wrap">
                                      {paragraph}
                                    </p>
                                  ))}
                              </BubbleContent>
                            </Bubble>
                          </MessageContent>
                        </Message>
                      </MessageScrollerItem>
                    );
                  })}
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </CardContent>
        </Card>
        <div className="mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-ui-muted">
          Use the controls to jump to any message in the conversation.
        </div>
      </div>
    </MessageScrollerProvider>
  );
}

Visibility outline

useMessageScrollerVisibility surfaces the current anchored turn — this outline highlights and jumps to it as the reader scrolls.

Transcript Outline
Track the current anchored turn.

Review the incident handoff and tell me what to read first.

Start with the summary and the impact section. The regression affected the upload queue, but the recovery path completed for every queued job.

What was the customer impact?

Impact was limited to delayed processing.

No records were dropped, and the reconciliation worker confirmed each retry batch. Support saw confusion from two customers, but there were no checkout or billing errors.

What actions are open?

Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.

The alert should fire on sustained queue growth, not a single short spike.

Give me the follow-up checklist.

After that, compare the queue recovery graph with the deploy timeline so the handoff shows exactly when processing returned to baseline. That makes it easier for support and engineering to answer the same customer questions without re-reading the whole incident thread.

I would also add a short owner note beside each follow-up item. The checklist is small, but ownership keeps the retry-window decision, alert tuning, and support macro from drifting into separate follow-up conversations.

Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.

The alert should fire on sustained queue growth, not a single short spike.

Open the outline to jump between anchored turns as you read.
171 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@codefast/ui/card";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@codefast/ui/hover-card";
import { Message, MessageContent } from "@codefast/ui/message";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
  useMessageScrollerVisibility,
} from "@codefast/ui/message-scroller";

interface Turn {
  id: string;
  role: "user" | "assistant";
  text: string;
}

const TRANSCRIPT: Array<Turn> = [
  {
    id: "vis-brief",
    role: "user",
    text: "Review the incident handoff and tell me what to read first.",
  },
  {
    id: "vis-brief-a",
    role: "assistant",
    text: "Start with the summary and the impact section. The regression affected the upload queue, but the recovery path completed for every queued job.",
  },
  {
    id: "vis-impact",
    role: "user",
    text: "What was the customer impact?",
  },
  {
    id: "vis-impact-a",
    role: "assistant",
    text: "Impact was limited to delayed processing.\n\nNo records were dropped, and the reconciliation worker confirmed each retry batch. Support saw confusion from two customers, but there were no checkout or billing errors.",
  },
  {
    id: "vis-actions",
    role: "user",
    text: "What actions are open?",
  },
  {
    id: "vis-actions-a",
    role: "assistant",
    text: "Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.\n\nThe alert should fire on sustained queue growth, not a single short spike.",
  },
  {
    id: "vis-checklist",
    role: "user",
    text: "Give me the follow-up checklist.",
  },
  {
    id: "vis-checklist-a",
    role: "assistant",
    text: "After that, compare the queue recovery graph with the deploy timeline so the handoff shows exactly when processing returned to baseline. That makes it easier for support and engineering to answer the same customer questions without re-reading the whole incident thread.\n\nI would also add a short owner note beside each follow-up item. The checklist is small, but ownership keeps the retry-window decision, alert tuning, and support macro from drifting into separate follow-up conversations.\n\nKeep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.\n\nThe alert should fire on sustained queue growth, not a single short spike.",
  },
];

const USER_TURNS = TRANSCRIPT.filter((turn) => turn.role === "user");

function trimText(text: string): string {
  return text.length > 42 ? `${text.slice(0, 39)}...` : text;
}

function TranscriptOutline() {
  const { scrollToMessage } = useMessageScroller();
  const { currentAnchorId } = useMessageScrollerVisibility();

  return (
    <HoverCard closeDelay={0} openDelay={0}>
      <HoverCardTrigger asChild>
        <button
          aria-label="Open transcript outline"
          className="flex h-9 w-9 flex-col items-center justify-center gap-1 rounded-md transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
          type="button"
        >
          {USER_TURNS.map((turn) => (
            <span
              key={turn.id}
              className="h-0.5 w-4 rounded-full bg-muted-foreground/40 data-[current=true]:bg-foreground"
              data-current={turn.id === currentAnchorId}
            />
          ))}
        </button>
      </HoverCardTrigger>
      <HoverCardContent
        align="center"
        className="flex w-64 flex-col gap-1 rounded-2xl p-1"
        side="left"
        sideOffset={-28}
      >
        {USER_TURNS.map((turn) => (
          <button
            key={turn.id}
            aria-current={currentAnchorId === turn.id ? "location" : undefined}
            className="flex min-h-7 items-center rounded-xl px-2 py-1.5 text-left text-sm transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-current:bg-accent aria-current:text-accent-foreground"
            type="button"
            onClick={() => {
              scrollToMessage(turn.id, { align: "start", behavior: "smooth" });
            }}
          >
            <span className="line-clamp-1 min-w-0">{trimText(turn.text)}</span>
          </button>
        ))}
      </HoverCardContent>
    </HoverCard>
  );
}

export function MessageScrollerVisibility() {
  return (
    <MessageScrollerProvider scrollMargin={12}>
      <div className="relative flex flex-col gap-4">
        <div className="relative mx-auto w-full max-w-sm">
          <Card className="h-140 w-full gap-0">
            <CardHeader className="gap-1 border-b">
              <CardTitle>Transcript Outline</CardTitle>
              <CardDescription>Track the current anchored turn.</CardDescription>
            </CardHeader>
            <CardContent className="flex-1 overflow-hidden p-0">
              <MessageScroller>
                <MessageScrollerViewport>
                  <MessageScrollerContent className="p-6">
                    {TRANSCRIPT.map((turn) => {
                      const isUser = turn.role === "user";

                      return (
                        <MessageScrollerItem key={turn.id} messageId={turn.id} isScrollAnchor={isUser}>
                          <Message align={isUser ? "end" : "start"}>
                            <MessageContent>
                              <Bubble align={isUser ? "end" : "start"} variant={isUser ? "muted" : "ghost"}>
                                <BubbleContent className="space-y-2">
                                  {turn.text
                                    .split(/\n\s*\n/)
                                    .map((paragraph) => paragraph.trim())
                                    .filter(Boolean)
                                    .map((paragraph) => (
                                      <p key={paragraph} className="whitespace-pre-wrap">
                                        {paragraph}
                                      </p>
                                    ))}
                                </BubbleContent>
                              </Bubble>
                            </MessageContent>
                          </Message>
                        </MessageScrollerItem>
                      );
                    })}
                  </MessageScrollerContent>
                </MessageScrollerViewport>
                <MessageScrollerButton />
              </MessageScroller>
            </CardContent>
          </Card>
          <div className="absolute top-1/2 -right-12 -translate-y-1/2">
            <TranscriptOutline />
          </div>
        </div>
        <div className="mx-auto max-w-sm px-0.5 text-center text-xs text-ui-muted">
          Open the outline to jump between anchored turns as you read.
        </div>
      </div>
    </MessageScrollerProvider>
  );
}

Usage

The minimal import and composition — see Examples below for styled, real-world variants.

25 lines
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@codefast/ui/message-scroller";

export function MessageScrollerUsage() {
  return (
    <MessageScrollerProvider autoScroll defaultScrollPosition="end">
      <MessageScroller className="h-72 rounded-lg border">
        <MessageScrollerViewport>
          <MessageScrollerContent>
            {Array.from({ length: 10 }, (_, index) => (
              <MessageScrollerItem key={index}>Message {index + 1}</MessageScrollerItem>
            ))}
          </MessageScrollerContent>
        </MessageScrollerViewport>
        <MessageScrollerButton />
      </MessageScroller>
    </MessageScrollerProvider>
  );
}

Anatomy

How the parts nest — every slot the component exposes, in composition order.

MessageScrollerProvider
└── MessageScroller
├── MessageScrollerViewport
│ └── MessageScrollerContent
│ └── MessageScrollerItem
└── MessageScrollerButton

Features

  • autoScroll follows new content at the bottom only while the reader is already there — any deliberate scroll or wheel input releases follow-bottom so they're never yanked back down.
  • isScrollAnchor on a MessageScrollerItem marks a turn boundary that streaming or appended content anchors against, and that defaultScrollPosition="last-anchor" restores to.
  • preserveScrollOnPrepend (default true) keeps the first visible row stable when older history is prepended above it.
  • Exposes imperative commands (useMessageScroller()) and live state hooks (useMessageScrollerScrollable, useMessageScrollerVisibility) for building custom jump-to or outline UIs.

API reference

Props for each part of the component. All native element props are also forwarded.

MessageScrollerProvider

Owns scroll behavior and state; renders no DOM.

autoScrollboolean

Follow new content at the bottom while the viewport is already at the end.

defaultScrollPosition"start" | "end" | "last-anchor"

Opening position on the first non-empty render, applied once.

scrollEdgeThresholdnumber

Distance from an edge that still counts as at-top/at-bottom.

Default8

scrollPreviousItemPeeknumber

Extra top margin kept above a newly anchored row.

Default64

scrollMarginnumber

Default aligned-edge margin for commands.

Default0

MessageScrollerViewport

Scrollable viewport; owns native scroll events and prepend preservation.

preserveScrollOnPrependboolean

Keep the first visible row stable when content is prepended.

Defaulttrue

MessageScrollerItem

One transcript row.

messageIdstring

Stable id for scrollToMessage, visibility, and prepend preservation.

isScrollAnchorboolean

Marks a turn boundary that appended anchors and last-anchor restore use.

Defaultfalse

MessageScrollerButton

Scroll-to-edge control; hides itself when there is no overflow that way.

direction"start" | "end"

Transcript edge to scroll toward.

Default"end"

behaviorScrollBehavior

Native scroll behavior when clicked.

Default"smooth"

Hooks

Read state from within a provider.

useMessageScroller()context

Imperative scroll commands (scrollToEnd/Start/Message).

useMessageScrollerScrollable(){ start, end }

Which edges can still scroll.

useMessageScrollerVisibility(){ currentAnchorId, visibleMessageIds }

Anchored turn and the ids intersecting the viewport.

Accessibility

Built to be keyboard-navigable and screen-reader friendly out of the box.

KeyFunction
ArrowUpScroll the viewport up; releases follow-bottom.
ArrowDownScroll the viewport down.
PageUpScroll up by a viewport; releases follow-bottom.
PageDownScroll down by a viewport.
HomeJump to the start of the transcript.
EndJump to the end of the transcript.
  • Deliberate keyboard or wheel scrolling releases follow-bottom so the reader is never yanked to the latest message.
  • The viewport is a focusable scroll region; MessageScrollerButton is a real button with an accessible label per direction.
  • Give each MessageScrollerItem a stable messageId so restore and prepend preservation stay correct.

Guidelines

Conventions that keep usage consistent across an app.

Do

  • Mark each turn boundary with isScrollAnchor so streaming replies anchor at the reading line.
  • Compose the feed from Message and Bubble for consistent chat styling.

Don’t

  • Don’t set a fixed height on the viewport itself — size the MessageScroller frame and let the viewport fill it.
  • Don’t reuse messageId values across rows.

Explore further

Ready to integrate?

Follow the Getting Started guide to install @codefast/ui, or browse the full component gallery.