---
title: Thread
description: The scroll container — at-bottom detection, auto-follow, docked-composer measurement, and prepend-aware restoration.
source: thread
---

```tsx title="primitives/thread/demos/basic.tsx"
"use client";

import { Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import { Message } from "@intentface/chat/message";
import { Thread, useThread } from "@intentface/chat/thread";
import { type ComponentProps, useState } from "react";

type DemoMessage = { id: string; role: "user" | "assistant"; text: string };

const INITIAL: DemoMessage[] = [
  { id: "1", role: "user", text: "What's the difference between useMemo and useCallback?" },
  {
    id: "2",
    role: "assistant",
    text: "useMemo caches a computed value; useCallback caches a function reference. In fact useCallback(fn, deps) is just useMemo(() => fn, deps).",
  },
  { id: "3", role: "user", text: "So when do I actually need useCallback?" },
  {
    id: "4",
    role: "assistant",
    text: "Mainly when you pass a callback to a memo-wrapped child or as another hook's dependency — a fresh function each render would break their memoization. Otherwise you usually don't.",
  },
];

const REPLY = "Good question — the short answer is it depends on what you're optimizing for.";

// Thread measures its docked composer and publishes the reserve as
// --thread-overlay-bottom-height, so the scroll area never hides behind it.
export const Basic = () => {
  const [messages, setMessages] = useState<DemoMessage[]>(INITIAL);

  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind !== "message" || !data.text.trim()) return;
    setMessages((current) => [
      ...current,
      { id: `${current.length}-u`, role: "user", text: data.text },
      { id: `${current.length}-a`, role: "assistant", text: REPLY },
    ]);
  };

  return (
    <div className="h-[440px] w-full max-w-xl overflow-hidden rounded-xl border border-[#f0f0f0] bg-white dark:border-[#262626] dark:bg-[#111111]">
      <Thread.Root className="relative flex h-full w-full overflow-hidden [--thread-overlay-top-height:1rem]">
        <Thread.Viewport className="h-full w-full overflow-x-hidden overflow-y-auto outline-none [overflow-anchor:auto]">
          <div className="relative flex min-h-full w-full flex-col items-center pt-(--thread-overlay-top-height) pb-(--thread-overlay-bottom-height)">
            {/* The last child carries the auto-scroll reserve the primitive sets. */}
            <Thread.Content className="mx-auto flex min-h-full w-full flex-col gap-4 px-4 [&>*:last-child]:min-h-(--thread-turn-min-height,0px)">
              {messages.map((message, index) => (
                <Message.Root
                  key={message.id}
                  role={message.role}
                  isLast={index === messages.length - 1}
                  className="group flex w-full flex-col gap-1 data-[role=user]:items-end"
                >
                  <Message.Text className="text-sm leading-[1.7] text-[#1a1a1a] group-data-[role=user]:min-h-9 group-data-[role=user]:max-w-[80%] group-data-[role=user]:rounded-2xl group-data-[role=user]:border group-data-[role=user]:border-[#f0f0f0] group-data-[role=user]:bg-white group-data-[role=user]:px-3 group-data-[role=user]:py-1.5 group-data-[role=user]:shadow-xs dark:text-[#fcfcfc] dark:group-data-[role=user]:border-[#262626] dark:group-data-[role=user]:bg-[#181818]">
                    {message.text}
                  </Message.Text>
                </Message.Root>
              ))}
            </Thread.Content>
          </div>
        </Thread.Viewport>
        <Thread.Composer className="absolute inset-x-0 bottom-0 z-2 w-full">
          <div className="relative flex w-full flex-col items-center px-4 pb-4">
            <ScrollButton />
            <Composer.Root onSubmit={handleSubmit} className="flex w-full flex-col">
              <Composer.Container className="cursor-text rounded-2xl border border-[#f0f0f0] bg-white shadow-xs transition-colors focus-within:border-[#ececec] dark:border-[#262626] dark:bg-[#181818] dark:focus-within:border-[#2d2d2d]">
                <Composer.Textarea className="max-h-32 min-h-8 overflow-y-auto px-4 pt-3 text-sm **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-[1.7] [&_[data-composer-editor]:focus]:outline-none">
                  <Composer.Placeholder
                    placeholder="Ask a follow-up…"
                    className="leading-[1.7] text-[#949494] dark:text-[#6f6f6f]"
                  />
                </Composer.Textarea>
                <Composer.Actions className="flex justify-end gap-2 p-2">
                  <Composer.Submit className="flex size-8 items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-opacity disabled:opacity-40 dark:bg-[#fcfcfc] dark:text-[#111111]">
                    <SendIcon />
                  </Composer.Submit>
                </Composer.Actions>
              </Composer.Container>
            </Composer.Root>
          </div>
        </Thread.Composer>
      </Thread.Root>
    </div>
  );
};

// useThread exposes the scroll state the viewport tracks; the button is yours.
const ScrollButton = () => {
  const { isAtBottom, scrollToBottom } = useThread();

  if (isAtBottom) return null;

  return (
    <button
      type="button"
      onClick={() => scrollToBottom()}
      aria-label="Scroll to latest"
      className="absolute -top-10 z-10 flex size-8 cursor-pointer items-center justify-center rounded-full border border-[#f0f0f0] bg-white text-[#686868] shadow-xs transition-colors hover:text-[#1a1a1a] dark:border-[#262626] dark:bg-[#181818] dark:text-[#9b9b9b] dark:hover:text-[#fcfcfc]"
    >
      <ArrowDownIcon />
    </button>
  );
};

const ArrowDownIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M8 3v10m0 0 4.5-4.5M8 13l-4.5-4.5" />
  </svg>
);

const SendIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M8 13V3m0 0L3.5 7.5M8 3l4.5 4.5" />
  </svg>
);
```

