Skip to main content
Gremorie

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

What is Gremorie?

Gremorie is an AI-native design system: registry + MCP first, React and Angular bindings second.

How do I install a primitive?

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>

PropTypeDefaultDescription
classNamestring-Extra classes on the scrolling content.

Renders inside StickToBottom.Content. Adds a vertical flex stack with gap-8 and p-4.

<ConversationScrollButton>

PropTypeDefaultDescription
classNamestring-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>

PropTypeDefaultDescription
titlestring"No messages yet"Heading rendered when there is no content.
descriptionstring"Start a conversation to see messages here"Supporting copy below the title.
iconReactNode-Optional icon above the title.
childrenReactNode-When provided, fully replaces the default title / description layout.

Composition

  1. <Conversation> owns the scroll container and the log role.
  2. <ConversationContent> is the inner flex column where Message siblings live.
  3. <ConversationScrollButton> floats inside the conversation, shown only while the user is not at the bottom.
  4. <ConversationEmptyState> can replace ConversationContent (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: ConversationScrollButton only mounts while there is somewhere to scroll, so it does not steal Tab order on a fully-scrolled view.
  • 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

On this page