Thread

A viewport for stacked Message turns (or any content) that sticks to the bottom as content grows—built on use-stick-to-bottom. Thread wraps the scroll root, ThreadContent wraps the scrolling column, and ThreadScrollToBottom shows a control when the user has scrolled away from the bottom.

I'm visiting Chicago for a long weekend—what's worth doing if I've never been?

Start with the lakefront and Millennium Park for an easy first day.

If the weather stays calm, a short architecture boat tour is a nice add-on. Weekend departures fill up, so book a day ahead if you care about the time slot.

I'm into food more than landmarks. Where should I eat?

Reserve one night for classic deep dish, then keep lunches casual in whatever neighborhood you're exploring.

Is public transit enough, or should I rent a car?

The L trains and buses cover most visitor routes without much hassle.

Rent a car only if you plan to leave the city every day. Downtown parking and hotel garages add up, so defaulting to transit usually saves money.

Any packing tip for April weather?

Pack layers and a light rain jacket; spring weather can swing between chilly mornings and warm afternoons.

Perfect. I'll draft a loose plan and see what fits.

Have a great trip!

"use client";

import {
  Message,
  MessageContent,
  MessageMarkdown,
  MessageStack,
} from "@/components/nexus-ui/message";
import {
  Thread,
  ThreadContent,
  ThreadScrollToBottom,
} from "@/components/nexus-ui/thread";

const turns = [
  {
    from: "user" as const,
    text: "I'm visiting Chicago for a long weekend—what's worth doing if I've never been?",
  },
  {
    from: "assistant" as const,
    text: "Start with the lakefront and Millennium Park for an easy first day.\n\nIf the weather stays calm, a short architecture boat tour is a nice add-on. Weekend departures fill up, so book a day ahead if you care about the time slot.",
  },
  {
    from: "user" as const,
    text: "I'm into food more than landmarks. Where should I eat?",
  },
  {
    from: "assistant" as const,
    text: "Reserve one night for classic **deep dish**, then keep lunches casual in whatever neighborhood you're exploring.",
  },
  {
    from: "user" as const,
    text: "Is public transit enough, or should I rent a car?",
  },
  {
    from: "assistant" as const,
    text: "The **L** trains and buses cover most visitor routes without much hassle.\n\nRent a car only if you plan to leave the city every day. Downtown parking and hotel garages add up, so defaulting to transit usually saves money.",
  },
  {
    from: "user" as const,
    text: "Any packing tip for April weather?",
  },
  {
    from: "assistant" as const,
    text: "Pack layers and a light rain jacket; spring weather can swing between chilly mornings and warm afternoons.",
  },
  {
    from: "user" as const,
    text: "Perfect. I'll draft a loose plan and see what fits.",
  },
  {
    from: "assistant" as const,
    text: "Have a great trip!",
  },
] as const;

const ThreadDefault = () => {
  return (
    <Thread>
      <ThreadContent>
        {turns.map((turn, i) => (
          <Message key={i} from={turn.from}>
            <MessageStack>
              <MessageContent>
                <MessageMarkdown>{turn.text}</MessageMarkdown>
              </MessageContent>
            </MessageStack>
          </Message>
        ))}
      </ThreadContent>
      <ThreadScrollToBottom />
    </Thread>
  );
};

export default ThreadDefault;

Installation

npx shadcn@latest add @nexus-ui/thread
pnpm dlx shadcn@latest add @nexus-ui/thread
yarn dlx shadcn@latest add @nexus-ui/thread
bunx shadcn@latest add @nexus-ui/thread

Copy and paste the following code into your project.

components/nexus-ui/thread.tsx
"use client";

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ArrowDown02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/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 w-full h-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 : "button";

  return (
    <Comp
      data-slot="thread-scroll-to-bottom"
      type={asChild ? undefined : "button"}
      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-secondary text-muted-foreground shadow-sm transition-all hover:bg-border active:scale-95",
        className,
      )}
      onClick={(event) => {
        scrollToBottom();
        onClick?.(event);
      }}
      {...props}
    >
      {children ?? (
        <HugeiconsIcon
          icon={ArrowDown02Icon}
          strokeWidth={2.0}
          className="size-4.5"
        />
      )}
    </Comp>
  );
}

export { Thread, ThreadContent, ThreadScrollToBottom };

Update the import paths to match your project setup.

Usage

import {
  Thread,
  ThreadContent,
  ThreadScrollToBottom,
} from "@/components/nexus-ui/thread";
<Thread className="h-[50vh]">
  <ThreadContent>{/* messages */}</ThreadContent>
  <ThreadScrollToBottom />
</Thread>

ThreadScrollToBottom must be rendered inside Thread so it can read useStickToBottomContext.

Vercel AI SDK Integration

Render useChat messages inside Thread by mapping the same messages array you would render with Message alone. Read each UIMessage parts array and join text parts for MessageMarkdown (streaming updates apply as the SDK appends or grows TextUIPart content).

See Prompt Input for a minimal POST /api/chat route with streamText and toUIMessageStreamResponse.

Install the AI SDK

npm install ai @ai-sdk/react @ai-sdk/openai

Create your chat API route

Use the same handler as in the Prompt Input docs: messages: await convertToModelMessages(messages) and return result.toUIMessageStreamResponse().

Map messages to Message inside Thread

Use isTextUIPart from ai so you only aggregate type: "text" segments. Skip system turns unless you surface them deliberately. Assistant messages can also include reasoning, tool, source, and other part types—extend this loop when you need those in the UI.

"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai";
import {
  Message,
  MessageStack,
  MessageContent,
  MessageMarkdown,
} from "@/components/nexus-ui/message";
import {
  Thread,
  ThreadContent,
  ThreadScrollToBottom,
} from "@/components/nexus-ui/thread";

function textFromMessage(message: UIMessage) {
  return message.parts.filter(isTextUIPart).map((p) => p.text).join("");
}

export default function ChatThread() {
  const { messages } = useChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  });

  return (
    <Thread className="h-[min(70vh,32rem)]">
      <ThreadContent className="items-stretch">
        {messages
          .filter((m) => m.role !== "system")
          .map((m) => (
            <Message key={m.id} from={m.role === "user" ? "user" : "assistant"}>
              <MessageStack>
                <MessageContent>
                  <MessageMarkdown>{textFromMessage(m)}</MessageMarkdown>
                </MessageContent>
              </MessageStack>
            </Message>
          ))}
      </ThreadContent>
      <ThreadScrollToBottom />
    </Thread>
  );
}

API Reference

Thread

Root scroll container wrapping StickToBottom. ThreadContent and ThreadScrollToBottom must live under Thread so the scrollable list and jump control share the same stick-to-bottom context.

Prop

Type

ThreadContent

Wraps the transcript—usually Message rows—so Thread can keep the viewport following the bottom as new content arrives (StickToBottom.Content).

Prop

Type

ThreadScrollToBottom

Optional control that appears when the user has scrolled away from the bottom, so they can jump back to the latest messages. Supports polymorphism via asChild.

Prop

Type