## Usage guidelines

- **Scroll surface** — lands the newest turn, follows the stream while you're at the bottom, and yields the moment you scroll up.
- **Auto-scroll modes** — `off` / `bottom` / `jump` / `follow` via the `autoScroll` prop (see below).
- **Composer inset** — measures the docked composer to reserve space; the overlays fade the top and bottom edges.
- **Owns no data** — you map your messages in; rows are addressable by a `data-message-id` attribute.
- **Get started** — see [Quick start](/quick-start) to add the package.

## Anatomy

The bare nesting — `Thread` provides the scroll context its parts read:

```tsx
<Thread.Root>
  <Thread.Viewport>{/* messages */}</Thread.Viewport>
  <Thread.Composer>{/* composer */}</Thread.Composer>
  <Thread.ScrollButton />
</Thread.Root>
```

A realistic surface with overlays and a message list:

```tsx
<Thread.Root autoScroll="follow">
  <Thread.Overlay direction="top" />
  <Thread.Viewport>
    {turns.map((turn) => (
      <Message.Turn key={turn.key} data-message-id={turn.id}>{/* … */}</Message.Turn>
    ))}
  </Thread.Viewport>
  <Thread.Composer>
    <Composer.Root onSubmit={sendMessage}>{/* … */}</Composer.Root>
  </Thread.Composer>
  <Thread.ScrollButton />
  <Thread.Overlay direction="bottom" />
</Thread.Root>
```

## Auto-scroll

`autoScroll` controls how the newest turn lands and whether the view follows a
stream. `Thread.Viewport` maps `--thread-turn-min-height` onto its last child,
so the reserve that lets the newest turn land at the top is wired for you.

export const autoScrollModes = [
  { value: '"follow"', default: true, description: "Newest lands at the top; the view follows the stream (ChatGPT-style)." },
  { value: '"bottom"', description: "Newest lands at the bottom; the view follows the stream (Codex-style)." },
  { value: '"jump"', description: "Newest lands at the top; the view does not follow." },
  { value: '"off"', description: "A plain scroll area — no landing, no follow, no reserve." },
];

<ValuesTable rows={autoScrollModes} />

### Opening position

Where a saved transcript opens is a consequence of the mode — there is no
separate `defaultScrollPosition` prop. `"bottom"` opens at the end; `"follow"`
and `"jump"` open with the newest turn's top at the reading line (the reserve
does this); `"off"` opens at the start. To deep-link into the middle of a
transcript, call `scrollToMessage` on mount — it queues until the rows exist
and overrides the landing.

