AI & Chat

Chat Container

A streaming-aware scroll manager that pins to the bottom while content grows, unpins when the user scrolls up, and offers a jump-to-latest pill.

Install

npx shadcn@latest add @paragon/chat-container

chat-container.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";

/** Mirrors var(--ease-out). */
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Mirrors var(--ease-exit). */
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

export interface ChatContainerProps extends React.ComponentProps<"div"> {
  /** Distance from the bottom (px) still treated as pinned. */
  threshold?: number;
  /** Label on the jump-to-latest pill. */
  jumpLabel?: string;
  /** Disables the pill's enter/exit motion and smooth scrolling. */
  static?: boolean;
}

/**
 * Scroll manager for streaming conversations. Stays pinned to the bottom
 * while content grows (ResizeObserver on both the content and the scroller,
 * so it's resize-safe), unpins the moment the user scrolls up, and offers a
 * "jump to latest" pill that fades in while unpinned. Programmatic snaps
 * land exactly at the bottom, so they never trip the unpin detection.
 */
export function ChatContainer({
  threshold = 32,
  jumpLabel = "Jump to latest",
  static: isStatic = false,
  className,
  children,
  ...props
}: ChatContainerProps) {
  const reducedMotion = useReducedMotion();
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const contentRef = React.useRef<HTMLDivElement>(null);
  const stuckRef = React.useRef(true);
  const [stuck, setStuckState] = React.useState(true);

  const setStuck = React.useCallback((next: boolean) => {
    stuckRef.current = next;
    setStuckState(next);
  }, []);

  // Pin to the bottom on mount, before first paint.
  React.useLayoutEffect(() => {
    const el = scrollRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, []);

  // Re-pin whenever the content grows or the container resizes while stuck.
  React.useEffect(() => {
    const scroller = scrollRef.current;
    const content = contentRef.current;
    if (!scroller || !content || typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(() => {
      if (stuckRef.current) scroller.scrollTop = scroller.scrollHeight;
    });
    observer.observe(content);
    observer.observe(scroller);
    return () => observer.disconnect();
  }, []);

  const blur = reducedMotion || isStatic ? "blur(0px)" : "blur(4px)";
  const rise = reducedMotion || isStatic ? 0 : 8;

  return (
    <div
      data-slot="chat-container"
      className={cn("relative", className)}
      {...props}
    >
      <div
        ref={scrollRef}
        onScroll={(e) => {
          const el = e.currentTarget;
          const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
          const next = distance <= threshold;
          if (next !== stuckRef.current) setStuck(next);
        }}
        className="h-full overflow-y-auto overscroll-contain"
      >
        <div ref={contentRef}>{children}</div>
      </div>
      <AnimatePresence>
        {!stuck && (
          <motion.button
            key="jump-to-latest"
            type="button"
            onClick={() => {
              setStuck(true);
              scrollRef.current?.scrollTo({
                top: scrollRef.current.scrollHeight,
                behavior: reducedMotion || isStatic ? "auto" : "smooth",
              });
            }}
            initial={{ opacity: 0, y: rise, filter: blur }}
            animate={{
              opacity: 1,
              y: 0,
              filter: "blur(0px)",
              transition: { duration: 0.2, ease: EASE_OUT },
            }}
            exit={{
              opacity: 0,
              y: rise,
              filter: blur,
              transition: { duration: 0.12, ease: EASE_EXIT },
            }}
            style={{ x: "-50%" }}
            className={cn(
              "absolute bottom-3 left-1/2 flex h-8 items-center gap-1.5 rounded-full bg-card px-3 text-xs font-medium text-foreground shadow-overlay transition-[scale] duration-150 ease-out after:absolute after:inset-x-0 after:-inset-y-1.5",
              !isStatic && "active:scale-[0.97]",
            )}
          >
            {jumpLabel}
            <ArrowDown aria-hidden className="size-3.5" />
          </motion.button>
        )}
      </AnimatePresence>
    </div>
  );
}