Component reference
ChatComposer
The message input of a conversation, with attachments, model choice and send.
Preview
Short drafts and multi-line editing
Short drafts sit on the same row as actions; long drafts grow naturally. Supporting instructions appear outside the writing surface on focus. Local editing only.
render: () => (
<Measure width="content">
<ChatComposer
layout="inline"
hint="Enter for a new line · Use Send to submit"
hintId="inline-composer-hint"
onSubmit={(event) => event.preventDefault()}
>
<Button variant="quiet" size="small" aria-label="Add attachment">
<Icon name="plus" />
</Button>
</ChatComposer>
</Measure>
)Installation
Usage
Taken from the examples on this page.
import { ChatComposer } from "meowtail";
<ChatComposer
layout="inline"
hint="Enter for a new line · Use Send to submit"
hintId="inline-composer-hint"
onSubmit={(event) => event.preventDefault()}
>
<Button variant="quiet" size="small" aria-label="Add attachment">
<Icon name="plus" />
</Button>
</ChatComposer>Examples
The other stories that show this component.
Identify, remove, and keep writing
Images are identified by thumbnails and files keep their names; sending saves only a local snapshot, and neither waiting nor failure loses the draft you keep editing.
render() {
const draft = signal("Use this sketch to check the reading order on a narrow screen.");
const pending = signal(false);
const feedback = signal(
"Local simulation. Attachments are not uploaded and messages are not sent to a service. ",
);
const submitted = signal("Not submitted yet.");
const attachments = signal<Child>(null);
let files = [...sampleFiles];
let preview: HTMLDialogElement | undefined;
const previewName = signal("");
const previewContent = signal<Child>(null);
const show = (file: (typeof sampleFiles)[number]) => {
previewName.value = file.name;
previewContent.value = file.mediaType.startsWith("image/") ? (
<img src={file.url} alt={file.name} style={{ maxWidth: "100%", maxHeight: "50vh" }} />
) : (
<p>
Check the narrow-screen reading order, long file names, and continued editing. Shows a
built-in review record; no local file is read.
</p>
);
preview?.showModal();
};
const refresh = () => {
attachments.value = files.length ? (
<ChatAttachments
label="Pending attachments"
items={files.map((file) => ({
...file,
onPreview: () => show(file),
onRemove: () => {
files = files.filter((entry) => entry.id !== file.id);
refresh();
feedback.value = `Removed ${file.name}. The draft is unchanged.`;
},
}))}
/>
) : null;
};
refresh();
return (
<Measure width="content">
<Stack gap="relaxed">
<Stack gap="compact">
<strong>Check the reading order in the workspace</strong>
<span>Confirm the attached materials first, then write down what to move forward.</span>
</Stack>
<ChatComposer
value={draft}
attachments={attachments}
submitDisabled={pending}
inputLabel="Working draft"
hint="You can keep editing while waiting for a result; removing an attachment only affects the next submission."
hintId="input-attachment-hint"
onInput={(event) => {
draft.value = (event.target as HTMLTextAreaElement).value;
}}
onSubmit={(event) => {
event.preventDefault();
if (pending.value) return;
submitted.value = `This submission: ${draft.value} · ${files.length} attachment${files.length === 1 ? "" : "s"}`;
pending.value = true;
feedback.value =
"Simulating locally; you can keep editing the next draft while waiting.";
}}
>
<Button
variant="quiet"
size="small"
onClick={() => {
files = [...sampleFiles];
refresh();
feedback.value = "Restored the two built-in sample attachments.";
}}
>
<Icon name="paperclip" />
Restore sample attachments
</Button>
</ChatComposer>
<Stack gap="compact">
<p data-submitted-snapshot>{submitted}</p>
<ActionRow>
<Button
variant="outline"
size="small"
onClick={() => {
if (!pending.value) return;
pending.value = false;
feedback.value =
"Simulated send failure. The latest draft and current attachments were kept; you can send again.";
}}
>
Simulate failure
</Button>
<Button
variant="quiet"
size="small"
onClick={() => {
if (!pending.value) return;
pending.value = false;
feedback.value =
"Simulated send success. The latest draft and current attachments are still kept.";
}}
>
Simulate success
</Button>
</ActionRow>
<Note role="status">{feedback}</Note>
</Stack>
<Dialog
ref={(element: HTMLDialogElement) => {
preview = element;
}}
labelledBy="input-preview-title"
>
<DialogPanel
header={
<DialogHeader
title={previewName}
titleId="input-preview-title"
description="Built-in material preview"
/>
}
>
<Stack gap="normal">
{previewContent}
<Inline justify="end">
<Button variant="outline" onClick={() => preview?.close()}>
Back to draft
</Button>
</Inline>
</Stack>
</DialogPanel>
</Dialog>
</Stack>
</Measure>
);
}Send, failure, and recovery
Local simulated states are advanced by hand, with no timer or network. You can keep editing while waiting; submitted content and the latest draft are kept separately.
render: () => (
<div className="workshop-composition">
<ChatComposer
value="Check component states in light and dark themes, and keep this input I am editing."
submitLabel="Submit check"
hint="Local send simulation: you can keep writing while waiting, and the latest draft is not cleared."
hintId="recovery-hint"
>
<IconButton label="Attachments not available yet" disabled>
<Icon name="plus" />
</IconButton>
</ChatComposer>
<Note data-composer-submitted>No content submitted yet.</Note>
<div className="workshop-row">
<Button variant="outline" data-composer-result="failure" disabled>
Simulate failure
</Button>
<Button variant="outline" data-composer-result="success" disabled>
Simulate success
</Button>
<Button variant="quiet" data-composer-recover disabled>
Recover and keep editing
</Button>
</div>
<Note role="status" data-composer-feedback>
Waiting for submission. Local demo only.
</Note>
</div>
)Model state, keyboard hint and send
The writing surface contains draft and bottom actions; keyboard help follows outside its rounded edge. Controls only change fixture data; the story adds no layout styles.
render() {
const draft = signal("");
const trailing = signal<Child>(<ChatStatus label="No model connected" tone="idle" />);
const attachmentsHidden = signal(true);
const helpCount = signal(0);
const sentCount = signal(0);
return (
<Measure width="content">
<Stack gap="relaxed">
<h2>Stacked composer footer</h2>
<Inline wrap gap="compact">
<Button
variant="quiet"
onClick={() => {
trailing.value = <ChatStatus label="No model connected" tone="idle" />;
}}
>
Disconnected model
</Button>
<Button
variant="quiet"
onClick={() => {
trailing.value = (
<ChatStatus
label="No model connection is available for the currently selected workspace and provider profile"
tone="waiting"
/>
);
}}
>
Long status
</Button>
<Button
variant="quiet"
onClick={() => {
trailing.value = (
<ChoiceSelect
aria-label="Model"
defaultValue="long"
options={[
{
value: "long",
label:
"AnUnbrokenProviderAndModelIdentifierThatStillNeedsAReachableSelector",
},
]}
placement="top"
/>
);
}}
>
Long model name
</Button>
<Button
variant="quiet"
onClick={() => {
attachmentsHidden.value = !attachmentsHidden.value;
}}
>
Toggle attachments
</Button>
</Inline>
<ChatComposer
value={draft}
inputLabel="Footer draft"
hintId="stacked-footer-hint"
hint={[
"Enter for a new line · Use Send to submit. ",
<a
href="#footer-help"
onClick={(event: MouseEvent) => {
event.preventDefault();
helpCount.value++;
}}
>
Keyboard help
</a>,
]}
trailing={trailing}
attachments={
<div hidden={attachmentsHidden}>
<ChatAttachments
items={[
{
id: "notes",
name: "footer-notes.md",
mediaType: "text/markdown",
url: "data:text/plain,Local%20fixture",
},
]}
/>
</div>
}
onInput={(event) => {
draft.value = (event.currentTarget as HTMLTextAreaElement).value;
}}
onSubmit={(event) => {
event.preventDefault();
sentCount.value++;
}}
submitLabel="Send draft"
>
<IconButton label="Attach files">
<Icon name="plus" />
</IconButton>
</ChatComposer>
<p id="footer-help">
Help opened: {helpCount}. Submissions: {sentCount}.
</p>
</Stack>
</Measure>
);
}API reference
Props
| Prop | Type | Default |
|---|---|---|
layout | "stacked" | "inline" | "hero" | "bar" | "stacked" |
surface | "default" | "none" | "default" |
value | MaybeSignal<string> | "" |
placeholder | string | "Write a message…" |
inputLabel | string | "Message" |
attachmentsDraft attachments belong to the same writing surface; upload ownership stays with the host. | Child | – |
trailingModel/provider selection beside the send action; options and state belong to the host. | Child | – |
hintSupporting instructions below the writing surface, associated through hintId. | Child | – |
hintId | string | – |
submitLabel | string | "Send" |
submitContentVisible action content; the accessible name always comes from submitLabel. | Child | – |
disabled | MaybeSignal<boolean> | false |
readOnly | MaybeSignal<boolean> | false |
submitDisabledPrevent another submission while keeping the draft editable. | MaybeSignal<boolean> | false |
onSubmit | ChatComposerSubmit | – |
onInput | ChatComposerInput | – |
Also takes className, children and the attributes and events of its root element.
DOM parts
Registry
The component is registered as chat-composer in the meowtail component registry.