Chat
Shared ComponentsPresentational conversations with a controlled history and an asynchronous composer.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { Chat, type ChatMessage } from "@/components/ui/chat";
import { useState } from "react";
const initial: ChatMessage[] = [
{
id: "one",
author: "Sam Rivera",
text: "The new workspace is ready for a thoughtful first look. What do you think?",
time: "10:40",
},
{
id: "two",
author: "You",
text: "The small details feel great. Let’s give the keyboard flows one more pass.",
time: "10:42",
outgoing: true,
status: "delivered",
},
{
id: "three",
author: "Sam Rivera",
text: "On it. I’ll start with navigation and the new forms.",
time: "10:43",
},
];
export default function ChatExample() {
const [messages, setMessages] = useState(initial);
const [fail, setFail] = useState(false);
return (
<div className="w-full max-w-xl space-y-6">
<Chat
label="Launch room"
messages={messages}
typing="Alex"
onSend={async (text) => {
await new Promise((resolve) => setTimeout(resolve, 120));
if (fail) throw new Error("Preview failure");
setMessages((previous) => [
...previous,
{
id: crypto.randomUUID(),
author: "You",
text,
time: "10:44",
outgoing: true,
status: "sent",
},
]);
}}
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={fail}
onChange={(event) => setFail(event.target.checked)}
/>
Simulate a failed send
</label>
<p className="text-xs text-muted-foreground">
Messages stay in this preview. No network requests or notifications are
sent.
</p>
<Chat label="Read-only archive" messages={initial.slice(0, 1)} />
</div>
);
}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 { 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.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Conversation title and accessible name. |
| messages | ChatMessage[]Default: required | Controlled history with unique ids and plain text. |
| onSend | (text: string) => void | Promise<void>Default: — | Enable sending; reject to retain the draft. |
| typing | stringDefault: — | Name to display as a typing status. |
| disabled | booleanDefault: false | Disable composer submission/editing. |
| maxLength | numberDefault: 2000 | Native maximum draft characters. |
| className | stringDefault: — | Extend the container with semantic theme utilities. |