Cal.comdesign-system

Chat UI

Reusable Chat UI components, working demos, and a Cal integration guide for engineers and agents.

This page turns the Chat UI Primitives checklist into a working example and maps the pieces as generic, reusable UI. The Cal Events Chat project is one planned consumer.

Start here

Use this page as the implementation entry point. Read the sections in this order:

  1. One Chat UI family — imports, public components, and state ownership.
  2. Minimal working example — copyable inline composition.
  3. Implement in the Cal app — data mapping, permissions, state transitions, and drawer integration.
  4. Integration checklist — behavior to preserve and decisions to resolve before shipping.

For agents, raw Markdown and Copy Markdown include the code and integration guidance below. Interactive previews alone do not expose their implementation in Markdown.

Complete example

Open the chat drawer to try one group conversation across multiple days. It rises from the bottom below md and opens from the right on wider screens. Select the member names below the title to view the group, mute a guest, remove them, or ban them. The current user is a co-host; host and co-host rows have no moderation menu. Scroll the timeline to see a date pill stay in view until the next day's messages arrive. Reply to a message, add a reaction, or type @ to mention someone. Sending a message triggers a simulated response, so you can try the typing and read states. Messages, membership, and drafts stay in local demo state.

Use the state triggers below the preview to open the drawer with a long message, pinned message, one or several people typing, first-message, loading, history-error, disconnected, access-lost, removed-from-chat, banned-from-chat, left-chat, unread, or delivery/retry example. The default conversation has no pinned message; choose Pinned message to see the pinned bar. The Long message state shows the expand and collapse control. The First message state introduces the event chat and shows who can access it; joining reveals the empty conversation and composer. The history-error, access-lost, removed-from-chat, banned-from-chat, and left-chat previews show a Coss Empty with no composer. Reconnect briefly shows its loading state before restoring the conversation, and Rejoin chat returns from the left-chat state. Opening a state resets sample messages and cancels pending replies, while keeping your draft. Loading and receipt examples stay in their illustrated states until you interact. Recovery and retry actions are local simulations.

Choose Message info from one of your messages’ action menus to inspect read, delivered, and pending recipients in a nested drawer. It uses the Coss nested-drawer pattern, keeping the conversation underneath. The selected message uses the same outgoing bubble and content as the conversation, and only recipient sections with people appear. The “Sounds good” message demonstrates partial receipts. Back, Escape, or dismissing the nested drawer returns to the conversation with your draft and reading position preserved.

Interactive preview

The studio

Design, details, and everything in between.

Jordan·See you at two. I’ll bring the notes.

10:51
Explore chat states

Choose Leave chat in the group members drawer footer to open a confirmation. Cancel keeps the conversation and draft; confirming shows “You left this chat,” clears the draft, and stops simulated replies. Rejoin chat restores the conversation. The event ticket stays valid. This is a local demo interaction.

Member moderation

Remove from chat kicks the person out by ending their membership. It does not create a ban: they can join again through their ticket invitation, subject to eligibility. The confirmation explains this, and the Removed from chat demo state includes Rejoin chat.

Ban from chat also removes membership and creates a persistent ban. After confirmation, the person appears under Banned users and cannot join again until a moderator selects Unban. The Banned from chat state has no rejoin action. In the intended UI flow, Unban returns the person to Current members and restores their access. Their old receipt watermark is not restored.

Both actions preserve the person's messages, quoted replies, reactions, and event ticket. A mute remains independent and is not cleared by leaving, removal, banning, or unbanning. Message info uses the current recipient list.

Try the full moderator flow by muting Alex, banning them, and then selecting Unban. Alex returns to Current members with the Muted badge preserved, and their messages remain visible throughout. The Removed from chat and Banned from chat state triggers show the two different guest access screens.

Muting currently demonstrates the moderator's member-list action and badge. A muted guest's own view, with readable history and a notice replacing the composer, is not yet demonstrated.

For integration, supply the current roster and ban list independently. PR #9727 provides the persisted ban data; the consuming app must wire Remove to a moderator-authorized membership-removal operation and Ban to the ban operation. The PR's unbanMember only deletes the restriction: the intended Unban UI also requires a server operation that restores eligible membership. The backend must check eligibility and authorize the combined operation before the UI moves the person back. The demo simulates its successful result.

The current PRO-2343 description still uses “Remove” for a ban. That engineering contract needs to be reconciled with the distinct Remove and Ban actions demonstrated here.

Message moderation

The demo's current user, Sam, is an accepted co-host. Open the ellipsis menu beside Alex Morgan’s message, select Delete message, and confirm. Cancel preserves the message; confirmation replaces it with Removed by a host, removes its reactions and any pin, and updates the quote in Sam’s reply. You can try this in the Pinned message state to see the pin disappear too.

