Chat

Chat Conversation

Scrolling chat transcript built on Message Scroller: a polite live log that follows the streaming edge, with turn, attribution, pulsing cursor and shimmering status parts.

Free, MIT licenseOpen in Browse
"use client"

import * as React from "react"
import { cn } from "cn"

import { Marker, MarkerContent } from "@/components/ui/marker"
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller"

/**
 * The scrolling transcript of a chat. It follows new messages as they stream
 * in, stops following when the reader scrolls up, and shows a button to jump
 * back to the latest message while they are away from it. Screen readers hear
 * it as a polite live log.
 *
 * `className` lands on the scrolling viewport, so padding scrolls with the
 * messages.
 */
function ChatConversation({
  className,
  children,
  ...props
}: React.ComponentProps<typeof MessageScrollerViewport>) {
  return (
    <MessageScrollerProvider autoScroll>
      <MessageScroller data-slot="chat-conversation" className="min-h-0 flex-1">
        <MessageScrollerViewport
          role="log"
          aria-live="polite"
          aria-atomic="false"
          aria-label="Conversation"
          data-slot="chat-conversation-viewport"
          className={className}
          {...props}
        >
          {children}
        </MessageScrollerViewport>
        <MessageScrollerButton />
      </MessageScroller>
    </MessageScrollerProvider>
  )
}

/** The column that holds the turns, inside `ChatConversation`. */
function ChatConversationContent({
  className,
  ...props
}: React.ComponentProps<typeof MessageScrollerContent>) {
  return (
    <MessageScrollerContent
      data-slot="chat-conversation-content"
      className={cn("gap-0", className)}
      {...props}
    />
  )
}

/**
 * One turn of the conversation: a message from the person or a reply from the
 * assistant.
 */
function ChatConversationTurn({
  pending,
  className,
  ...props
}: React.ComponentProps<typeof MessageScrollerItem> & {
  /**
   * While true, hides the turn from assistive tech, so a streaming reply
   * enters the live region once, whole, instead of one token at a time.
   */
  pending?: boolean
}) {
  return (
    <MessageScrollerItem
      data-slot="chat-conversation-turn"
      inert={pending}
      className={className}
      {...props}
    />
  )
}

/**
 * The sender's name, and optionally the time, above a turn, set on a thin
 * rule.
 *
 * It is hidden from assistive tech and can't be selected, so the name and
 * time never get copied along with a message.
 */
function ChatConversationAttribution({
  who,
  at,
  className,
  ...props
}: Omit<React.ComponentProps<typeof Marker>, "variant"> & {
  /**
   * The stock `Marker` variant, `separator` by default here: one rule
   * between the name and the time. `border` draws a line under the row
   * instead, and `default` draws no line.
   */
  variant?: React.ComponentProps<typeof Marker>["variant"]
  /** Who sent the turn, such as "You" or the assistant's name. */
  who: string
  /** When it was sent, shown after the rule. */
  at?: string
}) {
  return (
    <Marker
      variant="separator"
      aria-hidden="true"
      data-slot="chat-conversation-attribution"
      className={cn("gap-3 pt-6 pb-2 select-none before:hidden", className)}
      {...props}
    >
      <MarkerContent className="font-mono text-xs/5 tracking-[0.18em] uppercase">
        {who}
      </MarkerContent>
      {/* order-last puts the time after the ::after rule */}
      {at && (
        <MarkerContent className="order-last font-mono text-xs/5 tabular-nums">
          {at}
        </MarkerContent>
      )}
    </Marker>
  )
}

/**
 * A blinking block at the end of a reply that is still streaming. Hidden from
 * assistive tech; it stops blinking when reduced motion is on.
 */
function ChatConversationCursor({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      aria-hidden="true"
      data-slot="chat-conversation-cursor"
      className={cn(
        "ml-0.5 inline-block h-4 w-[0.5em] translate-y-[0.15em] bg-foreground align-baseline select-none motion-safe:animate-pulse",
        className
      )}
      {...props}
    />
  )
}

/**
 * A short status line under a turn, such as "Thinking". Screen readers
 * announce it politely; its shimmer stops when reduced motion is on.
 */
function ChatConversationStatus({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      role="status"
      data-slot="chat-conversation-status"
      className={cn(
        "w-fit font-mono text-xs/5 tracking-widest text-muted-foreground uppercase select-none motion-safe:shimmer",
        className
      )}
      {...props}
    />
  )
}

export {
  ChatConversation,
  ChatConversationAttribution,
  ChatConversationContent,
  ChatConversationCursor,
  ChatConversationStatus,
  ChatConversationTurn,
}

Installation

pnpm dlx shadcn add @01orbital/chat-conversation
npx shadcn add @01orbital/chat-conversation
yarn shadcn add @01orbital/chat-conversation
bunx --bun shadcn add @01orbital/chat-conversation

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/chat/chat-conversation.tsx, as source you own and edit.

