# Composer

> Prompt input for chat and agents: auto-growing textarea, attachments, toolbar slot, send→stop button, paste/drop files and a length hint.

- **Slug:** `composer` · **Category:** input · **Status:** beta
- **Import:** `import { Composer, ComposerActions, ComposerAttachButton, ComposerAttachments, ComposerHint, ComposerInput, ComposerMessage, ComposerStatus, ComposerSubmit, ComposerToolbar, ComposerTools } from "@kura-ui/react/composer";`
- **Docs:** https://kuro.abstergo.fyi/components/composer
- **Registry item:** https://kuro.abstergo.fyi/r/composer.json
- **Tags:** prompt, chat, input, textarea, composer, send, attachments, streaming

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/composer.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add composer
# npm package
npm install @kura-ui/react
```

## When to use

- The message box of a chat UI or agent console (“Ask anything…”).
- Any prompt field that should send on Enter, accept pasted or dropped files, and stop a streaming response.
- A prompt input that hosts a model picker, tool toggles, voice input or a slash-command menu in its toolbar.

## When not to use

- A single-line search or filter field — use a plain input or command-palette.
- Showing attachments outside an input (sent messages, file lists) — use attachment.
- Picking a model on its own — use model-picker.
- Voice-only input — use voice-dictation (it can also feed a composer).

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `value` | `string` | — | no | Controlled text. |
| `defaultValue` | `string` | `""` | no | Initial text when uncontrolled. |
| `onValueChange` | `(value: string) => void` | — | no | Called on every text change. |
| `attachments` | `AttachmentItem[]` | — | no | Controlled attachments ({ id, name, size?, type?, url?, status?, progress?, error?, file? }). |
| `defaultAttachments` | `AttachmentItem[]` | — | no | Initial attachments when uncontrolled. |
| `onAttachmentsChange` | `(attachments: AttachmentItem[]) => void` | — | no | Called when files are added or removed. |
| `onFilesAdded` | `(items: AttachmentItem[]) => void` | — | no | Called with newly accepted files (paste, drop or picker) — start uploads here and update `attachments` with status/progress. |
| `onFilesRejected` | `(files: File[]) => void` | — | no | Called with files rejected by accept, maxFileSize or maxFiles. |
| `onSubmit` | `(message: ComposerMessage) => void` | — | no | Send handler: receives { text (trimmed), attachments }. Not called while empty, over maxLength, uploading or busy. |
| `onStop` | `() => void` | — | no | Called by the stop button and by Escape while status is submitted or streaming. |
| `status` | `"idle" \| "submitted" \| "streaming" \| "error"` | `"idle"` | no | Request lifecycle. submitted = waiting for the first token (stop button with a travelling arc), streaming = model writing (stop button). |
| `disabled` | `boolean` | `false` | no | Disable typing, attaching and sending (60% opacity). |
| `maxLength` | `number` | — | no | Character limit. The hint turns amber at 90% and red above; sending is blocked when over. |
| `submitKey` | `"enter" \| "mod+enter"` | `"enter"` | no | enter = Enter sends and Shift+Enter adds a newline; mod+enter = ⌘/Ctrl+Enter sends and Enter adds a newline. |
| `accept` | `string \| false` | `""` | no | Allowed files, like <input accept> ("image/*,.pdf"). "" accepts any file; false disables attachments entirely. |
| `maxFiles` | `number` | — | no | Maximum number of attachments. |
| `maxFileSize` | `number` | — | no | Maximum size of one file in bytes. |
| `clearOnSubmit` | `boolean` | `true` | no | Clear text and attachments after a submit. |
| `placeholder` | `string` | — | no | Placeholder for the default layout (only used when Composer has no children). |

Native attributes of the root element, `className` and `ref` are passed through.

## Anatomy

- **Composer** (`data-slot="composer"`) — Root and drop target. data-status, data-focused, data-dragging, data-disabled; aria-busy while submitted/streaming. With no children it renders the complete default layout.
- **ComposerAttachments** (`data-slot="composer-attachments"`) — The attachment row (renders nothing when empty). Uses attachment's AttachmentList.
  - `max`: `number` — Collapse into a +N stack after this many.
- **ComposerInput** (`data-slot="composer-input"`) — Auto-growing <textarea>. Enter/Shift+Enter, IME-safe, paste files, Escape stops. Accepts all textarea props.
  - `minRows`: `number` = `1` — Rows when empty.
  - `maxRows`: `number` = `8` — Rows before it scrolls instead of growing.
- **ComposerToolbar** (`data-slot="composer-toolbar"`) — Bottom row; holds ComposerTools (left) and ComposerActions (right).
- **ComposerTools** (`data-slot="composer-tools"`) — Left group: attach button, model-picker, tool toggles.
- **ComposerActions** (`data-slot="composer-actions"`) — Right group: hint, voice-dictation, submit.
- **ComposerAttachButton** (`data-slot="composer-attach"`) — Paperclip icon button that opens the file picker.
  - `label`: `string` = `"Attach files"` — Accessible label and tooltip.
  - `icon`: `React.ReactNode` — Replace the paperclip.
- **ComposerHint** (`data-slot="composer-hint"`) — Character count with a mini gauge (with maxLength) or a ~token estimate. data-level = ok | near | over.
  - `mode`: `"characters" | "tokens"` — Default: characters when maxLength is set, tokens otherwise.
  - `showAt`: `number` = `0` — Only show once text reaches this fraction of maxLength (e.g. 0.8).
  - `countTokens`: `(text: string) => number` — Custom tokenizer for mode=tokens (default ≈ chars/4).
- **ComposerSubmit** (`data-slot="composer-submit"`) — Send button (ink when ready, quiet when empty) that becomes a stop button while busy; data-mode = send | stop.
  - `sendLabel`: `string` = `"Send message"` — Accessible label while idle.
  - `stopLabel`: `string` = `"Stop generating"` — Accessible label while busy.
  - `tooltip`: `boolean` = `true` — Tooltip with shortcut.

## Accessibility

- The textarea is labelled “Message” by default (override with aria-label or aria-labelledby) and has enterkeyhint=send.
- Over the character limit the textarea gets aria-invalid and the hint tells screen readers the limit was exceeded (announced once, not per keystroke).
- The submit button swaps its accessible name between “Send message” and “Stop generating”; the root sets aria-busy while a response is pending.
- The focus glow is on the whole composer (focus-within), so the textarea's own outline is replaced, never just removed.
- Attachments are a labelled list with “Remove <file>” buttons; removing returns focus to the textarea.
- Reduced motion: The send/stop icon swap and attachment enter/exit become plain fades (no scale or blur); the pending arc stops travelling and pulses instead.

| Keys | Action |
| --- | --- |
| Enter | Send (submitKey=enter). Ignored while an IME is composing. |
| Shift+Enter | Insert a newline |
| Mod+Enter | Send (both submitKey modes) |
| Escape | Stop the response while submitted/streaming |
| Mod+V | Paste text, or attach pasted files/images |

## Notes for agents

- Drive `status` from your chat hook: set "submitted" when you send, "streaming" on the first token, back to "idle" when done; pass `onStop` to abort.
- With the Vercel AI SDK: status maps 1:1 from useChat's status ('ready' → 'idle').
- Compose parts in this order: ComposerAttachments, ComposerInput, ComposerToolbar > (ComposerTools, ComposerActions > ComposerHint, ComposerSubmit).
- Use `useComposer()` inside custom parts to read value, setValue, attachments, addFiles, submit, status.
- Attachment object URLs are revoked when removed, not when sent — the sent message may still display them.
- To insert text from voice-dictation or prompt-suggestions, control `value` and append to it.

## Examples

### Default layout (minimal)

No children: textarea, attach button, hint and send button.

```tsx
"use client";

