Chat
Tabbed chat panel with reasoning replies and a composer.
"use client";
/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import "./styles/foundation.css";
import { useEffect, useRef, useState } from "react";
/* ─────────────────────────────────────────────────────────
* CHAT — interactive panel with tabs, replies, and composer.
* The reply sequence begins only after the user sends.
* ───────────────────────────────────────────────────────── */
type Phase = "idle" | "sent" | "reply1" | "reply2" | "done";
/* one scripted agent reply in the thread */
export type ChatMessage = {
label: string;
sub: string;
time: string;
body: string;
};
const MESSAGES: ChatMessage[] = [
{
label: "Sales History",
sub: "Project Data",
time: "4s",
body: "Pulled 3 summers of mint chip sales for comparison.",
},
{
label: "Comparison",
sub: "Trend Detection",
time: "2s",
body: "Mint chip is up 12% with stronger weekend peaks.",
},
];
const SUGGESTIONS = ["Projects", "Suppliers"];
export type ChatComposerLabels = {
/** the pre-filled prompt shown in the first user bubble */
initialPrompt: string;
/** composer input placeholder */
placeholder: string;
};
const DEFAULT_LABELS: ChatComposerLabels = {
initialPrompt: "Compare mint chip to last summer",
placeholder: "Prompt or tag a project with @",
};
function Section({
label,
sub,
time,
body,
resolving,
}: {
label: string;
sub: string;
time: string;
body: string;
resolving?: boolean;
}) {
return (
<div
className="flex w-full flex-col gap-1.5 transition-[opacity,filter,transform] duration-400"
style={{
opacity: resolving ? 0.55 : 1,
filter: resolving ? "blur(0.5px)" : "blur(0)",
transform: resolving ? "scale(0.985)" : "scale(1)",
transformOrigin: "top left",
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
animation: "fade-up 400ms cubic-bezier(0.23,1,0.32,1) both",
}}
>
<div className="flex items-center gap-1 text-[12px] leading-[1.3]">
<span className="font-medium text-ink">{label}</span>
<span className="text-ink-2">{sub}</span>
<span className="text-ink">for {time}</span>
</div>
<p className="text-[13px] leading-normal text-ink">{body}</p>
</div>
);
}
export default function ChatComposer({
messages = MESSAGES,
suggestions = SUGGESTIONS,
labels,
onSend,
}: {
variant?: string;
/** scripted agent replies revealed in sequence after the user sends */
messages?: ChatMessage[];
/** header chips (tabs) for switching context */
suggestions?: string[];
/** prominent copy strings */
labels?: Partial<ChatComposerLabels>;
/** fired with the trimmed prompt text when the user sends */
onSend?: (text: string) => void;
} = {}) {
const l = { ...DEFAULT_LABELS, ...labels };
const [phase, setPhase] = useState<Phase>("done");
const [draft, setDraft] = useState("");
const [submitted, setSubmitted] = useState(l.initialPrompt);
const [tab, setTab] = useState(suggestions[0] ?? "");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let t: ReturnType<typeof setTimeout>;
if (phase === "sent") t = setTimeout(() => setPhase("reply1"), 500);
else if (phase === "reply1") t = setTimeout(() => setPhase("reply2"), 1400);
else if (phase === "reply2") t = setTimeout(() => setPhase("done"), 1200);
else return;
return () => clearTimeout(t);
}, [phase]);
const sent = phase !== "idle";
const canSend = draft.trim().length > 0;
const send = () => {
if (!canSend) return;
const text = draft.trim();
setSubmitted(text);
onSend?.(text);
setDraft("");
setPhase("sent");
};
return (
<div className="flex h-[288px] w-full max-w-95 flex-col self-start overflow-hidden rounded-[14px] bg-card shadow-card">
{/* header — tabs + actions */}
<div className="flex shrink-0 items-center justify-between border-b border-line p-1.5">
<div className="flex items-center">
{suggestions.map((item) => (
<button
key={item}
type="button"
aria-pressed={tab === item}
onClick={() => setTab(item)}
className={`rounded-[6px] px-2 py-[3px] text-[13px] text-ink transition-[background-color,opacity] duration-100 ${tab === item ? "bg-field" : "opacity-50 hover:opacity-75"}`}
>
{item}
</button>
))}
</div>
<div className="flex items-center gap-1">
{[
<path key="p" d="M12 5v14M5 12h14" />,
<g key="h"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></g>,
<g key="e" fill="currentColor" stroke="none"><circle cx="5" cy="12" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="19" cy="12" r="1.8" /></g>,
].map((icon, i) => (
<button
key={i}
type="button"
aria-label="Action"
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
transition-colors duration-100 hover:bg-hover hover:text-ink-2"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{icon}
</svg>
</button>
))}
</div>
</div>
{/* conversation — fixed region so the card never changes shape */}
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-3 pt-2.5 pb-1">
{/* user bubble — right aligned, soft block */}
<div className="flex justify-end pl-14">
<div
className="rounded-xl bg-field px-3 py-1.5 text-[13px] leading-[1.4] text-ink
transition-[opacity,transform] duration-300"
style={{
opacity: sent ? 1 : 0,
transform: sent ? "translateY(0)" : "translateY(10px)",
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
{submitted}
</div>
</div>
{messages[0] && (phase === "reply1" || phase === "reply2" || phase === "done") ? (
<Section
label={messages[0].label}
sub={messages[0].sub}
time={messages[0].time}
body={messages[0].body}
/>
) : null}
{messages[1] && (phase === "reply2" || phase === "done") ? (
<Section
label={messages[1].label}
sub={messages[1].sub}
time={messages[1].time}
body={messages[1].body}
resolving={phase === "reply2"}
/>
) : null}
</div>
{/* composer */}
<div className="mt-auto shrink-0 p-1.5">
<div
role="presentation"
onClick={() => inputRef.current?.focus()}
className="flex cursor-text flex-col gap-2 rounded-control border border-line bg-field p-2.5 shadow-[0_1px_2px_rgba(0,0,0,0.035)] transition-[border-color,box-shadow] duration-150 focus-within:border-line-strong focus-within:shadow-[0_1px_2px_rgba(0,0,0,0.025)]"
>
<input
ref={inputRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") send();
}}
placeholder={l.placeholder}
aria-label="Chat prompt"
className="min-h-4.5 bg-transparent text-[13px] leading-[1.4] text-ink outline-none placeholder:text-ink-3"
/>
<div className="flex items-center justify-end">
<button
type="button"
aria-label="Send"
disabled={!canSend}
onClick={send}
className="flex size-7 items-center justify-center rounded-[8px]
transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.96]"
style={{
background: canSend ? "var(--ink)" : "var(--line-strong)",
color: canSend ? "var(--card)" : "var(--ink-2)",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
</button>
</div>
</div>
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add @ward/chat-composernpx shadcn@latest add @ward/chat-composeryarn dlx shadcn@latest add @ward/chat-composerbunx --bun shadcn@latest add @ward/chat-composerAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/chat-composer.json
Add the Ward items it builds on.
npx shadcn@latest add @ward/foundationCopy each file from the Code tab into:
components/ward/ChatComposer.tsx
Update the import paths to match your project.
Usage
import ChatComposer from "@/components/ward/ChatComposer";<ChatComposer
suggestions={["Orders", "Customers"]}
labels={{ initialPrompt: "Summarise last week's orders", placeholder: "Ask about orders" }}
messages={[
{ label: "Orders", sub: "Database", time: "3s", body: "Found 412 orders from last week." },
{ label: "Summary", sub: "Analysis", time: "1s", body: "Revenue is up 6% on the week before." },
]}
onSend={(text) => console.log(text)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | string | "Default" | Accepted for registry parity; ChatComposer has no visual variants. |
messages | ChatMessage[] | MESSAGES | Scripted agent replies revealed in turn after the user sends; only the first two are shown. |
suggestions | string[] | SUGGESTIONS | Tab labels in the header for switching context, with the first one selected. |
labels | Partial<ChatComposerLabels> | - | Overrides for the pre-filled first prompt and the input placeholder. |
onSend | (text: string) => void | - | Called with the trimmed prompt text when the user sends. |
Ward · Version 1.1.0 · Registry manifest