Skip to content

Chat

The channel shell: a scrolling message log with the composer pinned below it.

The channel shell: a scrolling message log with the composer pinned below it. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

"use client"

import * as React from "react"

import { Avatar } from "@/registry/bases/newt/ui/avatar"
import { Chat, ChatBody, ChatFooter } from "@/registry/bases/newt/ui/chat"
import { MessageComposer } from "@/registry/bases/newt/ui/message-composer"
import {
  MessageGroup,
  MessageGroupAuthor,
  MessageGroupAvatar,
  MessageGroupBody,
  MessageGroupContent,
  MessageGroupHeader,
  MessageGroupTime,
} from "@/registry/bases/newt/ui/message-group"
import { MessageList } from "@/registry/bases/newt/ui/message-list"

export default function ChatDemo() {
  const [value, setValue] = React.useState("")

  return (
    <Chat aria-label="#general channel chat" className="h-80 w-full max-w-md">
      <ChatBody focusable viewportLabel="#general channel messages">
        <MessageList label="Messages in #general">
          <MessageGroup>
            <MessageGroupAvatar>
              <Avatar>U</Avatar>
            </MessageGroupAvatar>
            <MessageGroupBody>
              <MessageGroupHeader>
                <MessageGroupAuthor>username</MessageGroupAuthor>
                <MessageGroupTime dateTime="2026-09-04T14:23:00.000Z">
                  Today at 14:23
                </MessageGroupTime>
              </MessageGroupHeader>
              <MessageGroupContent>Welcome to the channel.</MessageGroupContent>
            </MessageGroupBody>
          </MessageGroup>
        </MessageList>
      </ChatBody>
      <ChatFooter>
        <MessageComposer
          channelName="general"
          value={value}
          onValueChange={setValue}
          onSubmit={() => setValue("")}
        />
      </ChatFooter>
    </Chat>
  )
}
<script setup lang="ts">
import { ref } from "vue"
import { Avatar } from "~~/registry/bases/newt/ui/avatar"
import { Chat, ChatBody, ChatFooter } from "~~/registry/bases/newt/ui/chat"
import { MessageComposer } from "~~/registry/bases/newt/ui/message-composer"
import {
  MessageGroup,
  MessageGroupAuthor,
  MessageGroupAvatar,
  MessageGroupBody,
  MessageGroupContent,
  MessageGroupHeader,
  MessageGroupTime,
} from "~~/registry/bases/newt/ui/message-group"
import { MessageList } from "~~/registry/bases/newt/ui/message-list"

const value = ref("")
</script>

<template>
  <Chat aria-label="#general channel chat" class="h-80 w-full max-w-md">
    <ChatBody focusable viewport-label="#general channel messages">
      <MessageList label="Messages in #general">
        <MessageGroup>
          <MessageGroupAvatar>
            <Avatar>U</Avatar>
          </MessageGroupAvatar>
          <MessageGroupBody>
            <MessageGroupHeader>
              <MessageGroupAuthor>username</MessageGroupAuthor>
              <MessageGroupTime datetime="2026-09-04T14:23:00.000Z">
                Today at 14:23
              </MessageGroupTime>
            </MessageGroupHeader>
            <MessageGroupContent>Welcome to the channel.</MessageGroupContent>
          </MessageGroupBody>
        </MessageGroup>
      </MessageList>
    </ChatBody>
    <ChatFooter>
      <MessageComposer
        v-model="value"
        channel-name="general"
        @submit="value = ''"
      />
    </ChatFooter>
  </Chat>
</template>

Installation

pnpm dlx newtui@latest add chat
npx newtui@latest add chat
yarn dlx newtui@latest add chat
bunx newtui@latest add chat

Add the components it builds on

Copy the source into your project

import * as React from "react"

import { cn } from "@/lib/utils"
import { ScrollArea } from "@/registry/bases/newt/ui/scrollbar"

/* The channel shell: a scrolling message log with the composer pinned below. */
const Chat = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
  ({ className, ...props }, ref) => (
    <section
      ref={ref}
      className={cn(
        "flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-newt-bg-elevated",
        className
      )}
      {...props}
    />
  )
)
Chat.displayName = "Chat"

export interface ChatBodyProps extends React.ComponentProps<
  typeof ScrollArea
> {}

const ChatBody = React.forwardRef<HTMLDivElement, ChatBodyProps>(
  ({ className, children, ...props }, ref) => (
    <ScrollArea
      ref={ref}
      className={cn("min-h-0 flex-1", className)}
      {...props}
    >
      {/*
       * Pins a short conversation to the bottom of the scrollport, the way a
       * channel with three messages still reads from the composer upwards.
       */}
      <div className="flex min-h-full flex-col justify-end">{children}</div>
    </ScrollArea>
  )
)
ChatBody.displayName = "ChatBody"

const ChatFooter = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div ref={ref} className={cn("shrink-0 px-4 pb-4", className)} {...props} />
))
ChatFooter.displayName = "ChatFooter"

export { Chat, ChatBody, ChatFooter }
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<!-- The channel shell: a scrolling message log with the composer pinned below. -->
<template>
  <section
    :class="
      cn(
        'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-newt-bg-elevated',
        props.class
      )
    "
  >
    <slot />
  </section>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { ScrollArea } from "~~/registry/bases/newt/ui/scrollbar"

import { cn } from "@/lib/utils"

const props = defineProps<{
  class?: HTMLAttributes["class"]
  focusable?: boolean
  viewportLabel?: string
}>()
</script>

<template>
  <ScrollArea
    :focusable="props.focusable"
    :viewport-label="props.viewportLabel"
    :class="cn('min-h-0 flex-1', props.class)"
  >
    <!--
      Pins a short conversation to the bottom of the scrollport, the way a
      channel with three messages still reads from the composer upwards.
    -->
    <div class="flex min-h-full flex-col justify-end">
      <slot />
    </div>
  </ScrollArea>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div :class="cn('shrink-0 px-4 pb-4', props.class)">
    <slot />
  </div>
</template>
export { default as Chat } from "./Chat.vue"
export { default as ChatBody } from "./ChatBody.vue"
export { default as ChatFooter } from "./ChatFooter.vue"

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { Chat } from "@/components/ui/chat"

export function Example() {
  return <Chat />
}
<script setup lang="ts">
import { Chat, ChatBody, ChatFooter } from "@/components/ui/chat"
</script>

<template>
  <Chat />
</template>

API reference

Chat

PropType
children / default slot?React.ReactNode / Vue slot

Content rendered inside the component.

TypeReact.ReactNode / Vue slot
className / class?string

Additional classes merged with the component defaults.

Typestring

The registry exports Chat, ChatBody, ChatFooter. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.

Accessibility

Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.

Tokens

Chat reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.