import { Composer } from "@kura-ui/react/composer";

export function ComposerMinimal() {
  return (
    <div className="w-full max-w-xl">
      <Composer placeholder="Ask anything…" onSubmit={(m) => console.log(m.text)} />
    </div>
  );
}
```

### Controlled, with model picker and tools (full)

Controlled text, simulated submitted → streaming lifecycle with stop, file limits, a character limit and tool toggles.

```tsx
"use client";

import { Globe, Lightbulb } from "lucide-react";
import * as React from "react";
import { ModelPicker, type ModelPickerModel } from "@kura-ui/react/model-picker";
import {
  Composer,
  ComposerActions,
  ComposerAttachButton,
  ComposerAttachments,
  ComposerHint,
  ComposerInput,
  type ComposerMessage,
  type ComposerStatus,
  ComposerSubmit,
  ComposerToolbar,
  ComposerTools,
} from "@kura-ui/react/composer";

const models: ModelPickerModel[] = [
  {
    id: "opus",
    name: "Claude Opus 5.5",
    provider: "Anthropic",
    description: "Most capable for complex, multi-step work.",
    capabilities: ["vision", "tools", "reasoning"],
    contextWindow: 1_000_000,
    speed: 1,
    cost: 3,
  },
  {
    id: "sonnet",
    name: "Claude Sonnet 5.5",
    provider: "Anthropic",
    description: "Balanced speed and intelligence.",
    capabilities: ["vision", "tools", "reasoning"],
    contextWindow: 1_000_000,
    speed: 2,
    cost: 2,
  },
  {
    id: "haiku",
    name: "Claude Haiku 4.5",
    provider: "Anthropic",
    description: "Fastest, for quick answers.",
    capabilities: ["vision", "tools"],
    contextWindow: 200_000,
    speed: 3,
    cost: 1,
  },
];

