@cf/ui

@cf/message-scroller · beta

Message Scroller

The scrolling container for a chat thread, which follows new messages, anchors a turn to the top and offers a jump-to-end button.

Add a turn and the thread follows it. Scroll up and the jump-to-end button slides in.

What is @cf/ui?
The components connorforsyth.co runs on, as a shadcn registry.
Are the fonts included?
No, they’re licensed. Components inherit the page’s typeface.
Is it finished?
No. The unfinished parts are labelled, like this one, which is beta.

Styling over @shadcn/react/message-scroller, which does the scroll work. Items use content-visibility: auto with a 10rem intrinsic size, so long threads stay cheap to render, and the viewport hides itself while a scroll is pending so there's no jump on load.

MessageScrollerButton slides in from the edge when you're away from the end, and flips its arrow for direction="start". The chat panel passes autoScroll and a scrollMargin for the header to the provider. I've marked it beta because the primitive underneath is new and I'm the only user.

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

Props

PropTypeDefault
MessageScrollerButton variantButton variantsecondary
MessageScrollerButton sizeButton sizeicon-sm

Rules

The same rules ship in DESIGN.md and the MCP server, for the agents building with this.

  • must

    Nest MessageScrollerProvider > MessageScroller > MessageScrollerViewport > MessageScrollerContent > MessageScrollerItem, with MessageScrollerButton inside MessageScroller but outside the viewport.

    full-structure

  • must

    Give every MessageScrollerItem a stable messageId, including placeholder rows like a pending reply.

    item-per-message

  • should

    Set scrollAnchor only on the turn that should pin to the top when it arrives (the person's own message), and leave it false on everything else.

    anchor-sparingly

  • must

    Give MessageScroller a parent with a bounded height (for example flex-1 in a flex column), because it fills its container with size-full min-h-0.

    bounded-height

Install

bunx shadcn@latest add @cf/message-scroller

Needs the @cf registry in your components.json once. The theme and any sibling components come along automatically.

Source

message-scroller.tsxShow
"use client";
 
import { ArrowDownIcon } from "@phosphor-icons/react";
import { MessageScroller as MessageScrollerPrimitive } from "@shadcn/react/message-scroller";
import { cn } from "cn";
import type * as React from "react";
import { Button } from "./button";
 
function MessageScrollerProvider(
  props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>
) {
  return <MessageScrollerPrimitive.Provider {...props} />;
}
 
function MessageScroller({
  className,
  ...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
  return (
    <MessageScrollerPrimitive.Root
      className={cn(
        "group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
        className
      )}
      data-slot="message-scroller"
      {...props}
    />
  );
}
 
function MessageScrollerViewport({
  className,
  ...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
  return (
    <MessageScrollerPrimitive.Viewport
      className={cn(
        "scroll-fade-b scrollbar-thin scrollbar-gutter-stable data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain contain-content data-pending-scroll:invisible",
        className
      )}
      data-slot="message-scroller-viewport"
      {...props}
    />
  );
}
 
function MessageScrollerContent({
  className,
  ...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
  return (
    <MessageScrollerPrimitive.Content
      className={cn("flex h-max min-h-full flex-col gap-6", className)}
      data-slot="message-scroller-content"
      {...props}
    />
  );
}
 
function MessageScrollerItem({
  className,
  scrollAnchor = false,
  ...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
  return (
    <MessageScrollerPrimitive.Item
      className={cn(
        "min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
        className
      )}
      data-slot="message-scroller-item"
      scrollAnchor={scrollAnchor}
      {...props}
    />
  );
}
 
function MessageScrollerButton({
  direction = "end",
  className,
  children,
  render,
  variant = "secondary",
  size = "icon-sm",
  ...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
  Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
  return (
    <MessageScrollerPrimitive.Button
      className={cn(
        "absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:data-[active=false]:-translate-y-full data-[active=false]:pointer-events-none data-[direction=start]:top-4 data-[direction=end]:bottom-4 data-[active=true]:translate-y-0 data-[active=false]:scale-95 data-[active=true]:scale-100 data-[active=false]:opacity-0 data-[active=true]:opacity-100 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
        className
      )}
      data-direction={direction}
      data-size={size}
      data-slot="message-scroller-button"
      data-variant={variant}
      direction={direction}
      render={render ?? <Button size={size} variant={variant} />}
      {...props}
    >
      {children ?? (
        <>
          <ArrowDownIcon />
          <span className="sr-only">
            {direction === "end" ? "Scroll to end" : "Scroll to start"}
          </span>
        </>
      )}
    </MessageScrollerPrimitive.Button>
  );
}
 
export {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
};