Duck Chat Thread
The transcript, the wait state and the composer joined — following the stream only while the reader is at the bottom, and keeping tokens out of the live region.
BlockClient componentlucide-react
Preview
How do I start with the registry?
Add the
duck/ui docs@duck namespace to components.json, then install @duck/theme before anything else.Installation
One command writes the section and every component it renders.
$
pnpm dlx shadcn@latest add @duck/duck-chat-threadThe block lands in components/blocks/duck-chat-thread.tsx. It builds on quack-bubble, duck-thinking, stream-text, glow-input and quack-button, which the CLI installs alongside it. Already installed items are skipped.
Usage
import { DuckChatThread } from "@/components/blocks/duck-chat-thread"A block is a starting point, not a widget. The props exist so the example renders with real content — once the file is in your project, hard-code what never changes and delete the rest.
Props
Everything not listed here is forwarded to the root element.
| Prop | Type | Default | Description |
|---|---|---|---|
| messages | DuckChatMessage[] | - | id, from, content, meta, streaming. A streaming message's content must be a string — it goes through StreamText with the caret lit and the growing edge softened. |
| header | React.ReactNode | - | Above the transcript: a title row, a model picker, a clear button. |
| empty | React.ReactNode | - | Rendered when there are no messages. An EmptyPond fits here. |
| thinking | boolean | false | The wait before the first token. |
| thinkingLabel | string | "Thinking" | Read out, and shown beside the mark. |
| mark | React.ReactNode | - | The assistant's face, for the bubbles and the wait state alike. A transcript is the last place a design system should insist on its own mascot. |
| onSend | (text: string) => void | - | Fires with the trimmed text. The block clears and refocuses the field itself. |
| busy | boolean | false | Blocks the composer while a turn is in flight. |
| composerActions | React.ReactNode | - | Inside the field, left of send: attach, model, voice. |
| note | React.ReactNode | - | Under the composer: the "can be wrong" line, a token count. |
| placeholder | string | "Ask something…" | Also the composer's accessible name. |
| sendLabel | string | "Send" | Accessible name of the send button. |
| jumpLabel | string | "Jump to latest" | The way back once the reader has left the stream. |
| maxRows | number | 8 | Rows the composer grows to before it scrolls. |
Rules
What keeps this section from turning into noise.
- The thread follows the stream only while the reader is already at the bottom. Always scrolling makes it unreadable while it streams; never scrolling makes the reader chase it. That is why there is a jump button and not a timer.
- A token stream must not sit in a live region. The transcript is a polite role=log so a finished message announces once; the streaming bubble is aria-live="off" and aria-busy, because a live region re-announces the whole message on every token.
- Pass a streaming message as a string and flip streaming off when the turn ends. The finished message then re-renders as ordinary content, which is what the log announces.
- The composer is one surface: the textarea takes frame={false} and the row carries the edge and the focus glow. Without that prop the call site fights .sticker and loses on order.
- Enter sends by asking the form to submit, so the key and the button run the same path. Shift+Enter breaks the line.
