# Composer

Controlled agent-message form with autoresizing input, toolbar, and send/stop action.

- Package: `@mielui/svelte`
- Component version: `1.0.0`
- Depends on Mielui components: _internal/utils, button, spinner, toolbar
- Shared utilities: components/_internal/inset-layout, components/_internal/button-attributes, components/_internal/submission.svelte, components/_internal/surface, hugeicons-icon, transition, utils.cn, utils.createContext

## Install

~~~~sh
pnpm dlx @mielui/svelte add composer
~~~~

## API

This reference is generated at build time from the component manifest, public `index.ts`, and documentation examples below. Changes to those source files are reflected here in the published Markdown. Standard Svelte and HTML attributes accepted by the exported prop types are supported.


### Actions

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| class | string \| undefined | - | No | No | Classes added to the element. |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Footer

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| class | string \| undefined | - | No | No | Classes added to the element. |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Header

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| class | string \| undefined | - | No | No | Classes added to the element. |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Input

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| submitOnEnter | boolean \| undefined | true | No | No | Enter submits and Shift+Enter adds a line. Set false to make Enter add a line. |
| element | HTMLTextAreaElement \| undefined | - | No | Yes | Bindable reference to the DOM element. |
| class | string \| undefined | - | No | No | Classes added to the element. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Root

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| labels | ComposerLabels \| undefined | - | No | No | Overrides the built-in text and accessible names. Every key is optional and English is the fallback. |
| surface | "solid" \| "glass" \| undefined | - | No | No | Surface treatment. Omit it to inherit `--mielui-surface` from the theme. |
| value | string \| undefined | '' | No | Yes | Message text. Bindable. |
| status | ComposerStatus \| undefined | 'idle' | No | No | Current state of the composer, which sets what the submit control does. |
| generating | boolean \| undefined | - | No | No | Whether a response is being generated independently of submission state. |
| disabled | boolean \| undefined | false | No | No | Prevents interaction and dims the control. |
| allowEmpty | boolean \| undefined | false | No | No | Allows submitting with no text, for a message that is only attachments. |
| onSubmit | (value: string, event: SubmitEvent) => void \| Promise<void> | - | Yes | No | Called with the text on submit. Return a promise to show progress until it settles. |
| onError | ((error: unknown) => void) \| undefined | - | No | No | Called with the error when `onSubmit` rejects. |
| onStop | (() => void) \| undefined | - | No | No | Called when the stop control is pressed during a response. |
| class | string \| undefined | - | No | No | Classes added to the element. |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Submit

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| children | Snippet<[Readonly<{ action: ComposerSubmitAction; generating: boolean; empty: boolean; }>]> \| undefined | - | No | No | Content rendered inside. |
| element | HTMLButtonElement \| HTMLAnchorElement \| undefined | - | No | Yes | Bindable reference to the DOM element. |
| onclick | ((event: MouseEvent) => void) \| undefined | - | No | No | Called when it is clicked or activated. |
| class | string \| undefined | - | No | No | Classes added to the element. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Toolbar

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| variant | "default" \| "inset" \| undefined | 'default' | No | No | `inset` puts the input on its own inset surface and the toolbar in the frame under it, like an inset Card. The default joins the toolbar to the input on one surface. |
| class | string \| undefined | - | No | No | Classes added to the element. |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Public types

~~~~ts
import type { DefaultProps } from '@mielui/svelte/utils';
import type { Snippet } from 'svelte';
import type {
    HTMLAttributes,
    HTMLButtonAttributes,
    HTMLFormAttributes,
    HTMLTextareaAttributes
} from 'svelte/elements';
import Root from './composer.svelte';
import Actions from './composer-actions.svelte';
import Footer from './composer-footer.svelte';
import Header from './composer-header.svelte';
import Input from './composer-input.svelte';
import Submit from './composer-submit.svelte';
import Toolbar from './composer-toolbar.svelte';

export type ComposerStatus = 'idle' | 'submitting' | 'error';

export type ComposerLabels = {
    /** Message shown when `onSubmit` rejects. */
    error?: string;
    /** Accessible name of the message field when it has no `aria-label`. */
    input?: string;
    /** Placeholder of the message field when it has no `placeholder`. */
    placeholder?: string;
    /** Accessible name of the toolbar when it has no `aria-label`. */
    toolbar?: string;
    /** Accessible name of the submit button when it sends. */
    send?: string;
    /** Accessible name of the submit button when it queues a message behind a running response. */
    queue?: string;
    /** Accessible name of the submit button when it stops a response. */
    stop?: string;
    /** Accessible name of the submit button while the submission is pending. */
    sending?: string;
};

