Skip to contentVibraUI
Data display

Message scroller

The scrolling pane of a conversation: it opens at the latest message and keeps it in view as more arrive.

Vibra's jump-to-latest button slides in and out on the motion tokens rather than a fixed 200ms and 400ms, and scrolls smoothly only while motion is allowed — asked for less, by the OS setting or a [data-motion="reduced"] above, it takes the reader there at once. Underneath is @shadcn/react's scroller: it opens at the newest message and, with autoScroll, keeps following new ones while the reader stays at the end. Give the root a height; Bubble and Message draw what goes inside it.

Install

npx shadcn@latest add @vibra/message-scroller

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
MessageScrollerProvider.defaultScrollPosition"start" | "end" | "last-anchor""end"Where the pane opens — at the newest message, the oldest, or the last scroll anchor.
MessageScrollerProvider.autoScrollbooleanfalseFollows new messages while the reader is at the end, and lets go once they scroll up.
MessageScrollerItem.messageIdstring—Identifies the message, for scrollToMessage and the visibility hooks.
MessageScrollerButton.direction"start" | "end""end"Which end the button scrolls to; it shows only while that end is out of view.
MessageScrollerButton.behaviorScrollBehavior"smooth", or "auto" under reduced motionHow the button scrolls; a value passed here is used whatever the reader asked for.
useMessageScroller() => { scrollToEnd; scrollToStart; scrollToMessage }—Scrolls the pane from code — after sending, say.

Dependencies

Source

components/ui/message-scroller.tsx
"use client"

import * as React from "react"
import {
  MessageScroller as MessageScrollerPrimitive,
  useMessageScroller,
  useMessageScrollerScrollable,
  useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
import { cn } from "@/lib/utils"

import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { ArrowDownIcon } from "lucide-react"

function MessageScrollerProvider(
  props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>
) {
  return <MessageScrollerPrimitive.Provider {...props} />
}

function MessageScroller({
  className,
  ...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
  return (
    <MessageScrollerPrimitive.Root
      data-slot="message-scroller"
      className={cn(
        "group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
        className
      )}
      {...props}
    />
  )
}

function MessageScrollerViewport({
  className,
  ...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
  return (
    <MessageScrollerPrimitive.Viewport
      data-slot="message-scroller-viewport"
      className={cn(
        "size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible",
        className
      )}
      {...props}
    />
  )
}

function MessageScrollerContent({
  className,
  ...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
  return (
    <MessageScrollerPrimitive.Content
      data-slot="message-scroller-content"
      className={cn("flex h-max min-h-full flex-col gap-6", className)}
      {...props}
    />
  )
}

function MessageScrollerItem({
  className,
  scrollAnchor = false,
  ...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
  return (
    <MessageScrollerPrimitive.Item
      data-slot="message-scroller-item"
      scrollAnchor={scrollAnchor}
      className={cn(
        "min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
        className
      )}
      {...props}
    />
  )
}

function MessageScrollerButton({
  direction = "end",
  behavior,
  className,
  children,
  render,
  variant = "secondary",
  size = "icon-sm",
  ...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
  Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
  const reducedMotion = useReducedMotion()
  return (
    <MessageScrollerPrimitive.Button
      data-slot="message-scroller-button"
      data-direction={direction}
      data-variant={variant}
      data-size={size}
      direction={direction}
      // A smooth scroll is motion the reader set off; asked for less, they are
      // taken there at once. A behaviour the caller chose is kept.
      behavior={behavior ?? (reducedMotion ? "auto" : "smooth")}
      className={cn(
        "absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-(--duration-base) hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-(--duration-slow) data-[active=false]:ease-(--ease-exit) data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-(--ease-emphasized) data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
        className
      )}
      render={render ?? <Button variant={variant} size={size} />}
      {...props}
    >
      {children ?? (
        <>
          <ArrowDownIcon
          />
          <span className="sr-only">
            {direction === "end" ? "Scroll to end" : "Scroll to start"}
          </span>
        </>
      )}
    </MessageScrollerPrimitive.Button>
  )
}

export {
  MessageScrollerProvider,
  MessageScroller,
  MessageScrollerViewport,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerButton,
  useMessageScroller,
  useMessageScrollerScrollable,
  useMessageScrollerVisibility,
}