Conversation
Scroll-to-bottom container that keeps the latest message in view while content streams.
Overview
Conversation is the outer shell of any chat surface. It wraps use-stick-to-bottom so the scroll position follows new messages as they arrive, then steps out of the way the moment the user scrolls up to read history. The matching ConversationScrollButton floats in to bring the user back to the bottom on demand.
Use it as the parent of any Message stream. Use ConversationEmptyState for the zero-message case.
Preview
Standard
Gremorie is an AI-native design system: registry + MCP first, React and Angular bindings second.
Use npx gremorie add rx-message. The registry resolves dependencies and writes source files into your project.
Empty state
Start a conversation
Ask anything about Gremorie primitives, tokens, or the registry.
Installation
bash npx gremorie@latest add rx-conversation bash pnpm dlx gremorie@latest add rx-conversation
bash yarn dlx gremorie@latest add rx-conversation
bash bunx --bun gremorie@latest add rx-conversation
Usage
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@gremorie/rx-ai";
export function Example() {
return (
<Conversation>
<ConversationContent>
{messages.map((m) => (
<Message key={m.id} from={m.role}>
<MessageContent>{m.text}</MessageContent>
</Message>
))}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
);
}import { Component, signal } from "@angular/core";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
Message,
MessageContent,
} from "@gremorie/ng-ai";
@Component({
selector: "app-example",
standalone: true,
imports: [
Conversation,
ConversationContent,
ConversationScrollButton,
Message,
MessageContent,
],
template: ` <conversation class="h-96 w-full">
<conversation-content>
@for (m of messages(); track m.id) {
<message [from]="m.role">
<message-content>{{ m.text }}</message-content>
</message>
}
</conversation-content>
<conversation-scroll-button />
</conversation>
`,
})
export class ExampleComponent {
readonly messages = signal<Array<{ id: string; role: "user" | "assistant"; text: string }>>([]);
}
API
<Conversation>
Forwards all props of StickToBottom from use-stick-to-bottom. The sticky behaviour is preconfigured (initial="smooth", resize="smooth") and the element carries role="log" for assistive tech.
<ConversationContent>
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Extra classes on the scrolling content. |
Renders inside StickToBottom.Content. Adds a vertical flex stack with gap-8 and p-4.
<ConversationScrollButton>
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Extra classes on the button. |
Forwards all Button props. Only renders when the user has scrolled away from the bottom; clicking it scrolls back smoothly.
<ConversationEmptyState>
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | "No messages yet" | Heading rendered when there is no content. |
description | string | "Start a conversation to see messages here" | Supporting copy below the title. |
icon | ReactNode | - | Optional icon above the title. |
children | ReactNode | - | When provided, fully replaces the default title / description layout. |
Composition
<Conversation>owns the scroll container and thelogrole.<ConversationContent>is the inner flex column whereMessagesiblings live.<ConversationScrollButton>floats inside the conversation, shown only while the user is not at the bottom.<ConversationEmptyState>can replaceConversationContent(or be rendered as a sibling) when there is nothing to show.
Variations
Standard streaming conversation
The 90% case: feed messages in, let Conversation keep the latest one in view.
<Conversation className="h-[600px]">
<ConversationContent>
{messages.map((m) => (
<Message key={m.id} from={m.role}>
<MessageContent>
<MessageResponse>{m.text}</MessageResponse>
</MessageContent>
</Message>
))}
</ConversationContent>
<ConversationScrollButton />
</Conversation>Empty state before the first message
Show this on a fresh thread to invite the first prompt.
<Conversation className="h-[600px]">
{messages.length === 0 ? (
<ConversationEmptyState
icon={<MessageCircleIcon className="size-8" />}
title="Ask anything"
description="The assistant will answer with sources and a plan."
/>
) : (
<ConversationContent>
{messages.map((m) => (
<Message key={m.id} from={m.role}>
<MessageContent>{m.text}</MessageContent>
</Message>
))}
</ConversationContent>
)}
<ConversationScrollButton />
</Conversation>Custom empty state body
Use children to fully control the empty layout while keeping the centered container.
<ConversationEmptyState>
<div className="space-y-3 text-center">
<h3 className="font-medium">Welcome back</h3>
<p className="text-muted-foreground text-sm">Pick up where you left off.</p>
<Button variant="outline">Resume thread</Button>
</div>
</ConversationEmptyState>Accessibility
- Keyboard: standard scroll keys (Page Up / Page Down, Arrow keys, Home / End) work on the scroll container.
- ARIA: the root has
role="log", which signals to assistive tech that new entries are appended at the bottom and should be announced as they arrive. - Screen readers: empty state uses semantic heading + paragraph so it announces as a single block, not as scattered text.
- Focus management:
ConversationScrollButtononly mounts while there is somewhere to scroll, so it does not steal Tab order on a fully-scrolled view.
Related
- Message - atomic turn rendered inside
ConversationContent - PromptInput - input dock typically rendered below the conversation
- Suggestion - quick-reply chips that often pair with the empty state