Conversation

The scrolling viewport for a chat. It follows new content while you're at the bottom, releases the moment you scroll up, and offers a floating key to jump back to the latest message.

Give me a few sentences about springs.
SU
A spring animates toward a target by simulating stiffness, damping, and mass rather than a fixed clock. Because it carries velocity, an interrupted spring redirects smoothly instead of snapping.
And why depth?
SU
Depth turns the interface into a stack of surfaces on a virtual z-axis. Pressing recedes a control into the surface; overlays rise toward you. It reads as physical, not flat.
tsx
"use client"

import * as React from "react"

import { Button } from "@/registry/seam/ui/button"
import {
  Conversation,
  ConversationContent,
  ConversationScrollButton,
} from "@/registry/seam/ui/conversation"
import {
  Message,
  MessageAvatar,
  MessageContent,
} from "@/registry/seam/ui/message"

const SEED = [
  { from: "user", text: "Give me a few sentences about springs." },
  {
    from: "assistant",
    text: "A spring animates toward a target by simulating stiffness, damping, and mass rather than a fixed clock. Because it carries velocity, an interrupted spring redirects smoothly instead of snapping.",
  },
  { from: "user", text: "And why depth?" },
  {
    from: "assistant",
    text: "Depth turns the interface into a stack of surfaces on a virtual z-axis. Pressing recedes a control into the surface; overlays rise toward you. It reads as physical, not flat.",
  },
] as const

export default function ConversationDemo() {
  const [messages, setMessages] = React.useState<
    { from: "user" | "assistant"; text: string }[]
  >(SEED.map((m) => ({ ...m })))

  const send = () => {
    setMessages((prev) => [
      ...prev,
      { from: "user", text: "Tell me one more thing." },
      {
        from: "assistant",
        text: "Every interactive control reacts to press within a frame and settles springy on release — the viewport follows along, staying pinned to the newest message while you're at the bottom.",
      },
    ])
  }

  return (
    <div className="bg-card flex h-80 w-full max-w-md flex-col rounded-xl squircle border shadow-resting">
      <Conversation>
        <ConversationContent>
          {messages.map((m, i) => (
            <Message key={i} from={m.from}>
              {m.from === "assistant" && <MessageAvatar name="Seam UI" />}
              <MessageContent>{m.text}</MessageContent>
            </Message>
          ))}
        </ConversationContent>
        <ConversationScrollButton />
      </Conversation>
      <div className="border-t p-2">
        <Button size="sm" className="w-full" onClick={send}>
          Send more
        </Button>
      </div>
    </div>
  )
}
bunx --bun @seamui/cli@latest add conversation

API

PropTypeDefaultDescription
childrenReactNodeViewport content — typically ConversationContent (messages) plus ConversationScrollButton.
useConversation().isAtBottombooleanTrue while the viewport is pinned to the bottom; the scroll key shows when it flips false.
useConversation().scrollToBottom(behavior?: ScrollBehavior) => voidJump to the latest message — smooth by default, instant under reduced motion.

Conversation and ConversationContent accept all native <div> props; ConversationScrollButton accepts all Button props.

Notes

  • The viewport is a role="log" with aria-live="polite", so appended messages are announced without interrupting; it is focusable and keyboard scrollable.
  • While pinned, the viewport follows streamed content instantly — an animated follow would lag behind the tokens. Under reduced motion all scrolling is instant (behavior: "auto").
  • The scroll-to-latest key rises at overlay depth (springs.surface) and falls away at the bottom; under reduced motion it fades rather than rising. It carries an aria-label.
  • The stick-to-bottom logic ships in the component file — no dependency.

Press feedback, reduced motion, and haptics follow the global policy — see Motion and Haptics.