Channel Chat
A many-person channel log, the team-chat and radio-net shape: messages grouped by author, day separators, system notices, your own run tinted, a log that follows new messages until you scroll up, and a composer with a counter and a rejected notice.
Installation#
Usage#
import {
ChannelChat,
ChannelChatViewport,
ChannelChatGroup,
ChannelChatAvatar,
ChannelChatGroupHeader,
ChannelChatAuthor,
ChannelChatTime,
ChannelChatMessage,
ChannelChatComposer,
ChannelChatInput,
ChannelChatSend,
groupChannelMessages,
} from "@/components/ui/channel-chat"Chat or Channel Chat#
Chat is a two-party thread: you and an assistant, a bubble or a document each, built for streaming answers and reasoning. A channel has many authors talking at once, so it needs different structure: author groups with a name, a callsign and an avatar, separators between days, system notices for who joined, and a log that can be read while it moves. That is this component. It is built out of the same parts: the log scrolls in ScrollArea, the avatar is Avatar, separators are Divider, and the composer is PromptInput laid out as one row.
Anatomy#
Every part is a named export and every part is optional: a log without a header, a composer on its own, a group without an avatar (the text column keeps its place). Grouping is data, not markup: groupChannelMessages turns your flat list into rows, and you render each row with the parts.
import {
ChannelChat,
ChannelChatAuthor,
ChannelChatAvatar,
ChannelChatCallsign,
ChannelChatComposer,
ChannelChatCount,
ChannelChatEmpty,
ChannelChatGroup,
ChannelChatGroupHeader,
ChannelChatHeader,
ChannelChatInput,
ChannelChatMessage,
ChannelChatMeta,
ChannelChatSend,
ChannelChatSeparator,
ChannelChatSystem,
ChannelChatTime,
ChannelChatTitle,
ChannelChatViewport,
} from "@/components/ui/channel-chat"
<ChannelChat>
<ChannelChatHeader>
<ChannelChatTitle />
<ChannelChatMeta />
</ChannelChatHeader>
<ChannelChatViewport>
<ChannelChatSeparator />
<ChannelChatGroup>
<ChannelChatAvatar />
<ChannelChatGroupHeader>
<ChannelChatAuthor />
<ChannelChatCallsign />
<ChannelChatTime />
</ChannelChatGroupHeader>
<ChannelChatMessage />
</ChannelChatGroup>
<ChannelChatSystem />
<ChannelChatEmpty />
</ChannelChatViewport>
<ChannelChatComposer>
<ChannelChatInput />
<ChannelChatCount />
<ChannelChatSend />
</ChannelChatComposer>
</ChannelChat>Grouping and separators#
Consecutive messages from one author within windowMs (5 minutes) of the previous one share a header. A new local day opens a day separator, a silence of gapMs (an hour) on the same day opens a time separator, and a system notice always ends the run. Pass own on your own groups: they take a soft brand wash and your name in the link ink.
Following and jump to latest#
The viewport opens on the newest message and follows new ones while you are at the bottom. Scroll up and it holds your place: a pill appears over the log, counting what arrived since, and takes you back down. Only a move up leaves the bottom, so the log never loses you halfway through its own smooth scroll. Sending from the composer jumps to the newest message too. Scroll up in the log below, then receive a message.
What the log opened with is history and never animates. Only rows that mount after its first commit fade in, and a batch of more than five messages in one commit (a backlog that loaded late, a reconnect) lands still, as history. To open a different channel as fresh history, give the viewport a key per channel.
Empty states#
ChannelChatEmpty is our compact EmptyState, centered in the room it gets: inside the viewport for a quiet channel, or in place of it when there is no log at all. A composer with nowhere to send takes disabled.
Composer#
maxLength caps the draft, and ChannelChatCount shows length/max only once the draft nears it (20 characters out by default, or counterFrom). When the server refuses a message (too fast, filtered, not delivered), pass the reason as notice: it tucks in behind the composer as PromptInput's banner and is announced as an alert. Enter sends, Shift+Enter breaks the line, and focus returns to the field after a send.
Accessibility#
- The log is
role="log"witharia-live="polite": each arrival is read once, without interrupting. Name it witharia-label(the channel). - Avatars are decorative (the name sits beside them). Times are
<time>elements; passdateTime. - The hidden jump pill is out of the tab order and the accessibility tree; it rejoins both when it shows.
- The counter is a live region that stays mounted, and reads “15 characters left” rather than the visual
185/200. The rejected notice isrole="alert". - Arrivals honor reduced motion: no fade, and the jump is instant.
Older engines#
Built for Chromium 103 (in-game browsers such as CEF): no :has(), container queries or standalone translate/scale properties. The pill moves with transform, and the field grows with PromptInput's measured fallback where field-sizing is missing.
API reference#
ChannelChat
The panel, a <section> laid out as a flex column. Size it (h-96, or flex-1 min-h-0); the viewport takes the room between header and composer. It lets the composer reach the viewport, so a send jumps to the newest message. Every part accepts className, merged last.
| Data attribute | Description |
|---|---|
| data-slot="channel-chat" | On the root. |
ChannelChatHeader
A <header> with a hairline under it. A leading icon is muted.
ChannelChatTitle
The channel name, an <h2> that truncates.
ChannelChatMeta
Trailing meta in tabular figures (a frequency, a member count), a <span> pushed to the right edge.
ChannelChatViewport
The scrolling log, role="log" with polite announcements. Give it a key per channel to open another channel as fresh history. Other props go on the frame.
| Prop | Type | Default | Description |
|---|---|---|---|
| stickToBottom | boolean | true | Follows new messages while the reader is at the bottom, and shows the jump pill when not. Scrolling up is never yanked back down. |
| jumpLabel | (unread: number) => React.ReactNode | - | The pill's label, given how many messages arrived while scrolled up. Defaults to "n new messages" or "Jump to latest". |
| viewportRef | React.Ref<HTMLDivElement> | - | A ref to the scrolling element itself. |
| aria-label | string | - | Names the log (the channel). |
| aria-labelledby | string | - | Names the log from an element on the page. |
| Data attribute | Description |
|---|---|
| data-following | Present while the reader is at the bottom. |
| data-slot="channel-chat-jump" | The jump pill; carries data-state (visible or hidden). |
ChannelChatSeparator
A day or quiet-gap separator: a labeled hairline (our Divider), read as plain text. Children are the label.
ChannelChatGroup
A run of messages from one author. The avatar sits in a reserved gutter, so a group without one keeps the same text column.
| Prop | Type | Default | Description |
|---|---|---|---|
| own | boolean | false | Your own run: a soft brand wash and the name in the link ink. |
| Data attribute | Description |
|---|---|
| data-own | Present while own. |
ChannelChatAvatar
The author's avatar in the group's gutter, hidden from assistive tech. Every Avatar prop is forwarded.
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | - | The photo. |
| name | string | - | Derives two initials for the fallback. |
| size | "xs" | "sm" | "md" | "lg" | "xl" | "sm" | Our Avatar sizes. |
| children | React.ReactNode | - | Overrides the fallback initials. |
ChannelChatGroupHeader
The group's header row, baseline-aligned.
ChannelChatAuthor
The author's name, a <span> that truncates.
ChannelChatCallsign
The callsign in mono, a <span>.
ChannelChatTime
A <time> in tabular figures. Pass dateTime (ISO) for a machine-readable stamp.
ChannelChatMessage
One line of a group. Keeps the sender's line breaks and breaks a long token anywhere.
| Data attribute | Description |
|---|---|
| data-fresh | Stamped when it arrives after the log settled; history never animates. |
ChannelChatSystem
A system notice between groups: a join, a leave, a channel switch.
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "default" | "info" | "success" | "warning" | "destructive" | "default" | Tints the leading icon only; the text stays quiet. |
| Data attribute | Description |
|---|---|
| data-tone | The current tone. |
ChannelChatEmpty
Our EmptyState in a centering frame. Every EmptyState prop is forwarded; className goes on the frame.
| Prop | Type | Default | Description |
|---|---|---|---|
| density | "comfortable" | "compact" | "compact" | The EmptyState density. |
ChannelChatComposer
The message composer: our PromptInput as one row, with the notice as its banner. Compose ChannelChatInput, ChannelChatCount and ChannelChatSend inside.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | - | Controlled draft. Omit (and use defaultValue) to let the composer own it. |
| defaultValue | string | - | The initial draft, uncontrolled. |
| onValueChange | (value: string) => void | - | Called with the draft as it changes. |
| onSubmit | (text: string) => void | - | Receives the trimmed text. Never fires empty or disabled. Uncontrolled, the field clears itself, takes focus back and the log jumps to the newest message. |
| disabled | boolean | false | Nobody to talk to: the field and the send button go inert. |
| maxLength | number | - | Caps the draft and turns the counter on. |
| counterFrom | number | maxLength - 20 | The draft length at which the counter appears. |
| notice | React.ReactNode | - | The rejected-message notice, tucked behind the composer and announced as an alert. null removes it. |
| noticeTone | "default" | "info" | "warning" | "destructive" | "destructive" | The notice's tone (PromptInputBanner tones). |
| onNoticeDismiss | () => void | - | Adds a close button to the notice that calls this. |
| Data attribute | Description |
|---|---|
| data-disabled | Present while disabled. |
| data-slot="channel-chat-notice" | The notice banner. |
ChannelChatInput
The composer's auto-growing field. Enter sends, Shift+Enter breaks the line. Takes every PromptInputTextarea prop.
| Prop | Type | Default | Description |
|---|---|---|---|
| placeholder | string | "Write a message…" | Also names the field when there is no aria-label. |
ChannelChatCount
The draft counter, length/max, shown once the draft nears maxLength. Turns destructive at the cap. Renders nothing without maxLength.
| Prop | Type | Default | Description |
|---|---|---|---|
| srLabel | (left: number) => string | (left) => "n characters left" | Words the remaining count for screen readers. |
| Data attribute | Description |
|---|---|
| data-near | Present once the draft reaches counterFrom. |
| data-full | Present at the cap. |
ChannelChatSend
The send button, disabled until there is a draft. Takes every PromptInputSubmit prop.
| Prop | Type | Default | Description |
|---|---|---|---|
| sendLabel | string | "Send message" | The button's accessible name. |
| children | React.ReactNode | - | Replaces the paper plane icon. |
groupChannelMessages
groupChannelMessages(messages, options) turns a flat list into rows: { type: "separator", kind: "day" | "gap", at }, { type: "group", author, at, items } and { type: "system", at, item }, each with a key. It formats nothing; word the labels in your locale. Its options:
| Prop | Type | Default | Description |
|---|---|---|---|
| getKey* | (message: T) => string | number | - | A stable key per message (its id). |
| getAuthor* | (message: T) => string | number | - | Who sent it. Two messages group only when this matches. |
| getTime* | (message: T) => number | Date | - | When it was sent, as epoch ms or a Date. |
| isSystem | (message: T) => boolean | - | Marks a system notice. Never grouped. |
| windowMs | number | 5 minutes | How close a message must follow the previous one to join its group. |
| gapMs | number | 1 hour | A same-day silence long enough for a time separator. 0 turns gap separators off. |
| leadingSeparator | boolean | true | Inserts a separator before the very first message. |
channelChatVariants
The tv recipe behind every part, for styling a part of your own to match.