function ToolToggle({
  pressed,
  onPressedChange,
  icon,
  children,
}: {
  pressed: boolean;
  onPressedChange: (pressed: boolean) => void;
  icon: React.ReactNode;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      aria-pressed={pressed}
      onClick={() => onPressedChange(!pressed)}
      className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-kura-lg px-2.5 font-medium text-[13px] text-kura-fg-muted outline-none transition-[background-color,color,box-shadow] duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover hover:text-kura-fg focus-visible:shadow-kura-focus aria-pressed:bg-kura-active aria-pressed:text-kura-fg [&_svg]:size-3.5"
    >
      {icon}
      {children}
    </button>
  );
}

export function ComposerFull() {
  const [value, setValue] = React.useState("");
  const [status, setStatus] = React.useState<ComposerStatus>("idle");
  const [model, setModel] = React.useState("sonnet");
  const [search, setSearch] = React.useState(true);
  const [think, setThink] = React.useState(false);
  const [last, setLast] = React.useState<ComposerMessage | null>(null);
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);

  const clear = React.useCallback(() => {
    for (const t of timers.current) clearTimeout(t);
    timers.current = [];
  }, []);
  React.useEffect(() => clear, [clear]);

  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <Composer
        value={value}
        onValueChange={setValue}
        status={status}
        maxLength={2000}
        accept="image/*,.pdf,.csv,.md,.txt,.ts,.tsx"
        maxFiles={6}
        maxFileSize={20 * 1024 * 1024}
        submitKey="enter"
        clearOnSubmit
        onSubmit={(message) => {
          setLast(message);
          setStatus("submitted");
          timers.current.push(setTimeout(() => setStatus("streaming"), 1200));
          timers.current.push(setTimeout(() => setStatus("idle"), 4200));
        }}
        onStop={() => {
          clear();
          setStatus("idle");
        }}
      >
        <ComposerAttachments max={4} />
        <ComposerInput placeholder="Message Kura — paste or drop files" minRows={2} maxRows={10} />
        <ComposerToolbar>
          <ComposerTools>
            <ComposerAttachButton />
            <ModelPicker models={models} value={model} onValueChange={setModel} side="top" />
            <ToolToggle pressed={search} onPressedChange={setSearch} icon={<Globe />}>
              Search
            </ToolToggle>
            <ToolToggle pressed={think} onPressedChange={setThink} icon={<Lightbulb />}>
              Think
            </ToolToggle>
          </ComposerTools>
          <ComposerActions>
            <ComposerHint />
            <ComposerSubmit />
          </ComposerActions>
        </ComposerToolbar>
      </Composer>
      <p className="min-h-4 px-1 text-kura-fg-subtle text-xs" aria-live="polite">
        {status === "submitted"
          ? "Sending…"
          : status === "streaming"
            ? "Responding — press Esc to stop"
            : last
              ? `Sent “${last.text.slice(0, 48)}${last.text.length > 48 ? "…" : ""}”${
                  last.attachments.length ? ` with ${last.attachments.length} file(s)` : ""
                }`
              : "Enter to send · Shift+Enter for a new line"}
      </p>
    </div>
  );
}
```

### Submitted, streaming, over limit and disabled (state)

```tsx
import { Composer, ComposerActions, ComposerAttachButton, ComposerHint, ComposerInput, ComposerSubmit, ComposerToolbar, ComposerTools } from "@kura-ui/react/composer";

export function ComposerStates() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <Composer defaultValue="Summarise the attached incident report" status="submitted">
        <ComposerInput />
        <ComposerToolbar>
          <ComposerTools>
            <ComposerAttachButton />
          </ComposerTools>
          <ComposerActions>
            <ComposerSubmit />
          </ComposerActions>
        </ComposerToolbar>
      </Composer>
      <Composer
        defaultValue="Draft release notes for v2.4 — keep it under 200 words and lead with the breaking change to the auth middleware."
        status="streaming"
        maxLength={120}
      >
        <ComposerInput />
        <ComposerToolbar>
          <ComposerTools>
            <ComposerAttachButton />
          </ComposerTools>
          <ComposerActions>
            <ComposerHint />
            <ComposerSubmit />
          </ComposerActions>
        </ComposerToolbar>
      </Composer>
      <Composer disabled placeholder="Upgrade to continue the conversation" />
    </div>
  );
}
```

### New chat home (composition)

An empty-chat start: ModelPicker and VoiceDictation in the toolbar, PromptSuggestions chips that fill and focus the input.

```tsx
"use client";