Hosts and accepted co-hosts may delete guests’ messages, including messages from removed or banned guests. Everyone may delete their own messages, which show Message deleted. Other hosts’ and co-hosts’ messages are protected, so their menus omit Delete. Deletion erases the body and has no Undo. The consuming app must enforce these permissions server side, as specified in PRO-2342.

One Chat UI family

The example is built from a cohesive family of ready-made chat components in @coss/ui/chat/chat-*. The components share layout, styling, and interaction conventions and can be composed in a drawer or directly in a page. They are being prepared for rebuilding in Coss; they are not published registry entries yet.

ChatDatePill, ChatUnreadDivider, ChatJumpToLatestButton, ChatTypingIndicator, ChatDeliveryIndicator, and ChatReplyReference each have a matching chat-* module. ChatMessageBubble is available for standalone previews, such as message info. ChatBubble and ChatMessageLayout live alongside the other chat components and can also be imported directly for custom compositions.

ChatMessage formats plain string children automatically. Supply React content for quotes, attachments, or custom text formatting; use ChatMessageText when you need to highlight known mentions. Supply actions, reactions, and footer as needed. grouped controls consecutive-message layout, and bubble={false} supports unavailable-message content. bubbleProps forwards native bubble props, a ref, and styling. The deleted-message placeholder is currently composed in the demo using bubble={false}; it is not yet a separately exported Chat UI component.

The consuming app owns message data, ordering/grouping, membership, permissions, translated labels, delivery calculations, and persistence. useChatScroll from @coss/ui/chat/hooks/use-chat-scroll owns following the conversation: it keeps the viewport at the end while content resizes, preserves reading position when scrolled up, and powers the jump control. Set scroll.atBottomRef.current = true before adding an outgoing message so sending returns to the latest message. Use paused while another panel or focused reply owns the viewport.

Chat compositions use the shared Coss Composer. The caller owns value, onValueChange, and onSubmit, and clears an accepted draft. Use getComposerText(value).trim() for a plain-text message. The inline example renders a native textarea; the drawer uses the default contenteditable input with mention triggers. Set submitOnEnter="desktop" so Return inserts a newline on touch devices and the send button submits. Supply accessible input and send-button labels.

ChatCollapsibleMessage owns text-only preview/expansion, with controlled open/onOpenChange or an uncontrolled defaultOpen. renderText can style known mentions using ChatMessageText. Attachments and other arbitrary bubble children remain outside text truncation. ChatMessage accepts animateEntrance for live arrivals, highlighted for attention feedback, and onAnimationComplete; history defaults to no entrance animation. Motion respects reduced-motion preferences. ChatBubble and ChatMessageLayout have no Motion dependency.

Minimal working example

This smaller example uses ChatConversation, ChatMessage, and Composer with useChatScroll, without an event or drawer. Messages use plain string children and the composer has no mention configuration. All messages remain local to this example.

Enter to send / Shift + Enter for a new line

The following is the complete source of apps/design-system/components/chat-ui-inline-example.tsx. It uses the existing workspace package and Coss theme setup. Add it to a client component; replace its local array and synthetic copy when connecting the app.

chat-ui-inline-example.tsx

This example deliberately demonstrates local sending only. Its array indexes, own flag, and immediate success are fixtures, not a production message contract. Use stable server IDs, reconcile optimistic messages, and translate labels in the application. Keep the existing Coss stylesheet and Tailwind source discovery; do not install another copy of Base UI or Motion for this composition.

Implement in the Cal app

1. Keep data and policy in the application

