import {
Message,
MessageContent,
MessageStack,
} from "@/components/pandacoderz-ui/message";
import {
Thread,
ThreadContent,
ThreadScrollToBottom,
} from "@/components/pandacoderz-ui/thread";
const turns = Array.from({ length: 10 }, (_, i) => ({
from: i % 2 === 0 ? ("user" as const) : ("assistant" as const),
text:
i % 2 === 0
? `Question ${i / 2 + 1}: what does the thread component do?`
: "It keeps the newest message pinned to the bottom while content streams in, and shows a scroll-to-bottom button once you scroll away.",
}));
export default function ThreadDemo() {
return (
<div className="h-80 w-full max-w-xl overflow-hidden rounded-2xl border bg-card">
<Thread className="h-full">
<ThreadContent>
{turns.map((turn, i) => (
<Message key={i} from={turn.from}>
<MessageStack>
<MessageContent>{turn.text}</MessageContent>
</MessageStack>
</Message>
))}
</ThreadContent>
<ThreadScrollToBottom />
</Thread>
</div>
);
}Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/thread.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/thread.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/thread.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/thread.jsonInstall the dependencies:
npm install radix-ui use-stick-to-bottom @heroicons-animated/react motionCopy the source into your project:
"use client";
import * as React from "react";
import { Slot } from "radix-ui";
import { ArrowDownIcon } from "@heroicons-animated/react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import { cn } from "@/lib/utils";
type ThreadProps = React.ComponentProps<typeof StickToBottom>;
function Thread({
className,
resize = "smooth",
initial = "smooth",
...props
}: ThreadProps) {
return (
<StickToBottom
data-slot="thread"
className={cn("relative h-full w-full", className)}
resize={resize}
initial={initial}
{...props}
/>
);
}
type ThreadContentProps = React.ComponentProps<typeof StickToBottom.Content>;
function ThreadContent({ className, ...props }: ThreadContentProps) {
return (
<StickToBottom.Content
data-slot="thread-content"
className={cn("flex w-full flex-col gap-6 p-6", className)}
{...props}
/>
);
}
type ThreadScrollToBottomProps = React.ComponentProps<"button"> & {
asChild?: boolean;
};
function ThreadScrollToBottom({
asChild = false,
className,
children,
onClick,
...props
}: ThreadScrollToBottomProps) {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
if (isAtBottom) {
return null;
}
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="thread-scroll-to-bottom"
type={asChild ? undefined : "button"}
aria-label="Scroll to bottom"
className={cn(
!asChild &&
"absolute bottom-6 left-[50%] flex size-8 translate-x-[-50%] cursor-pointer items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-modal transition-all hover:text-foreground active:scale-95",
className,
)}
onClick={(event) => {
scrollToBottom();
onClick?.(event);
}}
{...props}
>
{children ?? <ArrowDownIcon size={16} className="flex" />}
</Comp>
);
}
export { Thread, ThreadContent, ThreadScrollToBottom };Usage
import {
Thread,
ThreadContent,
ThreadScrollToBottom,
} from "@/components/pandacoderz-ui/thread";<Thread className="h-[600px]">
<ThreadContent>
{messages.map((m) => (
<Message key={m.id} from={m.role}>…</Message>
))}
</ThreadContent>
<ThreadScrollToBottom />
</Thread>Thread must have a bounded height (or be inside a flex column with min-h-0) so the content can scroll.
API Reference
Thread
Wraps use-stick-to-bottom. Accepts its props; resize and initial default to "smooth".
ThreadScrollToBottom
Renders nothing while the viewport is already at the bottom. Accepts asChild to use your own button.