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.
npx shadcn@latest add @paragon/chat-container"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>
);
}