---
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 { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
import { 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]">
                    <IconArrowUp className="size-4" />
                  </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]"
    >
      <IconArrowDown className="size-4" />
    </button>
  );
};
```

## 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.
- **Costs nothing while streaming** — see [Why the scroll subsystem is free](#why-the-scroll-subsystem-is-free). There is nothing here to optimise around.
- **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.Overlay />
  <Thread.Viewport>
    <Thread.Content />
  </Thread.Viewport>
  <Thread.Composer />
</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>
  {/* Not a part: a scroll-to-bottom control is yours, built from
      `useThread()`'s `isAtBottom` and `scrollToBottom`. */}
  <ScrollToBottomButton />
  <Thread.Overlay direction="bottom" />
</Thread.Root>
```

## Examples

### Choosing an auto-scroll mode

`autoScroll` decides two things at once: where a new turn lands, and whether the
view keeps following while text streams into it. `Thread.Viewport` maps
`--thread-turn-min-height` onto its last child, so the reserve that lets a 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} />

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

import { Message } from "@intentface/chat/message";
import { Thread } from "@intentface/chat/thread";
import { useEffect, useRef, useState } from "react";

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

const SEED: Turn[] = [
  { id: "1", role: "user", text: "Why does my list re-render on every keystroke?" },
  {
    id: "2",
    role: "assistant",
    text: "Because the parent holding the input state re-renders, and every child re-renders with it unless something stops the cascade.",
  },
  { id: "3", role: "user", text: "Can I just memo the list?" },
  {
    id: "4",
    role: "assistant",
    text: "You can, but only if its props keep reference identity. A fresh array or an inline callback defeats it silently.",
  },
];

const MODES = ["follow", "bottom", "jump", "off"] as const;
type Mode = (typeof MODES)[number];

const CAPTIONS: Record<Mode, string> = {
  follow: "Newest lands at the top and the view follows the stream.",
  bottom: "Newest lands at the bottom and the view follows the stream.",
  jump: "Newest lands at the top; the view does not follow.",
  off: "A plain scroll area — no landing, no follow, no reserve.",
};

const REPLY =
  "Memoisation compares props, so the comparison itself has to be cheaper than the render you are avoiding. That is usually true for a list and rarely true for a single row.";

/*
 * The four modes, on the same transcript.
 *
 * `autoScroll` decides two things at once: where a new turn lands, and whether
 * the view keeps following while text streams into it. Send a message in each
 * mode and watch the difference — and in `follow` and `jump`, notice the space
 * reserved beneath the newest turn, which is what lets it land at the top.
 *
 * Remounting on a mode change is this demo's doing, not a requirement: the key
 * forces a fresh scroll subsystem so each mode is observed from its own
 * opening position rather than wherever the last one left the scroller.
 */