Build the client controller in the owning apps/web/modules feature when it depends on tRPC, authentication, or routing. Domain behavior belongs in the owning service; the Chat UI family receives view data and callbacks. Import family components directly from @coss/ui/chat/*; keep demo fixtures in the design-system app.

Read openspec/specs/cal-events-chat/spec.md for event eligibility and service rules. Use the current API and DTO definitions from the backend work; the names in the following table describe data responsibilities, not new endpoint or DTO names. The demo's CHAT_MEMBERS, MemberId, scenario flags, timers, and receipt fixtures must not become production contracts.

2. Map data to the component contracts

For receipts, distinguish “some recipients have read” from “everyone has read.” The demo shows blue double checks only when all current recipients have read, gray double checks when all have at least received it, and one check otherwise. Sending/failure state takes precedence. Confirm the recipient set and acknowledgement contract with the backend; never infer delivery or reading from a timer, connected socket, or scroll position alone.

3. Wire mutations and live updates

  1. Load and join: resolve eligibility and membership before exposing history or member data. Join is an explicit server operation. Do not equate having a ticket with having joined the chat.
  2. Send and retry: accept a draft into an optimistic message or await a successful send, then clear that accepted draft. Set scroll.atBottomRef.current = true before appending your outgoing message. Preserve failed content with a Retry action; reconcile server events with the pending message instead of appending it twice.
  3. Receive: merge updates by stable ID and order by the backend's sequence contract. Update reactions, typing, receipts, and membership from their authoritative data. Expire stale typing indications; never present typing as online presence.
  4. Moderate: confirm deletion, removal, and banning. Keep the dialog pending during the request and handle failure without reporting success. Reconcile the returned roster/message state, including other clients. Keep confirmation copy consistent with the operation's real semantics.
  5. Reconnect: show loading on Reconnect, retain readable cached history only while access is still valid, and reauthorize. Refresh the latest history page as well as catching up newer messages: a deletion can affect an old sequence position. Refocus the composer only if sending is permitted and focus is not in another open panel.

Deletion must clear all rendered copies of the body: the timeline, reply references, focused reply, and message-info preview. Remove reactions and a matching pin. Retain the message row, author, and time with the correct deletion reason. Do not keep erased text in the client cache or offer an Undo that the backend cannot perform.

Moderation permissions

This is the intended event-chat presentation. Treat server authorization as authoritative, including if permissions change while a menu is open.

Muting blocks sending and typing, while retaining reading and receipt acknowledgement. Removing or banning a member does not delete their historical messages. Only explicit message deletion produces “Message deleted” or “Removed by a host.”

Contract decisions

The visual flow is agreed; these backend differences must be resolved before connecting the corresponding controls. The observations below refer to the reviewed PR #9727 head 73423a57beafb97f44c0983879ed08ba354cfd8c and the moderation ticket descriptions reviewed on October 9, 2026. Recheck the implementation before wiring it.

4. Compose the responsive drawer

Use apps/design-system/components/chat-ui-demo.tsx as the shell reference. Port its composition into the owning feature; replace its controller and fixture data. Keep these settings together:

Coss styles and the current package dependencies are already part of the Cal workspace. Keep the existing primitives intact. The architecture handoff in openspec/specs/design-system/design/chat-ui.md records intentional overrides and the later Coss distribution work.

Focused reply and nested focus

The focused reply is an optional composition; it is not enabled by merely rendering ChatReplyPreview. Preserve the existing interaction when implementing the full demo:

  1. Capture the original bubble element through bubbleProps.ref, its width, grouping, and expanded state before selecting it. Keep the message/avatar in their original layout slot while the source is lifted.
  2. Render ChatReplyPreview in ChatConversation.overlay, inside AnimatePresence. Supply the same message content and source geometry. Mark the underlying timeline inactive and pause useChatScroll while the overlay owns the viewport.
  3. On cancel or send, set the dismissal ref to the corresponding value and start the presence exit. Retain the reply geometry until onExitComplete, then clear it and restore scroll/focus. On main-drawer dismissal, wait for onOpenChangeComplete before resetting the reply.
  4. Keep member and message-info drawers nested in the parent drawer context. Return focus to their initiating controls and preserve the main draft/reading position. Confirmation dialogs must also restore focus on cancel; after deletion, focus the retained placeholder rather than its removed menu trigger.

See chat-ui-demo-reply-focus.tsx, chat-ui-demo-message.tsx, and the presence/focus callbacks in chat-ui-demo.tsx together. Copying only the animation component omits the lifecycle needed for a stable return transition.

Access and recovery states

The demo states are isolated examples, not a precedence resolver. In the app, access denial takes precedence over cached history; a ban takes precedence over a rejoin action; mute takes precedence over enabling send after reconnect. Dispose of old-room subscriptions and pending callbacks when switching rooms.

Accessible receipts and popup dismissal

Delivery indicators expose a labelled image, not a live region. Keep important send failures in one application-owned status region; do not announce the same failure from each receipt. Supply labels for sending, sent, delivered, read, and failed states.

Message menus and reactors popovers dismiss on deliberate input scrolling their conversation. Automatic scrolling, incoming messages, menu keyboard navigation, and scrolling unrelated containers must keep them open. Reaction inspection keeps the 500ms touch hold and 8px movement tolerance; a short tap still toggles the reaction. Preserve reduced-motion behavior.

Integration checklist

  • Resolve the contract decisions; enforce authorization and eligibility on the server, including stale-menu requests.
  • Use stable IDs and reconcile optimistic sends, retries, duplicate live events, and deletion updates across every message preview.
  • Implement and demonstrate the muted guest's own view. The moderator's Muted badge does not cover this state.
  • Supply translated labels, accessible action names, localized dates/times, and empty/error copy. Demo English is synthetic.
  • Keep useChatScroll active only for the visible conversation, pause it for focused/nested UI, and reset it only when changing rooms. Implement older-history anchoring separately: capture the viewport before prepending and preserve the visible message afterward. The hook does not fetch, paginate, virtualize, or compensate for prepended pages.
  • Use actual recipient acknowledgements. onReachEnd only reports scrolling near the bottom; it is not proof the user read every message. Confirm the recipient set after membership changes.
  • Exercise long drafts with the mobile keyboard, desktop Enter/Shift+Enter, IME composition, mention selection, nested focus return, and reduced motion. Preserve the approved animation and drawer defaults.
  • Verify failure as well as success for join, reconnect, send/retry, deletion, and moderation. Keep focused reply cancellation and draft preservation working throughout.

Source map

Paths below are relative to the Cal repository. Import reusable components from the package; consult demo files for composition without introducing a dependency on apps/design-system.

Single conversation drawer

The drawer has a header, a scrolling message timeline, and a composer. The middle is a repeater, so the map labels the components that can appear inside it. It combines conditional states and actions for reference; they do not all appear at once.

Drawer and timeline

  • Conversation header — a plain title and a short list of member names that opens the nested member drawer, using the Coss header, title, and description defaults.
  • Chat panel layout — the drawer shell positions the header, scrolling history, and composer; it also needs narrow and wide layouts.
  • Message timeline — repeats grouped messages and keeps scrolling stable as history loads.
  • Older history loading — feedback while fetching earlier messages.
  • Timeline separators — sticky date pills and new-message markers.
  • Jump to latest — returns to recent messages when the reader has scrolled up.
  • Timeline states — first-message/join, initial loading, history error, disconnected with reconnecting feedback, access lost, removed from chat, banned from chat, and left chat.

Message parts

  • Message bubble — incoming and outgoing messages, sender, time, wrapping, grouping, a smaller top corner on the sender's side for the first bubble only, and slots for actions and reactions.
  • Delivery indicator — clock while sending, one check when sent, gray double checks when delivered to everyone, blue double checks when read by everyone, and failure/retry.
  • Message info — a nested drawer with the selected message and mutually exclusive Read by, Delivered to, and Pending delivery lists, reached from your own message’s action menu.
  • Reply reference — a quoted message inside a bubble, including an unavailable-message fallback; hover and focus use the primary color on the reply arrow and author without dimming the text.
  • Deleted message placeholder — preserves the sender, time, and timeline position; displays “Message deleted” or “Removed by a host” according to the deletion reason, with matching quoted-reply text and no Undo.
  • Mention text — uses medium font weight for a mentioned participant, including current-user and former-member states.
  • Translated message treatment — exploratory presentation of a translation and its source.

Actions and activity

  • Message actions — a toolbar sits beside the bubble, on the left for your messages and the right for others, with reaction and reply buttons and an ellipsis menu. The menu offers Copy, Pin/Unpin, Message info on your messages, and Delete where permitted. Controls support pointer, keyboard, and touch activation; a message context menu, long-press message menu, and forwarding action remain exploratory.
  • Reaction picker — chooses a reaction.
  • Reaction strip — shows reactions attached to a message.
  • Swipe action treatment — exploratory gesture affordance for replying.
  • Forward and pin presentation — pin/unpin is interactive and the Pinned message state shows the pinned bar. The Forwarded marker is sample content; forwarding is not implemented.
  • Edit-message concept — exploratory; editing is not yet a decided chat feature.
  • Typing indicator — one three-dot bubble names one or several people typing, with a soft entrance and exit and a staggered dot pulse. Keep it mounted and use its visible prop to animate its exit. Reduced motion disables these animations.

Composer

  • Focused reply — lifts the selected bubble above the composer while the conversation blurs. Cancel, Escape, or clicking the blurred background returns it to its original position and preserves the draft. Sending restores the conversation at the new reply.
  • Mention suggestions — a participant picker shown after typing @, filtered from the supplied options, with avatars, names, and keyboard selection. No popup appears when no members match. Loading and empty-state presentations are not implemented.
  • Message composer — text entry, multiline input, send, disabled, sending, failed and retry states.

Conversation list

The dashboard may need discovery across chats. The following map is an exploration inventory; working conversation-list, search, and list-state components are not implemented in this example or exported by the Chat UI family.

  • Conversation search input — filters the list.
  • Conversation list — repeats list items and supports keyboard selection.
  • Conversation list item — avatar, title, preview, timestamp, unread state, and typing, deleted-message, and mention variants.
  • Conversation list states — loading, no conversations, no search results, and recoverable error.

Exploration notes

  • Editor approach — both examples use the shared Coss Composer. The drawer enables @ mentions in its contenteditable input; the inline example renders a plain textarea. The agent UI uses Lexical; replacing that editor remains a separate investigation.
  • Motion details — the demo animates arriving messages, focused replies, quote navigation, reactions, and typing indicators, with reduced-motion support. Swipe interactions remain exploratory.

The anatomy maps also show exploratory components and states that are not part of the working example. They do not decide package ownership or commit every feature to the first product release.