Meowland docs
Workbench

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.

packages/meowtail/stories/chat-input.stories.tsxline 42tsx
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

bun add meowtail

Usage

Taken from the examples on this page.

usage.tsxtsx
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.

packages/meowtail/stories/chat-input.stories.tsxline 67tsx
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.

packages/meowtail/stories/chat.stories.tsxline 163tsx
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.

packages/meowtail/stories/composer-footer.stories.tsxline 26tsx
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

PropTypeDefault
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

rootsurfaceattachmentsfieldinputhintactionstrailingsubmit

Registry

The component is registered as chat-composer in the meowtail component registry.