export const AutoScroll = () => {
  const [mode, setMode] = useState<Mode>("follow");
  const [turns, setTurns] = useState<Turn[]>(SEED);
  const [streaming, setStreaming] = useState(false);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);

  useEffect(() => () => timers.current.forEach(clearTimeout), []);

  const send = () => {
    timers.current.forEach(clearTimeout);
    timers.current = [];

    const stamp = Date.now();
    setTurns((current) => [
      ...current,
      { id: `${stamp}-u`, role: "user", text: "Is memoising the list enough?" },
      { id: `${stamp}-a`, role: "assistant", text: "" },
    ]);
    setStreaming(true);

    // Word by word, so following is something you can actually watch.
    const words = REPLY.split(" ");
    words.forEach((_, index) => {
      timers.current.push(
        setTimeout(() => {
          setTurns((current) =>
            current.map((turn) =>
              turn.id === `${stamp}-a`
                ? { ...turn, text: words.slice(0, index + 1).join(" ") }
                : turn,
            ),
          );
        }, index * 60),
      );
    });
    timers.current.push(setTimeout(() => setStreaming(false), words.length * 60));
  };

  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <div className="flex flex-wrap items-center justify-center gap-1">
        {MODES.map((candidate) => (
          <button
            key={candidate}
            type="button"
            onClick={() => {
              setMode(candidate);
              setTurns(SEED);
            }}
            className={[
              "h-8 cursor-pointer rounded-full border px-3 font-mono text-xs transition-colors",
              candidate === mode
                ? "border-[#1a1a1a] bg-[#1a1a1a] text-white dark:border-[#fcfcfc] dark:bg-[#fcfcfc] dark:text-[#111111]"
                : "border-[#e4e4e4] bg-white text-[#686868] hover:bg-[#f4f4f4] dark:border-[#2d2d2d] dark:bg-[#181818] dark:text-[#9b9b9b] dark:hover:bg-[#232323]",
            ].join(" ")}
          >
            {candidate}
          </button>
        ))}
      </div>

      <div className="h-80 w-full overflow-hidden rounded-xl border border-[#f0f0f0] bg-white dark:border-[#262626] dark:bg-[#181818]">
        <Thread.Root
          key={mode}
          autoScroll={mode}
          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 pt-(--thread-overlay-top-height) pb-4">
              {/* The reserve the primitive publishes lands on the last child. */}
              <Thread.Content className="flex min-h-full w-full flex-col gap-4 px-4 [&>*:last-child]:min-h-(--thread-turn-min-height,0px)">
                {turns.map((turn, index) => (
                  <Message.Root
                    key={turn.id}
                    role={turn.role}
                    isLast={index === turns.length - 1}
                    data-message-id={turn.id}
                    className="group flex w-full flex-col data-[role=user]:items-end"
                  >
                    <Message.Text className="max-w-[85%] text-[#1a1a1a] text-sm leading-[1.7] group-data-[role=user]:rounded-2xl group-data-[role=user]:border group-data-[role=user]:border-[#f0f0f0] group-data-[role=user]:bg-[#fafafa] group-data-[role=user]:px-3 group-data-[role=user]:py-1.5 dark:text-[#fcfcfc] dark:group-data-[role=user]:border-[#262626] dark:group-data-[role=user]:bg-[#232323]">
                      {turn.text}
                    </Message.Text>
                  </Message.Root>
                ))}
              </Thread.Content>
            </div>
          </Thread.Viewport>
        </Thread.Root>
      </div>

      <div className="flex items-center justify-between gap-3">
        <p className="text-[#686868] text-xs dark:text-[#9b9b9b]">{CAPTIONS[mode]}</p>
        <button
          type="button"
          onClick={send}
          disabled={streaming}
          className="h-8 shrink-0 cursor-pointer rounded-full border border-[#e4e4e4] bg-white px-4 font-medium text-[#1a1a1a] text-sm transition-colors hover:bg-[#f4f4f4] disabled:cursor-default disabled:opacity-40 dark:border-[#2d2d2d] dark:bg-[#181818] dark:text-[#fcfcfc] dark:hover:bg-[#232323]"
        >
          {streaming ? "Streaming…" : "Send a reply"}
        </button>
      </div>
    </div>
  );
};
```

### 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.

### Jumping to a message

`scrollToMessage` finds a row by the `data-message-id` attribute you put on it.
There is no wrapper part and no registry: rows resolve lazily at call time, so a
transcript of ten thousand turns costs the same as this one.
`useThreadVisibility` reads the same attribute to report which rows are on
screen, and creates its observers only once something subscribes.

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

import { Message } from "@intentface/chat/message";
import { Thread, useThread, useThreadVisibility } from "@intentface/chat/thread";

const TURNS = Array.from({ length: 14 }, (_, index) => ({
  id: `turn-${index + 1}`,
  role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
  title: `Turn ${index + 1}`,
  text:
    index % 2 === 0
      ? `Question ${index / 2 + 1}: how does this behave when the transcript is long?`
      : "It resolves the row lazily by its attribute, so nothing is registered up front and a long transcript costs no more than a short one.",
}));

/*
 * Addressing a row without registering it.
 *
 * `scrollToMessage` finds a row by the `data-message-id` attribute you put on
 * it. There is no wrapper part and no registry: rows are resolved lazily at
 * call time, so a transcript of ten thousand turns costs the same as this one.
 *
 * `useThreadVisibility` is the other half. It reads the same attribute to
 * report which rows are on screen, and it creates its observers on the first
 * subscriber — a thread that never calls it pays nothing.
 */
export const Jump = () => (
  <div className="flex w-full max-w-2xl gap-3">
    <div className="h-80 min-w-0 flex-1 overflow-hidden rounded-xl border border-[#f0f0f0] bg-white dark:border-[#262626] dark:bg-[#181818]">
      <Thread.Root autoScroll="off" className="relative flex h-full w-full overflow-hidden">
        <Thread.Viewport className="h-full w-full overflow-y-auto outline-none">
          <Thread.Content className="flex w-full flex-col gap-4 p-4">
            {TURNS.map((turn) => (
              <Message.Root
                key={turn.id}
                role={turn.role}
                data-message-id={turn.id}
                className="group flex w-full flex-col gap-1"
              >
                <span className="font-medium text-[#949494] text-xs dark:text-[#6f6f6f]">
                  {turn.title}
                </span>
                <Message.Text className="text-[#1a1a1a] text-sm leading-[1.7] dark:text-[#fcfcfc]">
                  {turn.text}
                </Message.Text>
              </Message.Root>
            ))}
          </Thread.Content>
        </Thread.Viewport>

        {/* Both panels live inside the Root, which is how they reach the
            scroll context; neither is a part of the package. */}
        <Outline />
      </Thread.Root>
    </div>
  </div>
);

/** An outline that jumps, and highlights whichever row is being read. */
const Outline = () => {
  const { scrollToMessage } = useThread();
  const { currentMessageId } = useThreadVisibility();

  return (
    <nav
      aria-label="Transcript outline"
      className="flex w-36 shrink-0 flex-col gap-0.5 overflow-y-auto border-[#f0f0f0] border-l p-2 dark:border-[#262626]"
    >
      {TURNS.map((turn) => (
        <button
          key={turn.id}
          type="button"
          onClick={() => scrollToMessage(turn.id, { align: "start" })}
          className={[
            "h-7 shrink-0 cursor-pointer rounded-md px-2 text-left text-xs transition-colors",
            turn.id === currentMessageId
              ? "bg-[#ececec] font-medium text-[#1a1a1a] dark:bg-[#2d2d2d] dark:text-[#fcfcfc]"
              : "text-[#686868] hover:bg-[#f4f4f4] dark:text-[#9b9b9b] dark:hover:bg-[#232323]",
          ].join(" ")}
        >
          {turn.title}
        </button>
      ))}
    </nav>
  );
};
```

## Why the scroll subsystem is free

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.

## 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} />

## 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} />