The example also uses chat-message and chat-response. Install them too:

pnpm dlx shadcn add @01orbital/chat-message @01orbital/chat-response
npx shadcn add @01orbital/chat-message @01orbital/chat-response
yarn shadcn add @01orbital/chat-message @01orbital/chat-response
bunx --bun shadcn add @01orbital/chat-message @01orbital/chat-response

Usage

"use client"

import {
  ChatConversation,
  ChatConversationContent,
  ChatConversationCursor,
  ChatConversationStatus,
  ChatConversationTurn,
} from "@/components/chat/chat-conversation"
import { ChatMessage } from "@/components/chat/chat-message"
import { ChatResponse } from "@/components/chat/chat-response"

// Every word on the page. Change copy here, not in the markup.
const COPY = {
  body: "Which hero should I use for a product page?",
  body2:
    "hero-108 is the flagship — an app frame on the house ground. For a type-led page, hero-109 keeps it minimal.",
  body3: "And for a pricing page?",
  body4: "Pair it with three-plan-pricing-01 and keep one display size",
  label: "Thinking",
} as const

export default function ChatConversationDemo() {
  return (
    <div className="grid h-112 w-full max-w-xl">
      <ChatConversation>
        <ChatConversationContent>
          <ChatConversationTurn messageId="usr-1" scrollAnchor>
            <ChatMessage variant="mine">{COPY.body}</ChatMessage>
          </ChatConversationTurn>
          <ChatConversationTurn messageId="asst-1">
            <ChatResponse text={COPY.body2} />
          </ChatConversationTurn>
          <ChatConversationTurn messageId="usr-2" scrollAnchor>
            <ChatMessage variant="mine">{COPY.body3}</ChatMessage>
          </ChatConversationTurn>
          <ChatConversationTurn messageId="asst-2" pending>
            <ChatResponse text={COPY.body4}>
              <ChatConversationCursor />
            </ChatResponse>
          </ChatConversationTurn>
          <ChatConversationTurn messageId="status">
            <ChatConversationStatus>{COPY.label}</ChatConversationStatus>
          </ChatConversationTurn>
        </ChatConversationContent>
      </ChatConversation>
    </div>
  )
}

Anatomy

Chat Conversation is built from 6 parts. In the example they nest like this:

ChatConversation
└─ ChatConversationContent
   └─ ChatConversationTurn
      ├─ ChatConversationCursor
      └─ ChatConversationStatus
  • ChatConversation: The scrolling transcript of a chat. It follows new messages as they stream in, stops following when the reader scrolls up, and shows a button to jump back to the latest message while they are away from it. Screen readers hear it as a polite live log.
  • ChatConversationContent: The column that holds the turns, inside ChatConversation.
  • ChatConversationTurn: One turn of the conversation: a message from the person or a reply from the assistant.
  • ChatConversationCursor: A blinking block at the end of a reply that is still streaming. Hidden from assistive tech; it stops blinking when reduced motion is on.
  • ChatConversationStatus: A short status line under a turn, such as "Thinking". Screen readers announce it politely; its shimmer stops when reduced motion is on.
  • ChatConversationAttribution: The sender's name, and optionally the time, above a turn, set on a thin rule.

API reference

ChatConversation

The scrolling transcript of a chat. It follows new messages as they stream in, stops following when the reader scrolls up, and shows a button to jump back to the latest message while they are away from it. Screen readers hear it as a polite live log.

className lands on the scrolling viewport, so padding scrolls with the messages.

It accepts the props of the shadcn part it wraps, and the HTML attributes of the element it renders.

ChatConversationContent

The column that holds the turns, inside ChatConversation.

It accepts the props of the shadcn part it wraps, and the HTML attributes of the element it renders.

ChatConversationTurn

One turn of the conversation: a message from the person or a reply from the assistant.

Prop

Type

It also accepts the props of the shadcn part it wraps, and the HTML attributes of the element it renders.

ChatConversationCursor

A blinking block at the end of a reply that is still streaming. Hidden from assistive tech; it stops blinking when reduced motion is on.

It accepts the HTML attributes of the element it renders.

ChatConversationStatus

A short status line under a turn, such as "Thinking". Screen readers announce it politely; its shimmer stops when reduced motion is on.

It accepts the HTML attributes of the element it renders.

ChatConversationAttribution

The sender's name, and optionally the time, above a turn, set on a thin rule.

It is hidden from assistive tech and can't be selected, so the name and time never get copied along with a message.

Prop

Type

It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

Dependencies

npm: cn. The CLI installs them for you.

Registry pieces it installs with it:

  • font-geist-mono
  • marker
  • message-scroller