import { ChartLine, Code, FileText, Mail } from "lucide-react";
import * as React from "react";
import { ModelPicker, type ModelPickerModel } from "@kura-ui/react/model-picker";
import { type PromptSuggestion, PromptSuggestions } from "@kura-ui/react/prompt-suggestions";
import { VoiceDictation } from "@kura-ui/react/voice-dictation";
import { Composer, ComposerActions, ComposerAttachButton, ComposerAttachments, ComposerInput, type ComposerStatus, ComposerSubmit, ComposerToolbar, ComposerTools } from "@kura-ui/react/composer";

const models: ModelPickerModel[] = [
  {
    id: "opus",
    name: "Claude Opus 5.5",
    provider: "Anthropic",
    description: "Most capable for complex, multi-step work.",
    capabilities: ["vision", "tools", "reasoning"],
    contextWindow: 1_000_000,
    speed: 1,
    cost: 3,
  },
  {
    id: "sonnet",
    name: "Claude Sonnet 5.5",
    provider: "Anthropic",
    description: "Balanced speed and intelligence.",
    capabilities: ["vision", "tools", "reasoning"],
    contextWindow: 1_000_000,
    speed: 2,
    cost: 2,
  },
  {
    id: "haiku",
    name: "Claude Haiku 4.5",
    provider: "Anthropic",
    description: "Fastest, for quick answers.",
    capabilities: ["vision", "tools"],
    contextWindow: 200_000,
    speed: 3,
    cost: 1,
  },
];

const starters: PromptSuggestion[] = [
  {
    title: "Analyze a CSV",
    prompt: "Find the three biggest drivers of churn in the attached CSV.",
    icon: <ChartLine />,
  },
  {
    title: "Summarize a doc",
    prompt: "Summarize this doc in five bullets for an exec audience.",
    icon: <FileText />,
  },
  {
    title: "Review a diff",
    prompt: "Review this diff for bugs and missing tests.",
    icon: <Code />,
  },
  {
    title: "Draft a reply",
    prompt: "Draft a friendly reply that moves the meeting to next week.",
    icon: <Mail />,
  },
];

export function ComposerNewChat() {
  const [value, setValue] = React.useState("");
  const [interim, setInterim] = React.useState("");
  const [model, setModel] = React.useState("opus");
  const [status, setStatus] = React.useState<ComposerStatus>("idle");
  const inputRef = React.useRef<HTMLTextAreaElement>(null);
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);
  React.useEffect(() => () => clearTimeout(timer.current), []);

  return (
    <div className="flex w-full max-w-xl flex-col items-center gap-5">
      <div className="flex flex-col items-center gap-1 text-center">
        <h3 className="font-medium text-2xl text-kura-fg tracking-tight">
          What are we working on?
        </h3>
        <p className="text-kura-fg-muted text-sm">
          Start typing, dictate, or pick a starting point.
        </p>
      </div>
      <Composer
        value={value}
        onValueChange={setValue}
        status={status}
        accept="image/*,.pdf,.csv,.md,.txt"
        className="w-full"
        onSubmit={() => {
          setStatus("submitted");
          timer.current = setTimeout(() => setStatus("idle"), 1400);
        }}
        onStop={() => {
          clearTimeout(timer.current);
          setStatus("idle");
        }}
      >
        <ComposerAttachments />
        <ComposerInput ref={inputRef} placeholder={interim || "Message Kura…"} minRows={2} />
        <ComposerToolbar>
          <ComposerTools>
            <ComposerAttachButton />
            <ModelPicker models={models} value={model} onValueChange={setModel} side="top" />
          </ComposerTools>
          <ComposerActions>
            <VoiceDictation
              simulate="Compare our Q3 pricing page with the two closest competitors."
              showTranscript={false}
              hotkey={null}
              onInterim={setInterim}
              onStatusChange={(s) => s === "idle" && setInterim("")}
              onTranscript={(text) => setValue((v) => (v ? `${v.trimEnd()} ${text}` : text))}
            />
            <ComposerSubmit />
          </ComposerActions>
        </ComposerToolbar>
      </Composer>
      <PromptSuggestions
        variant="chips"
        label="Starting points"
        suggestions={starters}
        disabled={status !== "idle"}
        onSelect={(prompt) => {
          setValue(prompt);
          inputRef.current?.focus();
        }}
        className="justify-center"
      />
    </div>
  );
}
```

## Dependencies

- npm: `lucide-react`, `motion`
- Kura registry items: `kura-theme`, `attachment`, `icon-button`, `kura-merge-refs`, `kura-motion`, `tooltip`, `use-controllable-state`, `use-reduced-motion`, `utils`
- Related: `attachment`, `model-picker`, `slash-command-menu`, `voice-dictation`, `prompt-suggestions`
