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"use client";
import * as React from "react";
import { Cpu, SendHorizontal } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
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, MessageAvatar, MessageContent, MessageFooter } from "@/components/ui/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
} from "@/components/ui/message-scroller";
import { StatusDot } from "@/components/ui/status-dot";
type ChatMessage = { id: string; from: "me" | "hub"; text: string; time: string };
const seed: ChatMessage[] = [
["hub", "Morning. Deepstone came back online at 06:10."],
["me", "Nice. Any players on yet?"],
["hub", "Two. moss_builder and river_kay are at spawn."],
["me", "Can you check on turtle T-03?"],
["hub", "T-03 is mining the north tunnel, depth 41."],
["hub", "Fuel is at 38 percent, enough for about 900 moves."],
["me", "Good. Queue a return trip when it drops under 20."],
["hub", "Done. I will ping you when it heads home."],
["me", "Also, did anyone sign the guestbook overnight?"],
["hub", "Three new entries. One asks for a map of the tunnels."],
["me", "Pin that one, I will draw a map later."],
["hub", "Pinned to the top of guestbook.hub."],
].map(([from, text], index) => ({
id: `seed-${index}`,
from: from as ChatMessage["from"],
text,
time: `08:${String(10 + index * 3).padStart(2, "0")}`,
}));
const replies = [
"Copy that.",
"Logged it on the hub terminal.",
"T-03 says hello. It is still digging.",
"Queued. I will report back when it finishes.",
"The guestbook has one more visitor since you asked.",
];
function now() {
return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
function Chat() {
const [messages, setMessages] = React.useState<ChatMessage[]>(seed);
const [draft, setDraft] = React.useState("");
const [typing, setTyping] = React.useState(false);
const { scrollToEnd } = useMessageScroller();
const followRef = React.useRef(false);
const replyIndex = React.useRef(0);
const timeout = React.useRef<number | null>(null);
React.useEffect(() => {
if (followRef.current) {
followRef.current = false;
scrollToEnd({ behavior: "smooth" });
}
}, [messages, scrollToEnd]);
React.useEffect(() => () => {
if (timeout.current) window.clearTimeout(timeout.current);
}, []);
function send(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = draft.trim();
if (!text) return;
followRef.current = true;
setMessages((current) => [...current, { id: crypto.randomUUID(), from: "me", text, time: now() }]);
setDraft("");
setTyping(true);
if (timeout.current) window.clearTimeout(timeout.current);
timeout.current = window.setTimeout(() => {
const reply = replies[replyIndex.current++ % replies.length];
setTyping(false);
setMessages((current) => [...current, { id: crypto.randomUUID(), from: "hub", text: reply, time: now() }]);
}, 900);
}
return (
<>
<MessageScroller className="flex-1">
<MessageScrollerViewport aria-label="Hub chat messages">
<MessageScrollerContent className="gap-4 px-3 py-4 sm:px-4">
<MessageScrollerItem>
<Marker variant="separator" role="separator">
<MarkerContent>Today</MarkerContent>
</Marker>
</MessageScrollerItem>
{messages.map((message) => (
<MessageScrollerItem key={message.id} messageId={message.id}>
<Message align={message.from === "me" ? "end" : "start"}>
{message.from === "hub" && (
<MessageAvatar>
<Avatar>
<AvatarFallback>
<Cpu className="size-4" />
</AvatarFallback>
</Avatar>
</MessageAvatar>
)}
<MessageContent>
<Bubble variant={message.from === "me" ? "default" : "muted"}>
<BubbleContent>{message.text}</BubbleContent>
</Bubble>
<MessageFooter>
<time>{message.time}</time>
</MessageFooter>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
{typing && (
<MessageScrollerItem>
<Marker aria-live="polite" className="animate-lantern-pulse ps-11">
<MarkerContent>Hub terminal is typing</MarkerContent>
</Marker>
</MessageScrollerItem>
)}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
<form onSubmit={send} className="flex gap-2 border-t bg-card p-3">
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Message the hub"
aria-label="Message"
autoComplete="off"
/>
<Button type="submit" size="icon" aria-label="Send message" disabled={!draft.trim()}>
<SendHorizontal />
</Button>
</form>
</>
);
}
export default function MessageScrollerDemo() {
return (
<div className="flex h-[480px] w-full max-w-lg flex-col overflow-hidden rounded-lg border bg-background shadow-block-sm">
<div className="flex items-center justify-between gap-3 border-b bg-card px-4 py-3">
<div className="flex min-w-0 items-center gap-2.5">
<StatusDot tone="online" />
<span className="truncate font-display text-sm font-medium tracking-tight">hub-chat</span>
</div>
<span className="font-mono text-[10px] tracking-[0.2em] text-muted-foreground uppercase">deepstone</span>
</div>
<MessageScrollerProvider autoScroll>
<Chat />
</MessageScrollerProvider>
</div>
);
}Installation#
With the CLI
$npx lanterncn add message-scrollerThis 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
Install the dependencies
npm install @shadcn/react lucide-reactCopy 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, };Update the import paths to match your project
The source imports
cnfrom@/lib/utilsand 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.