The follow is released by deliberate upward reading intent and re-arms when you
return to the bottom (see [Keyboard](#keyboard)). Content growth alone never
releases it: a large code block landing at once won't drop the follow
mid-stream. Once you scroll up, the follow can't scroll again until you return
to the bottom.

## useThread

Read the scroll state and issue commands from anywhere inside `<Thread.Root>`:

export const useThreadMembers = [
  { name: "isAtTop", type: "boolean", description: "Whether the top sentinel is in view (start of the transcript) — pairs with load-older-history. Subscribes the caller; independent of isAtBottom." },
  { name: "isAtBottom", type: "boolean", description: "Whether the bottom sentinel is in view. Subscribes the caller — only components that read it re-render on flips." },
  { name: "scrollToBottom", type: "(behavior?) => void", description: "Scroll to the live end." },
  { name: "scrollToTop", type: "(behavior?) => void", description: "Scroll to the start; releases the follow." },
  { name: "scrollToMessage", type: "(id, options?) => boolean", description: "Scroll to a row carrying data-message-id={id}. Options: align (\"start\" | \"center\" | \"end\" | \"nearest\"), behavior. Called before the transcript loads (a deep link), the jump is queued and runs when the row mounts. Returns false only when the id is absent from a loaded transcript." },
];

<PropsTable rows={useThreadMembers} />

`scrollToMessage` resolves rows lazily by the `data-message-id` attribute — put
it on each row you want addressable; there is no wrapper component and no
per-row cost:

```tsx
{turns.map((turn) => (
  <Message.Turn key={turn.key} data-message-id={turn.id}>{/* … */}</Message.Turn>
))}
```

## useThreadVisibility

Track which rows are in view — e.g. to highlight the active turn in an outline.
Subscribing lazily creates the tracking observers; when the last subscriber
unmounts they are torn down, so threads that never call it pay nothing. Rows
are identified by the same `data-message-id` attribute `scrollToMessage` uses.

export const visibilityMembers = [
  { name: "visibleMessageIds", type: "string[]", description: "Rows intersecting the viewport, in document order." },
  { name: "currentMessageId", type: "string | null", description: "The topmost visible row — the one being read." },
];

<PropsTable rows={visibilityMembers} />

## Performance

Thread's scroll subsystem is built to cost nothing while a reply streams — you
don't need to optimize around it:

- **No scroll handler.** Edge detection is an IntersectionObserver sentinel per
  edge, computed off the main thread. Scrolling runs zero JavaScript.
- **Landing and follow are event-driven** — a MutationObserver for new turns, a
  ResizeObserver for growth, one `scrollTo` per change. No per-token geometry
  reads, no animation-frame polling.
- **Edge state lives in external stores** (one per edge), so a flip re-renders
  only the components that read it (your scroll button) — never the Thread tree.
- **Lazy capabilities stay free until used**: visibility tracking creates its
  observers on the first `useThreadVisibility` subscriber and tears them down
  with the last; the prepend-preservation scroll listener exists only when
  `preserveScrollOnPrepend` is set.

The boundary: Thread does not virtualize. Cost is O(rendered rows) of DOM, which
holds comfortably for realistic transcripts (hundreds to low thousands of
turns). What re-renders during a stream is decided by your message components —
see [Composer performance](/primitives/composer#performance).

## Keyboard

The viewport carries `tabIndex=0`, so keyboard users can Tab to it and scroll
with the usual keys. Scrolling is otherwise native — the thread intercepts only
the upward keys, `ArrowUp`, `PageUp` and `Home`, which release auto-follow.
Scrolling down never releases it: doing so at the bottom would leave the view
unfollowed while pinned there.

An upward wheel or a downward touch-drag releases follow the same way; a
scrollbar drag away from the bottom releases it via the sentinel.

## Accessibility

- **Viewport** is a focusable `role="region"` with a default
  `aria-label="Messages"` (overridable), so it's reachable and scrollable by
  keyboard.
- **Content column** is a `role="log"` with `aria-relevant="additions"`: a turn
  announces when its row is **added**. In-place text mutation — tokens
  streaming into an existing row — deliberately does not re-announce
  (token-by-token narration would be noise). If you want end-of-response
  announcements, add a consumer-owned `role="status"` region that flips on
  completion.
- **Reduced motion.** Programmatic scrolls requested as `"smooth"`
  (`scrollToBottom`, `scrollToTop`, `scrollToMessage`, and the auto-follow)
  downgrade to instant when the OS has `prefers-reduced-motion` set.

## API reference

Every part accepts `className`, `style`, and `render` (see
[Styling](/handbook/styling)) and emits a bespoke part attribute (`data-<part>`) unless noted.
Only part-specific props and state-driven attributes are listed below.

### Thread

The root: a positioned, overflow-clipped container that owns the scroll
subsystem and measures the composer dock. Renders `data-thread-root`.

export const rootProps = [
  { name: "autoScroll", type: '"off" | "bottom" | "jump" | "follow"', default: '"follow"', description: "Landing + follow behavior (see Auto-scroll)." },
  { name: "preserveScrollOnPrepend", type: "boolean", default: "false", description: "Hold the reading position when older rows load in above (history pagination). Opt-in — it attaches a passive scroll listener." },
];

<PropsTable rows={rootProps} />

export const rootAttrs = [
  { attribute: "data-thread-root", description: "The root element." },
  { attribute: "data-at-top", description: "Present while the top edge is in view (start of the transcript) — the CSS-only mirror of useThread().isAtTop." },
  { attribute: "data-at-bottom", description: "Present while the bottom edge is in view (at the live end) — the CSS-only mirror of useThread().isAtBottom." },
];

<AttributesTable rows={rootAttrs} />

### Thread.Overlay

A positioned fade strip at the top or bottom edge. The top overlay's height is
also the top inset the viewport reserves.

export const overlayProps = [
  { name: "direction", type: '"top" | "bottom"', default: "(required)", description: "Which edge the overlay marks." },
];

<PropsTable rows={overlayProps} />

export const overlayAttrs = [
  { attribute: "data-thread-overlay", values: '"top" | "bottom"', description: "Which edge this overlay marks — style the fade direction from it. The top one doubles as the top-inset measurement target." },
];

<AttributesTable rows={overlayAttrs} />

### Thread.Viewport

The scroll container plus the measured content column and the 1px edge sentinels
(top + bottom). Focusable so keyboard users can scroll it.

export const viewportAttrs = [
  { attribute: "data-thread-scroller", description: "The scroll container (role=region, tabIndex 0, aria-label \"Messages\")." },
  { attribute: "data-thread-content", description: "The content column (role=log, aria-relevant=\"additions\") where your messages render." },
  { attribute: "data-thread-top", description: "The 1px at-top sentinel the IntersectionObserver watches." },
  { attribute: "data-thread-bottom", description: "The 1px at-bottom sentinel the IntersectionObserver watches." },
];

<AttributesTable rows={viewportAttrs} />

### Thread.Composer

Bottom-docked slot; its height is measured to inset the viewport. Renders
`data-thread-composer`.

Anything inside it that should _not_ push content up — a floating scroll
button, an overlay panel — has to be out of the slot's flow (absolute, or
portaled like `Composer.Panel`'s default). An in-flow `Composer.Panel`
(`anchor={false}`) is part of the dock, so the viewport insets around it.

### Thread.Placeholder

Empty-state slot, shown when there are no messages. Renders
`data-thread-placeholder`.

### Thread.Content

The column inside the viewport that holds the messages. Renders
`data-thread-content`, and carries the auto-scroll reserve as
`--thread-turn-min-height` on its last child.

There is no scroll-to-bottom part — build one from `useThread()`, which exposes
`isAtBottom` and `scrollToBottom`:

```tsx
const { isAtBottom, scrollToBottom } = useThread();

return isAtBottom ? null : (
  <button type="button" onClick={() => scrollToBottom()} aria-label="Scroll to latest">
    <ArrowDownIcon />
  </button>
);
```

### CSS variables

The thread reads these, so you can override them from your own CSS:

export const cssVars = [
  { attribute: "--thread-width", values: "672px", description: "Max width of the content column and overlays." },
  { attribute: "--thread-overlay-top-height", values: "4rem", description: "Top overlay height and top inset." },
  { attribute: "--thread-overlay-bottom-height", values: "8rem", description: "Bottom overlay height and bottom inset (measured from the Thread.Composer slot at runtime)." },
];

<AttributesTable rows={cssVars} />
