Components
Message Scroller
A conversation's scroll area that stays on the newest message and offers the white Jump to latest key when the reader scrolls up.
npx -y github:anwarlemu/opencommerce-brand-cli#release add message-scroller --surface siteRun from the target React 19.2+ app root with Tailwind CSS 4 and the @/* source alias. Source and examples as JSON.
Usage
Use to hold a conversation that grows: MessageScroller with MessageScrollerViewport, MessageScrollerContent and one MessageScrollerItem per message, plus MessageScrollerButton. Set scrollAnchor on the message just sent. Wrap the composer in MessageScrollerProvider when its send key needs useMessageScroller.
- Scrolling the page instead of the conversation
- Sliding the jump key in
- Auto-scrolling while the reader is reading older messages
Exports: MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility. This component includes a client boundary. Keep event handlers and state in a client component.
Dependencies: button, lucide-react.
Example
"use client";
import * as React from "react";
import { SendHorizontal } from "lucide-react";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Marker, MarkerContent } from "@/components/ui/marker";
import { Message, MessageContent, MessageHeader } from "@/components/ui/message";
import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller } from "@/components/ui/message-scroller";
import { Window } from "@/components/ui/window";
type Entry = { id: number; from: "person" | "agent"; text: string };
const STORES = ["Amazon", "Target", "Best Buy", "Staples", "Wayfair"];
const START: Entry[] = Array.from({ length: 14 }, (_, i) => ({
id: i,
from: i % 3 === 2 ? "agent" : "person",
text: i % 3 === 2 ? `Checked ${STORES[i % STORES.length]}: in stock, ships tomorrow.` : `Can you price the standing desk at ${STORES[i % STORES.length]}?`,
}));
function Composer({ onSend }: { onSend: (text: string) => void }) {
const { scrollToEnd } = useMessageScroller();
const [text, setText] = React.useState("");
return (
<form
className="flex items-center gap-2 border-t border-line p-3"
onSubmit={(event) => {
event.preventDefault();
if (!text.trim()) return;
onSend(text.trim());
setText("");
scrollToEnd({ behavior: "auto" });
}}
>
<Input aria-label="Message" value={text} onChange={(e) => setText(e.target.value)} placeholder="Ask your agent to buy something" />
<Button type="submit" size="icon" className="size-10" aria-label="Send">
<SendHorizontal strokeWidth={1.75} />
</Button>
</form>
);
}
export function Site() {
const [entries, setEntries] = React.useState(START);
return (
<Window name="agent.chat" className="mx-auto h-110 w-full max-w-xl" bodyClassName="flex min-h-0 flex-col">
<MessageScrollerProvider>
<MessageScroller className="min-h-0 flex-1">
<MessageScrollerViewport>
<MessageScrollerContent className="gap-4">
<Marker variant="separator">
<MarkerContent>Yesterday</MarkerContent>
</Marker>
{entries.map((entry) => (
<MessageScrollerItem key={entry.id}>
<Message align={entry.from === "agent" ? "end" : "start"}>
<MessageContent>
<MessageHeader>{entry.from === "agent" ? "Shopping agent" : "Jordan Miles"}</MessageHeader>
<Bubble variant={entry.from === "agent" ? "default" : "secondary"} align={entry.from === "agent" ? "end" : "start"}>
<BubbleContent>{entry.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
<Composer onSend={(text) => setEntries((all) => [...all, { id: all.length, from: "person", text }])} />
</MessageScrollerProvider>
</Window>
);
}
export function Streaming() {
const [count, setCount] = React.useState(3);
React.useEffect(() => {
if (count >= 12) return;
// ds-latency: simulates order updates arriving in this example; scrolling and input remain immediate.
const t = setTimeout(() => setCount((c) => c + 1), 900);
return () => clearTimeout(t);
}, [count]);
return (
<Window name="orders.live" className="mx-auto h-80 w-full max-w-xl" bodyClassName="flex min-h-0 flex-col">
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent className="gap-2">
{Array.from({ length: count }, (_, i) => (
<MessageScrollerItem key={i}>
<Bubble variant="muted">
<BubbleContent>{`Update ${i + 1}: order #A-${10293 + i} moved to ${i % 2 ? "shipped" : "confirmed"}.`}</BubbleContent>
</Bubble>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton direction="start" />
<MessageScrollerButton />
</MessageScroller>
</Window>
);
}Source
"use client";
import * as React from "react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
// A conversation's scroll area. It opens at the newest message and stays pinned there while new messages arrive, until
// the reader scrolls up; then the white key (the site's secondary key on the hairline, 34px, with the floating drop)
// says "Jump to latest" at the bottom center. It only fades (200ms, the strong ease-out): it shows while someone is
// reading, and sliding would pull the eye off the text. The jump scrolls smoothly, or instantly with reduced motion.
type ScrollOptions = { behavior?: ScrollBehavior };
type ScrollerState = {
isAtStart: boolean;
isAtEnd: boolean;
scrollable: boolean;
viewport: HTMLDivElement | null;
setViewport: (el: HTMLDivElement | null) => void;
setContent: (el: HTMLDivElement | null) => void;
update: () => void;
scrollToEnd: (options?: ScrollOptions) => void;
scrollToStart: (options?: ScrollOptions) => void;
};
const ScrollerContext = React.createContext<ScrollerState | null>(null);
// How close to the end still counts as reading the newest message.
const END_SLACK = 24;
function smoothUnlessReduced(): ScrollBehavior {
return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
}
/** Holds a scroller's state, so controls outside the scroll area (a composer's send button) can reach it. */
function MessageScrollerProvider({ children }: { children: React.ReactNode }) {
const [viewport, setViewport] = React.useState<HTMLDivElement | null>(null);
const [content, setContent] = React.useState<HTMLDivElement | null>(null);
const [edges, setEdges] = React.useState({ isAtStart: true, isAtEnd: true, scrollable: false });
// Pinned while the reader is at the newest message; new content then keeps the view at the end.
const pinned = React.useRef(true);
const update = React.useCallback(() => {
if (!viewport) return;
const { scrollTop, scrollHeight, clientHeight } = viewport;
const isAtEnd = scrollHeight - scrollTop - clientHeight <= END_SLACK;
const next = { isAtStart: scrollTop <= 1, isAtEnd, scrollable: scrollHeight > clientHeight + 1 };
pinned.current = isAtEnd;
setEdges((prev) => (prev.isAtStart === next.isAtStart && prev.isAtEnd === next.isAtEnd && prev.scrollable === next.scrollable ? prev : next));
}, [viewport]);
const scrollToEnd = React.useCallback(
(options?: ScrollOptions) => {
if (!viewport) return;
pinned.current = true;
viewport.scrollTo({ top: viewport.scrollHeight, behavior: options?.behavior ?? smoothUnlessReduced() });
},
[viewport],
);
const scrollToStart = React.useCallback(
(options?: ScrollOptions) => {
if (!viewport) return;
pinned.current = false;
viewport.scrollTo({ top: 0, behavior: options?.behavior ?? smoothUnlessReduced() });
},
[viewport],
);
// Open at the newest message.
React.useLayoutEffect(() => {
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight;
update();
}, [viewport, update]);
// Keep the newest message in view as content grows, unless the reader has scrolled away.
React.useEffect(() => {
if (!viewport || !content) return;
const observer = new ResizeObserver(() => {
if (pinned.current) viewport.scrollTop = viewport.scrollHeight;
update();
});
observer.observe(content);
observer.observe(viewport);
return () => observer.disconnect();
}, [viewport, content, update]);
const value = React.useMemo<ScrollerState>(() => ({ ...edges, viewport, setViewport, setContent, update, scrollToEnd, scrollToStart }), [edges, viewport, update, scrollToEnd, scrollToStart]);
return <ScrollerContext.Provider value={value}>{children}</ScrollerContext.Provider>;
}
function useScrollerContext(part: string) {
const context = React.useContext(ScrollerContext);
if (!context) throw new Error(`${part} must be used inside MessageScroller.`);
return context;
}
/** The scroller's position and controls: whether it is at the start or the end, and functions to jump there. */
function useMessageScroller() {
const { isAtStart, isAtEnd, scrollToEnd, scrollToStart } = useScrollerContext("useMessageScroller");
return { isAtStart, isAtEnd, scrollToEnd, scrollToStart };
}
/** Whether there is more to scroll to in each direction. */
function useMessageScrollerScrollable() {
const { isAtStart, isAtEnd, scrollable } = useScrollerContext("useMessageScrollerScrollable");
return { scrollable, canScrollStart: scrollable && !isAtStart, canScrollEnd: scrollable && !isAtEnd };
}
/** Whether an element (an unread marker, a message) is in view inside the scroller. */
function useMessageScrollerVisibility(ref: React.RefObject<Element | null>, threshold = 0.5) {
const { viewport } = useScrollerContext("useMessageScrollerVisibility");
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!viewport || !el) return;
const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { root: viewport, threshold });
observer.observe(el);
return () => observer.disconnect();
}, [viewport, ref, threshold]);
return visible;
}
function ScrollerRoot({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="message-scroller" className={cn("group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden", className)} {...props} />;
}
function MessageScroller(props: React.ComponentProps<"div">) {
const parent = React.useContext(ScrollerContext);
if (parent) return <ScrollerRoot {...props} />;
return (
<MessageScrollerProvider>
<ScrollerRoot {...props} />
</MessageScrollerProvider>
);
}
function MessageScrollerViewport({ className, onScroll, ...props }: React.ComponentProps<"div">) {
const { setViewport, update } = useScrollerContext("MessageScrollerViewport");
return (
<div
ref={setViewport}
data-slot="message-scroller-viewport"
tabIndex={0}
role="log"
aria-live="polite"
onScroll={(event) => {
update();
onScroll?.(event);
}}
className={cn("size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain outline-none [scrollbar-gutter:stable] [scrollbar-width:thin] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-focus-ring", className)}
{...props}
/>
);
}
function MessageScrollerContent({ className, ...props }: React.ComponentProps<"div">) {
const { setContent } = useScrollerContext("MessageScrollerContent");
return <div ref={setContent} data-slot="message-scroller-content" className={cn("flex h-max min-h-full flex-col justify-end gap-6 p-4", className)} {...props} />;
}
/**
* One entry of the conversation. `scrollAnchor` brings it to the top of the view when it mounts (the message just
* sent, so its reply grows below it).
*/
function MessageScrollerItem({ className, scrollAnchor = false, ...props }: React.ComponentProps<"div"> & { scrollAnchor?: boolean }) {
const { viewport } = useScrollerContext("MessageScrollerItem");
const ref = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => {
const el = ref.current;
if (!scrollAnchor || !viewport || !el) return;
viewport.scrollTop += el.getBoundingClientRect().top - viewport.getBoundingClientRect().top;
}, [scrollAnchor, viewport]);
return <div ref={ref} data-slot="message-scroller-item" data-scroll-anchor={scrollAnchor || undefined} className={cn("min-w-0 shrink-0", className)} {...props} />;
}
function MessageScrollerButton({ direction = "end", className, children, variant = "secondary", size = "sm", onClick, ...props }: React.ComponentProps<typeof Button> & { direction?: "start" | "end" }) {
const { isAtStart, isAtEnd, scrollable, scrollToEnd, scrollToStart } = useScrollerContext("MessageScrollerButton");
const active = scrollable && (direction === "end" ? !isAtEnd : !isAtStart);
return (
<Button
data-slot="message-scroller-button"
data-direction={direction}
data-active={active}
aria-hidden={!active || undefined}
tabIndex={active ? undefined : -1}
variant={variant}
size={size}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) (direction === "end" ? scrollToEnd : scrollToStart)();
}}
className={cn(
"absolute left-1/2 z-10 -translate-x-1/2 gap-1.5 shadow-floating transition-[opacity,background-color,scale] duration-200 ease-out data-[active=false]:pointer-events-none data-[active=false]:opacity-0 data-[direction=end]:bottom-4 data-[direction=start]:top-4 motion-reduce:transition-[opacity,background-color] [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
>
{children ?? (
<>
{direction === "end" ? <ArrowDown strokeWidth={1.75} /> : <ArrowUp strokeWidth={1.75} />}
{direction === "end" ? "Jump to latest" : "Jump to first"}
</>
)}
</Button>
);
}
export {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
};Reference: Extrapolated from opencommerce (the secondary key and floating drop).