export type ComposerProps = {
    /**
     * Overrides the built-in text and accessible names. Every key is optional and English is the
     * fallback.
     */
    labels?: ComposerLabels;
    /** Surface treatment. Omit it to inherit `--mielui-surface` from the theme. */
    surface?: 'solid' | 'glass';
    /** Message text. Bindable. */
    value?: string;
    /** Current state of the composer, which sets what the submit control does. */
    status?: ComposerStatus;
    /** Whether a response is being generated independently of submission state. */
    generating?: boolean;
    /** Prevents interaction and dims the control. */
    disabled?: boolean;
    /** Allows submitting with no text, for a message that is only attachments. */
    allowEmpty?: boolean;
    /** Called with the text on submit. Return a promise to show progress until it settles. */
    onSubmit: (value: string, event: SubmitEvent) => void | Promise<void>;
    /** Called with the error when `onSubmit` rejects. */
    onError?: (error: unknown) => void;
    /** Called when the stop control is pressed during a response. */
    onStop?: () => void;
    /** Classes added to the element. */
    class?: string;
    /** Content rendered inside. */
    children?: Snippet;
} & Omit<
    HTMLFormAttributes,
    'children' | 'class' | 'onsubmit' | 'action' | 'method' | 'target' | 'enctype'
>;

export type ComposerHeaderProps = {
    /** Classes added to the element. */
    class?: string;
    /** Content rendered inside. */
    children?: Snippet;
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class'>;

export type ComposerInputProps = {
    /** Enter submits and Shift+Enter adds a line. Set false to make Enter add a line. */
    submitOnEnter?: boolean;
    /** Bindable reference to the DOM element. */
    element?: HTMLTextAreaElement;
    /** Classes added to the element. */
    class?: string;
} & Omit<HTMLTextareaAttributes, 'children' | 'class' | 'value'>;

export type ComposerToolbarProps = {
    /**
     * `inset` puts the input on its own inset surface and the toolbar in the frame under it,
     * like an inset Card. The default joins the toolbar to the input on one surface.
     */
    variant?: 'default' | 'inset';
    /** Classes added to the element. */
    class?: string;
    /** Content rendered inside. */
    children?: Snippet;
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'role'>;

export type ComposerActionsProps = {
    /** Classes added to the element. */
    class?: string;
    /** Content rendered inside. */
    children?: Snippet;
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class'>;

export type ComposerFooterProps = {
    /** Classes added to the element. */
    class?: string;
    /** Content rendered inside. */
    children?: Snippet;
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class'>;

export type ComposerSubmitProps = {
    /** Content rendered inside. */
    children?: Snippet<[ComposerSubmitState]>;
    /** Bindable reference to the DOM element. */
    element?: HTMLButtonElement | HTMLAnchorElement;
    /** Called when it is clicked or activated. */
    onclick?: (event: MouseEvent) => void;
    /** Classes added to the element. */
    class?: string;
} & Omit<DefaultProps, 'children'> &
    Omit<HTMLButtonAttributes, 'children' | 'class' | 'type' | 'onclick'>;

export type ComposerSubmitAction = 'send' | 'queue' | 'stop' | 'pending';

export type ComposerSubmitState = Readonly<{
    action: ComposerSubmitAction;
    generating: boolean;
    empty: boolean;
}>;

export { Actions, Footer, Header, Input, Root, Submit, Toolbar };
~~~~

## Examples

### Allow Empty

~~~~svelte
<script lang="ts">
    import * as Composer from '@mielui/svelte/components/composer';
    import { Switch } from '@mielui/svelte/components/switch';

    let value = $state('');
    let disabled = $state(false);
    let sent = $state(0);
</script>

<div class="flex w-full max-w-md flex-col gap-3">
    <Switch bind:checked={disabled} label="Disable the composer" />
    <Composer.Root
        bind:value
        allowEmpty
        {disabled}
        onSubmit={() => {
            sent += 1;
            value = '';
        }}
    >
        <Composer.Input aria-label="Message" placeholder="Send works with no text" />
        <Composer.Toolbar>
            <Composer.Submit />
        </Composer.Toolbar>
    </Composer.Root>
    <p class="text-sm text-foreground-muted">Sent {sent} times</p>
</div>
~~~~

### Attachments

~~~~svelte
<script lang="ts">
    import * as Attachment from '@mielui/svelte/components/attachment';
    import * as Composer from '@mielui/svelte/components/composer';
    import * as Select from '@mielui/svelte/components/select';

    const models = ['Mielui 3.1', 'Mielui Mini'];
    const skyImage = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#a8d4fb"/><stop offset="1" stop-color="#7cc0f5"/></linearGradient></defs><rect width="40" height="40" fill="url(#g)"/></svg>`;

    let value = $state('');
    let model = $state(models[0]);
    let result = $state('');
    let files = $state<File[]>([
        new File(['Brief'], 'brief.pdf', { type: 'application/pdf' }),
        new File([skyImage], 'cloud.svg', { type: 'image/svg+xml' }),
        new File(['Name,Email'], 'lead-list.xlsx', {
            type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
        }),
        new File(['# Notes'], 'meeting-notes.md', { type: 'text/markdown' })
    ]);

    function send(prompt: string) {
        const count = files.length;
        const noun = count === 1 ? 'file' : 'files';

        result = `Sent "${prompt}" with ${count} ${noun}.`;
        value = '';
        files = [];
    }
</script>

<div class="flex w-full max-w-2xl flex-col gap-3">
    <Attachment.Root bind:files class="flex flex-col gap-2">
        <Attachment.List variant="chip" />
        <Composer.Root bind:value onSubmit={send}>
            <Composer.Input
                aria-label="Prompt"
                placeholder="Add instructions for the attached files..."
            />

            <Composer.Toolbar>
                <Composer.Actions>
                    <Attachment.Trigger variant="outline" />

                    <Select.Root bind:value={model}>
                        <Select.Trigger variant="outline" class="w-auto max-w-40">
                            <span class="truncate">{model}</span>
                        </Select.Trigger>
                        <Select.Content dynamic>
                            <Select.Label>Model</Select.Label>
                            {#each models as option (option)}
                                <Select.Item value={option}>{option}</Select.Item>
                            {/each}
                        </Select.Content>
                    </Select.Root>
                </Composer.Actions>

                <Composer.Submit />
            </Composer.Toolbar>
        </Composer.Root>
    </Attachment.Root>

    <p class="min-h-5 text-sm text-foreground-muted" role="status">{result}</p>
</div>
~~~~

### Footer

~~~~svelte
<script lang="ts">
    import { Button } from '@mielui/svelte/components/button';
    import * as Composer from '@mielui/svelte/components/composer';

    let value = $state('');
    let web = $state(false);
    let result = $state('');

    function send() {
        result = `Submitted with web search ${web ? 'on' : 'off'}: ${value}`;
        value = '';
    }
</script>

<div class="flex w-full max-w-2xl flex-col gap-3">
    <Composer.Root bind:value onSubmit={send}>
        <Composer.Input aria-label="Prompt" placeholder="Ask a question..." />
        <Composer.Toolbar>
            <Composer.Submit />
        </Composer.Toolbar>
        <Composer.Footer>
            <span class="px-2.5">Answers can cite the web.</span>
            <Button
                variant="ghost"
                class="ms-auto"
                aria-pressed={web}
                onclick={() => {
                    web = !web;
                }}
            >
                {web ? 'Web search on' : 'Web search off'}
            </Button>
        </Composer.Footer>
    </Composer.Root>
    <p class="min-h-5 text-sm text-foreground-muted" role="status">{result}</p>
</div>
~~~~

### Glass

~~~~svelte
<script lang="ts">
    import * as Composer from '@mielui/svelte/components/composer';

    let value = $state('What should we work on next?');

    function send() {
        value = '';
    }
</script>

<div class="w-full max-w-2xl">
    <Composer.Root surface="glass" bind:value onSubmit={send}>
        <Composer.Input aria-label="Message" />
        <Composer.Toolbar>
            <Composer.Actions>
                <span class="text-sm text-foreground-muted">Shift + Enter for a new line</span>
            </Composer.Actions>
            <Composer.Submit />
        </Composer.Toolbar>
    </Composer.Root>
</div>
~~~~

### Hero

~~~~svelte
<script lang="ts">
    import {
        GoogleDriveIcon as Drive,
        HandIcon as Hand,
        Mic01Icon as Microphone,
        NotionIcon as Notion,
        SlackIcon as Slack
    } from '@hugeicons/core-free-icons';
    import * as Attachment from '@mielui/svelte/components/attachment';
    import { Button } from '@mielui/svelte/components/button';
    import * as Composer from '@mielui/svelte/components/composer';
    import * as Select from '@mielui/svelte/components/select';
    import HugeiconsIcon from '@mielui/svelte/hugeicons-icon';
    import { onDestroy } from 'svelte';

    const permissions = ['Request approval', 'Auto approve'];
    const models = ['Auto', 'Mielui 3.1', 'Mielui Mini'];
    const apps = [Drive, Slack, Notion];

    let value = $state('');
    let files = $state<File[]>([]);
    let permission = $state(permissions[0]);
    let model = $state(models[0]);
    let connected = $state(false);
    let timer: ReturnType<typeof setTimeout> | undefined;
    let settle: (() => void) | undefined;

    async function submitPrompt() {
        await new Promise<void>((resolve) => {
            settle = resolve;
            timer = setTimeout(() => {
                value = '';
                timer = undefined;
                settle = undefined;
                resolve();
            }, 1600);
        });
    }

    function stopSubmission() {
        if (timer) {
            clearTimeout(timer);
        }
        timer = undefined;
        const resolve = settle;
        settle = undefined;
        resolve?.();
    }

    onDestroy(() => {
        if (timer) {
            clearTimeout(timer);
        }
        settle?.();
    });
</script>

<Attachment.Root bind:files class="flex w-full max-w-2xl flex-col gap-2">
    <Attachment.List variant="chip" />
    <Composer.Root bind:value onSubmit={submitPrompt} onStop={stopSubmission}>
        <Composer.Input aria-label="Prompt" placeholder="Start by typing..." />

        <Composer.Toolbar>
            <Composer.Actions>
                <Attachment.Trigger />

                <Select.Root bind:value={permission}>
                    <Select.Trigger variant="ghost" aria-label="Permission" class="w-auto">
                        <HugeiconsIcon icon={Hand} size={16} aria-hidden="true" />
                        <span class="truncate">{permission}</span>
                    </Select.Trigger>
                    <Select.Content dynamic>
                        <Select.Label>Permission</Select.Label>
                        {#each permissions as option (option)}
                            <Select.Item value={option}>{option}</Select.Item>
                        {/each}
                    </Select.Content>
                </Select.Root>
            </Composer.Actions>

            <div class="ms-auto flex shrink-0 items-center gap-1">
                <Select.Root bind:value={model}>
                    <Select.Trigger variant="ghost" aria-label="Model" class="w-auto">
                        <span class="truncate">{model}</span>
                    </Select.Trigger>
                    <Select.Content dynamic>
                        <Select.Label>Model</Select.Label>
                        {#each models as option (option)}
                            <Select.Item value={option}>{option}</Select.Item>
                        {/each}
                    </Select.Content>
                </Select.Root>

                <Button variant="ghost" size="icon" aria-label="Dictate">
                    <HugeiconsIcon icon={Microphone} size={17} aria-hidden="true" />
                </Button>

                <Composer.Submit />
            </div>
        </Composer.Toolbar>

        <Composer.Footer>
            <Button
                variant="ghost"
                class="text-foreground-muted hover:text-foreground"
                aria-pressed={connected}
                onclick={() => {
                    connected = !connected;
                }}
            >
                <span class="flex items-center gap-1" aria-hidden="true">
                    {#each apps as app (app)}
                        <HugeiconsIcon icon={app} size={15} />
                    {/each}
                </span>
                {connected ? 'Apps connected' : 'Connect apps'}
            </Button>
        </Composer.Footer>
    </Composer.Root>
</Attachment.Root>
~~~~

### Idle

~~~~svelte
<script lang="ts">
    import * as Composer from '@mielui/svelte/components/composer';

    let value = $state('Compare the two implementation options.');
    let submitted = $state('');

    function submit() {
        submitted = value;
        value = '';
    }
</script>

<div class="w-full max-w-2xl space-y-3">
    <Composer.Root bind:value onSubmit={submit}>
        <Composer.Input aria-label="Prompt" />
        <Composer.Toolbar>
            <Composer.Submit />
        </Composer.Toolbar>
    </Composer.Root>
    <p class="min-h-5 text-sm text-foreground-muted" role="status">
        {submitted ? `Submitted: ${submitted}` : 'Enter sends. Shift + Enter adds a line.'}
    </p>
</div>
~~~~

### State Error

~~~~svelte
<script lang="ts">
    import * as Composer from '@mielui/svelte/components/composer';
    import { Switch } from '@mielui/svelte/components/switch';

    let value = $state('Summarize the incident timeline.');
    let fail = $state(true);
    let result = $state('');

    async function submit() {
        result = '';
        if (fail) {
            throw new Error('The demo service is unavailable.');
        }
        result = `Submitted: ${value}`;
        value = '';
    }
</script>

<div class="w-full max-w-2xl space-y-4">
    <Switch bind:checked={fail} label="Simulate service failure" />
    <Composer.Root
        bind:value
        onSubmit={submit}
        labels={{
            error: 'Could not send. Turn off simulated failure, then retry.'
        }}
    >
        <Composer.Input aria-label="Incident prompt" />
        <Composer.Toolbar>
            <Composer.Submit />
        </Composer.Toolbar>
    </Composer.Root>
    <p class="min-h-5 text-sm text-foreground-muted" role="status">{result}</p>
</div>
~~~~

### Submitting

~~~~svelte
<script lang="ts">
    import * as Composer from '@mielui/svelte/components/composer';
    import { onDestroy } from 'svelte';

    let value = $state('Check the deployment plan.');
    let result = $state('Send the prompt, then stop it before the demo finishes.');
    let timer: ReturnType<typeof setTimeout> | undefined;
    let status = $state<'idle' | 'submitting'>('idle');

    function stop() {
        clearTimeout(timer);
        timer = undefined;
        result = 'Stopped. Your prompt is still here.';
        status = 'idle';
    }

    function submit() {
        result = 'Checking the plan…';
        status = 'submitting';
        timer = setTimeout(() => {
            result = 'Plan checked. No blocking changes found.';
            value = '';
            timer = undefined;
            status = 'idle';
        }, 2400);
    }

    onDestroy(() => {
        clearTimeout(timer);
    });
</script>

<div class="w-full max-w-2xl space-y-3">
    <Composer.Root bind:value {status} onSubmit={submit} onStop={stop}>
        <Composer.Input aria-label="Deployment prompt" />
        <Composer.Toolbar>
            <Composer.Submit />
        </Composer.Toolbar>
    </Composer.Root>
    <p class="min-h-5 text-sm text-foreground-muted" role="status">{result}</p>
</div>
~~~~

### Toolbar Inset

~~~~svelte
<script lang="ts">
    import * as Composer from '@mielui/svelte/components/composer';

    let inset = $state('Compare the two implementation options.');
    let joined = $state('Summarize the incident timeline.');
    let result = $state('');

    function sendInset() {
        result = `Submitted: ${inset}`;
        inset = '';
    }

    function sendJoined() {
        result = `Submitted: ${joined}`;
        joined = '';
    }
</script>

<div class="flex w-full max-w-2xl flex-col gap-6">
    <Composer.Root bind:value={inset} onSubmit={sendInset}>
        <Composer.Input aria-label="Inset toolbar prompt" />
        <Composer.Toolbar variant="inset">
            <Composer.Actions>
                <span class="px-2 text-xs text-foreground-muted">Mielui 3.1</span>
            </Composer.Actions>
            <Composer.Submit />
        </Composer.Toolbar>
    </Composer.Root>

    <Composer.Root bind:value={joined} onSubmit={sendJoined}>
        <Composer.Input aria-label="Default toolbar prompt" />
        <Composer.Toolbar>
            <Composer.Actions>
                <span class="px-2 text-xs text-foreground-muted">Mielui 3.1</span>
            </Composer.Actions>
            <Composer.Submit />
        </Composer.Toolbar>
    </Composer.Root>
    <p class="min-h-5 text-sm text-foreground-muted" role="status">{result}</p>
</div>
~~~~

For the rendered reference, visit [/docs/components/composer](/docs/components/composer).
