Skip to content

Chat

Shared Components

Presentational conversations with a controlled history and an asynchronous composer.

Incoming/outgoing messagesDelivery labelsTyping stateLocal send and failure retryRead-only archive
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Own messages with stable ids, author, plain text, display time, and optional outgoing delivery status. Supply onSend to enable the composer, or omit it for a read-only archive. A resolved callback clears the submitted draft; rejection retains it and shows retry guidance. Pending sends disable duplicate submission. Typing is caller-supplied text. This component provides no transport, authentication, persistence, notification delivery, auto-response, or automatic scroll-to-bottom behavior.

Import
import { Chat, type ChatMessage } from "@/components/ui/chat";

Shipped as editable source in src/components/ui/chat.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/chat.tsx
"use client";
import { useId, useRef, useState } from "react";
import { Send } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Label } from "@dashboardpack/core/components/ui/label";
import { cn } from "@dashboardpack/core/lib/utils";
export interface ChatMessage {
  id: string;
  author: string;
  text: string;
  time: string;
  outgoing?: boolean;
  status?: "sent" | "delivered" | "failed";
}
export interface ChatProps {
  label: string;
  messages: ChatMessage[];
  onSend?: (text: string) => void | Promise<void>;
  typing?: string;
  disabled?: boolean;
  maxLength?: number;
  className?: string;
}
export function Chat({
  label,
  messages,
  onSend,
  typing,
  disabled,
  maxLength = 2000,
  className,
}: ChatProps) {
  const id = useId();
  const [draft, setDraft] = useState("");
  const [busy, setBusy] = useState(false);
  const inFlight = useRef(false);
  const [error, setError] = useState("");
  const [announcement, setAnnouncement] = useState("");
  const send = async () => {
    const text = draft.trim();
    if (!text || disabled || !onSend || inFlight.current) return;
    inFlight.current = true;
    setBusy(true);
    setError("");
    try {
      await onSend(text);
      setDraft((previous) => (previous === draft ? "" : previous));
      setAnnouncement("Message sent.");
    } catch {
      setError(
        "Message could not be sent. Your draft has been kept. Try again.",
      );
    } finally {
      inFlight.current = false;
      setBusy(false);
    }
  };
  return (
    <section
      aria-label={label}
      className={cn(
        "min-w-0 overflow-hidden rounded-xl border bg-card",
        className,
      )}
    >
      <div className="border-b p-4">
        <h3 className="text-sm font-semibold">{label}</h3>
        <p className="mt-1 text-xs text-muted-foreground">
          {messages.length} messages
        </p>
      </div>
      <div
        role="log"
        aria-live="polite"
        aria-relevant="additions text"
        aria-label={`${label} conversation`}
        tabIndex={0}
        className="max-h-80 space-y-4 overflow-auto p-4 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
      >
        {!messages.length && (
          <p className="py-8 text-center text-sm text-muted-foreground">
            Start the conversation.
          </p>
        )}
        <ol className="space-y-4">
          {messages.map((message) => (
            <li
              key={message.id}
              className={cn(
                "flex",
                message.outgoing ? "justify-end" : "justify-start",
              )}
            >
              <div className="max-w-[85%] space-y-1">
                <div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
                  <bdi className="font-medium">{message.author}</bdi>
                  <span>{message.time}</span>
                </div>
                <p
                  dir="auto"
                  className={cn(
                    "whitespace-pre-wrap break-words rounded-xl px-3 py-2 text-sm leading-6",
                    message.outgoing
                      ? "bg-primary text-primary-foreground"
                      : "bg-muted",
                  )}
                >
                  {message.text}
                </p>
                {message.outgoing && message.status && (
                  <p className="text-end text-[11px] text-muted-foreground">
                    {message.status === "failed"
                      ? "Not delivered"
                      : message.status === "delivered"
                        ? "Delivered"
                        : "Sent"}
                  </p>
                )}
              </div>
            </li>
          ))}
        </ol>
      </div>
      {typing && (
        <p role="status" className="px-4 pb-3 text-xs text-muted-foreground">
          {typing} is typing…
        </p>
      )}
      {onSend && (
        <form
          className="space-y-2 border-t p-4"
          onSubmit={(event) => {
            event.preventDefault();
            void send();
          }}
        >
          <Label htmlFor={id}>Message</Label>
          <div className="flex items-end gap-2">
            <textarea
              id={id}
              dir="auto"
              value={draft}
              maxLength={maxLength}
              disabled={disabled || busy}
              rows={2}
              placeholder="Write a thoughtful reply…"
              onChange={(event) => setDraft(event.target.value)}
              className="min-w-0 flex-1 resize-y rounded-lg border border-input bg-background p-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
            />
            <Button
              type="submit"
              size="icon"
              aria-label="Send message"
              disabled={disabled || busy || !draft.trim()}
            >
              <Send className="size-4" />
            </Button>
          </div>
          <p className="text-xs text-muted-foreground">
            {draft.length} / {maxLength}. Use Send to keep line breaks.
          </p>
          {error && (
            <p role="alert" className="text-xs text-destructive">
              {error}
            </p>
          )}
          <p role="status" className="text-xs text-muted-foreground">
            {announcement}
          </p>
        </form>
      )}
    </section>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

Chat props
PropType / DefaultDescription
labelstring

Default: required

Conversation title and accessible name.
messagesChatMessage[]

Default: required

Controlled history with unique ids and plain text.
onSend(text: string) => void | Promise<void>

Default: —

Enable sending; reject to retain the draft.
typingstring

Default: —

Name to display as a typing status.
disabledboolean

Default: false

Disable composer submission/editing.
maxLengthnumber

Default: 2000

Native maximum draft characters.
classNamestring

Default: —

Extend the container with semantic theme utilities.

Build with these too