Chat

Message Scroller.

A chat scroll area that starts at the newest message, follows new messages and shows a jump to latest button when you scroll up.

hub-chat
deepstone
Morning. Deepstone came back online at 06:10.
Nice. Any players on yet?
Two. moss_builder and river_kay are at spawn.
Can you check on turtle T-03?
T-03 is mining the north tunnel, depth 41.
Fuel is at 38 percent, enough for about 900 moves.
Good. Queue a return trip when it drops under 20.
Done. I will ping you when it heads home.
Also, did anyone sign the guestbook overnight?
Three new entries. One asks for a map of the tunnels.
Pin that one, I will draw a map later.
Pinned to the top of guestbook.hub.

Installation#

With the CLI

$npx lanterncn add message-scroller

This also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/message-scroller. See Installation if your project is not set up yet.

Manually

  1. Install the dependencies

    npm install @shadcn/react lucide-react
  2. Copy the source into your project

    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 { ArrowDownIcon } from "lucide-react";
    
    import { cn } from "@/lib/utils";
    import { Button } from "@/components/ui/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
          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 overflow-y-auto overscroll-contain outline-none contain-content [scrollbar-color:var(--input)_transparent] [scrollbar-gutter:stable] [scrollbar-width:thin]",
            "focus-visible:ring-2 focus-visible:ring-ring/25 focus-visible:ring-inset",
            "data-autoscrolling:[scrollbar-width:none] 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",
      className,
      children,
      render,
      variant = "outline",
      size = "icon-sm",
      ...props
    }: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
      Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
      return (
        <MessageScrollerPrimitive.Button
          data-slot="message-scroller-button"
          data-direction={direction}
          data-variant={variant}
          data-size={size}
          direction={direction}
          className={cn(
            "absolute inset-s-1/2 z-20 -translate-x-1/2 border-input bg-popover text-foreground shadow-block-sm transition-[translate,scale,opacity] duration-200 hover:border-primary hover:bg-popover hover:text-primary rtl:translate-x-1/2",
            "data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-300 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)]",
            "data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)]",
            "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 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,
    };
  3. Update the import paths to match your project

    The source imports cn from @/lib/utils and uses button.

Usage#

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

Give the scroller a bounded height, for example a flex-1 child of a fixed height column.

With autoScroll it keeps following new messages while you are at the bottom and stops once you scroll up. Call scrollToEnd from useMessageScroller to jump down after sending.