---
title: Composer
description: Rich-text chat input with chips, slash/mention commands, attachments, and an ask-user flow.
source: composer
---

```tsx title="primitives/composer/demos/basic.tsx"
"use client";

import { Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import type { ComponentProps } from "react";

// Every <Composer.Root> owns an isolated store, so a bare composer needs no
// setup beyond an onSubmit handler.
export const Basic = () => {
  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind === "message") {
      console.log(data.text, data.files);
    }
  };

  return (
    <Composer.Root onSubmit={handleSubmit} className="flex w-full max-w-xl flex-col">
      <Composer.Container className="cursor-text rounded-2xl border border-[#f0f0f0] bg-white shadow-xs transition-colors focus-within:border-[#ececec] dark:border-[#262626] dark:bg-[#181818] dark:focus-within:border-[#2d2d2d]">
        {/* The editable element is engine-owned and out of JSX reach, so it is
            styled through the data-composer-editor variants. */}
        <Composer.Textarea className="max-h-32 min-h-8 overflow-y-auto px-4 pt-3 text-sm **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-[1.7] [&_[data-composer-editor]:focus]:outline-none">
          <Composer.Placeholder
            placeholder="Send a message…"
            className="leading-[1.7] text-[#949494] dark:text-[#6f6f6f]"
          />
        </Composer.Textarea>
        <Composer.Actions className="flex justify-end gap-2 p-2">
          <Composer.Submit className="flex size-8 items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-opacity disabled:opacity-40 dark:bg-[#fcfcfc] dark:text-[#111111]">
            <SendIcon />
          </Composer.Submit>
        </Composer.Actions>
      </Composer.Container>
    </Composer.Root>
  );
};

const SendIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M8 13V3m0 0L3.5 7.5M8 3l4.5 4.5" />
  </svg>
);
```

## Usage guidelines

- **Chat input** — a hand-rolled contenteditable over a flat segment model: native typing and IME, inline chips, attachments.
- **Prefix commands** — type `/`, `@`, or other prefixes to open command lists.
- **Panel** — hosts command results, live steps, or an ask-user prompt above the field.
- **Headless** — behavior lives in `@intentface/chat`; the demos below show one way to style it, and every class in them is yours to change.
- **Get started** — see [Quick start](/quick-start) to add the package.

## Anatomy

The bare nesting — every part is optional except `Composer` and `Container`:

```tsx
<Composer.Root onSubmit={handleSubmit}>
  <Composer.Panel>
    {(composer) =>
      composer.commands.active && (
        <Composer.Command prefix="@">
          <Composer.CommandLoading />
          <Composer.CommandEmpty />
          <Composer.CommandList>
            {(item) => (
              <Composer.CommandItem value={item.value}>
                <Composer.CommandItemLabel>{item.label}</Composer.CommandItemLabel>
              </Composer.CommandItem>
            )}
          </Composer.CommandList>
        </Composer.Command>
      )
    }
  </Composer.Panel>
  <Composer.Container>
    <Composer.Textarea>
      <Composer.Placeholder />
    </Composer.Textarea>
    <Composer.Actions>
      <Composer.Submit />
    </Composer.Actions>
  </Composer.Container>
</Composer.Root>
```

`Composer.Panel` takes plain children or a callback receiving the composer
state, and shows only while its resolved content is non-empty. Gate each part on
the state it belongs to (`commands.active`, `askUser.active`, …) and the panel
opens and closes to match — priority is just the order of your branches.

## Examples

### Mention command list

Type `@` to open the command list — the panel routes to it automatically while
a prefix is active. `commands` maps each prefix to its config and items.

```tsx title="primitives/composer/demos/commands.tsx"
"use client";

import { type CommandItemData, Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import type { ComponentProps } from "react";

const MENTIONS: CommandItemData[] = [
  { value: "readme", label: "README.md", description: "Project overview" },
  { value: "package", label: "package.json", description: "Dependencies and scripts" },
  { value: "composer", label: "composer.tsx", description: "The composer primitive" },
];

// Type "@" to open the list. Panel takes a callback receiving composer state,
// so the command list shows only while a prefix is active.
export const Commands = () => {
  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind === "message") {
      console.log(data.text);
    }
  };

  return (
    // Reserve height and bottom-anchor so opening the list grows the composer
    // upward instead of shifting the page.
    <div className="flex min-h-[300px] w-full max-w-xl flex-col justify-end">
      <Composer.Root
        onSubmit={handleSubmit}
        commands={{ "@": { kind: "insert", trigger: "word-boundary", items: MENTIONS } }}
        className="flex flex-col"
      >
        {/* anchor={false} makes the panel an in-flow block that grows the
            composer upward; the default is a portaled overlay. */}
        <Composer.Panel
          anchor={false}
          className="mb-2 overflow-hidden rounded-2xl border border-[#f0f0f0] bg-white dark:border-[#262626] dark:bg-[#181818]"
        >
          {(composer) =>
            composer.commands.active ? (
              // data-empty and data-loading land on Command; the group gates
              // which child shows.
              <Composer.Command prefix="@" className="group/list flex flex-col p-1">
                <Composer.CommandEmpty className="hidden h-8 items-center rounded-[10px] px-3 text-sm text-[#949494] group-data-empty/list:flex dark:text-[#6f6f6f]">
                  No files found.
                </Composer.CommandEmpty>
                <Composer.CommandList className="flex max-h-56 flex-col overflow-y-auto group-data-empty/list:hidden">
                  {(item) => (
                    <Composer.CommandItem
                      value={item.value}
                      className="flex h-8 w-full cursor-pointer items-center gap-2.5 rounded-[10px] px-3 text-sm outline-none select-none data-highlighted:bg-[#f4f4f4] dark:data-highlighted:bg-[#232323]"
                    >
                      <Composer.CommandItemLabel className="font-medium">
                        {item.label}
                      </Composer.CommandItemLabel>
                      {item.description && (
                        <Composer.CommandItemDescription className="truncate text-xs text-[#949494] dark:text-[#6f6f6f]">
                          {item.description}
                        </Composer.CommandItemDescription>
                      )}
                    </Composer.CommandItem>
                  )}
                </Composer.CommandList>
              </Composer.Command>
            ) : null
          }
        </Composer.Panel>
        <Composer.Container className="cursor-text rounded-2xl border border-[#f0f0f0] bg-white shadow-xs transition-colors focus-within:border-[#ececec] dark:border-[#262626] dark:bg-[#181818] dark:focus-within:border-[#2d2d2d]">
          <Composer.Textarea className="max-h-32 min-h-8 overflow-y-auto px-4 pt-3 text-sm **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-[1.7] [&_[data-composer-editor]:focus]:outline-none **:data-command-badge:rounded-sm **:data-command-badge:bg-[#f4f4f4] **:data-command-badge:px-0.5 **:data-command-badge:dark:bg-[#232323] **:data-command-hint:text-[#949494]">
            <Composer.Placeholder
              placeholder="Type @ to mention a file…"
              className="leading-[1.7] text-[#949494] dark:text-[#6f6f6f]"
            />
          </Composer.Textarea>
          <Composer.Actions className="flex justify-end gap-2 p-2">
            <Composer.Submit className="flex size-8 items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-opacity disabled:opacity-40 dark:bg-[#fcfcfc] dark:text-[#111111]">
              <SendIcon />
            </Composer.Submit>
          </Composer.Actions>
        </Composer.Container>
      </Composer.Root>
    </div>
  );
};

const SendIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M8 13V3m0 0L3.5 7.5M8 3l4.5 4.5" />
  </svg>
);
```

### Floating command popover

`Composer.Popover` is the floating alternative to `Composer.Panel`. It takes the
same children — plain nodes or a state callback — but portals them above the
field, anchored to the active trigger token, so the list overlays instead of
growing the composer and needs no reserved height. It's collision-aware — near a
viewport edge it flips, shifts, and caps its height to stay on screen. Mount one
or the other; the content is identical.

```tsx title="primitives/composer/demos/popover.tsx"
"use client";

import { type CommandItemData, Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import type { ComponentProps } from "react";

const MENTIONS: CommandItemData[] = [
  { value: "readme", label: "README.md", description: "Project overview" },
  { value: "package", label: "package.json", description: "Dependencies and scripts" },
  { value: "composer", label: "composer.tsx", description: "The composer primitive" },
];

// The floating alternative to Panel: same children, but portalled and anchored
// to the active token, so the list overlays instead of growing the composer.
export const Popover = () => {
  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind === "message") {
      console.log(data.text);
    }
  };

  return (
    <Composer.Root
      onSubmit={handleSubmit}
      commands={{ "@": { kind: "insert", trigger: "word-boundary", items: MENTIONS } }}
      className="flex w-full max-w-xl flex-col"
    >
      {/* Positioning sets --anchor-width and --anchor-available-height; the
          width and max-height are yours to derive from them. */}
      <Composer.Popover className="absolute z-50 w-72 overflow-hidden rounded-2xl border border-[#f0f0f0] bg-white shadow-lg dark:border-[#262626] dark:bg-[#181818]">
        {(composer) =>
          composer.commands.active ? (
            // data-empty and data-loading land on Command; the group gates
            // which child shows.
            <Composer.Command prefix="@" className="group/list flex flex-col p-1">
              <Composer.CommandEmpty className="hidden h-8 items-center rounded-[10px] px-3 text-sm text-[#949494] group-data-empty/list:flex dark:text-[#6f6f6f]">
                No files found.
              </Composer.CommandEmpty>
              <Composer.CommandList className="flex max-h-56 flex-col overflow-y-auto group-data-empty/list:hidden">
                {(item) => (
                  <Composer.CommandItem
                    value={item.value}
                    className="flex h-8 w-full cursor-pointer items-center gap-2.5 rounded-[10px] px-3 text-sm outline-none select-none data-highlighted:bg-[#f4f4f4] dark:data-highlighted:bg-[#232323]"
                  >
                    <Composer.CommandItemLabel className="font-medium">
                      {item.label}
                    </Composer.CommandItemLabel>
                    {item.description && (
                      <Composer.CommandItemDescription className="truncate text-xs text-[#949494] dark:text-[#6f6f6f]">
                        {item.description}
                      </Composer.CommandItemDescription>
                    )}
                  </Composer.CommandItem>
                )}
              </Composer.CommandList>
            </Composer.Command>
          ) : null
        }
      </Composer.Popover>
      <Composer.Container className="cursor-text rounded-2xl border border-[#f0f0f0] bg-white shadow-xs transition-colors focus-within:border-[#ececec] dark:border-[#262626] dark:bg-[#181818] dark:focus-within:border-[#2d2d2d]">
        <Composer.Textarea className="max-h-32 min-h-8 overflow-y-auto px-4 pt-3 text-sm **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-[1.7] [&_[data-composer-editor]:focus]:outline-none **:data-command-badge:rounded-sm **:data-command-badge:bg-[#f4f4f4] **:data-command-badge:px-0.5 **:data-command-badge:dark:bg-[#232323] **:data-command-hint:text-[#949494]">
          <Composer.Placeholder
            placeholder="Type @ to mention a file…"
            className="leading-[1.7] text-[#949494] dark:text-[#6f6f6f]"
          />
        </Composer.Textarea>
        <Composer.Actions className="flex justify-end gap-2 p-2">
          <Composer.Submit className="flex size-8 items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-opacity disabled:opacity-40 dark:bg-[#fcfcfc] dark:text-[#111111]">
            <SendIcon />
          </Composer.Submit>
        </Composer.Actions>
      </Composer.Container>
    </Composer.Root>
  );
};

const SendIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M8 13V3m0 0L3.5 7.5M8 3l4.5 4.5" />
  </svg>
);
```

### Ask-user flow

Setting the `questions` prop — typically from an assistant's clarifying
question — arms the ask-user flow and flips `askUser.active`; compose the
`AskUser` parts from `@intentface/chat/ask-user` inside a `Panel` (or
`Popover`), reading the current step from `useComposer(c => c.askUser)`. The
flow steps through each question (single- or multi-select), and answering or
skipping the last one fires `onSubmit` with `{ kind: "answers" }`. Passing a
fresh `questions` array re-arms it from the first step.

```tsx title="primitives/composer/demos/ask-user-flow.tsx"
"use client";

import { AskUser } from "@intentface/chat/ask-user";
import {
  type AskUserQuestion,
  Composer,
  type ComposerSubmitData,
  useComposer,
} from "@intentface/chat/composer";
import { type ComponentProps, useState } from "react";

// Setting `questions` arms the flow and flips askUser.active. Answering or
// skipping the last one fires onSubmit with { kind: "answers" }.
const QUESTIONS: AskUserQuestion[] = [
  {
    question: "Which framework are you deploying to?",
    options: [
      { label: "Next.js", description: "App Router on Vercel." },
      { label: "Vite", description: "SPA on any static host." },
      { label: "Remix", description: "Full-stack on a Node server." },
    ],
  },
  {
    question: "Which features do you need?",
    multiSelect: true,
    options: [
      { label: "Auth", description: "Sessions and sign-in." },
      { label: "Database", description: "Persistent storage." },
      { label: "File uploads", description: "Attachments and media." },
    ],
  },
  {
    question: "What matters most for this project?",
    options: [
      { label: "Speed", description: "Ship as fast as possible." },
      { label: "Scale", description: "Handle heavy traffic." },
      { label: "Cost", description: "Keep the bill low." },
    ],
  },
];

export const AskUserFlow = () => {
  const [questions, setQuestions] = useState<AskUserQuestion[]>(QUESTIONS);
  const [done, setDone] = useState(false);

  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind === "answers") setDone(true);
  };

  const reset = () => {
    setDone(false);
    setQuestions([...QUESTIONS]);
  };

  return (
    // Reserve height and bottom-anchor so the panel opening never shifts the page.
    <div className="flex min-h-[440px] w-full max-w-xl flex-col items-center justify-end gap-3">
      <Composer.Root
        questions={done ? undefined : questions}
        onSubmit={handleSubmit}
        className="flex w-full flex-col"
      >
        {/* anchor={false} makes the panel an in-flow block that grows the
            composer upward; the default is a portaled overlay. */}
        <Composer.Panel
          anchor={false}
          className="mb-2 overflow-hidden rounded-2xl border border-[#f0f0f0] bg-white dark:border-[#262626] dark:bg-[#181818]"
        >
          {!done && <Prompt />}
        </Composer.Panel>
        <Composer.Container className="cursor-text rounded-2xl border border-[#f0f0f0] bg-white shadow-xs transition-colors focus-within:border-[#ececec] dark:border-[#262626] dark:bg-[#181818] dark:focus-within:border-[#2d2d2d]">
          <Composer.Textarea className="max-h-32 min-h-8 overflow-y-auto px-4 pt-3 text-sm **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-[1.7] [&_[data-composer-editor]:focus]:outline-none">
            <Composer.Placeholder
              placeholder={done ? "All set — reset to try again" : "Or type your own answer…"}
              className="leading-[1.7] text-[#949494] dark:text-[#6f6f6f]"
            />
          </Composer.Textarea>
          <Composer.Actions className="flex items-center justify-end gap-2 p-2">
            {done ? (
              <Composer.Submit className="flex size-8 items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-opacity disabled:opacity-40 dark:bg-[#fcfcfc] dark:text-[#111111]">
                <SendIcon />
              </Composer.Submit>
            ) : (
              <Controls />
            )}
          </Composer.Actions>
        </Composer.Container>
      </Composer.Root>
      {done && (
        <button
          type="button"
          onClick={reset}
          className="cursor-pointer rounded-full border border-[#f0f0f0] bg-white px-4 py-1.5 text-sm font-medium text-[#686868] transition-colors hover:bg-[#fafafa] dark:border-[#262626] dark:bg-[#181818] dark:text-[#9b9b9b] dark:hover:bg-[#232323]"
        >
          Reset questions
        </button>
      )}
    </div>
  );
};

// The parts are structural; the current question and selections come from the
// composer's askUser slice.
const Prompt = () => {
  const askUser = useComposer((composer) => composer.askUser);
  const question = askUser.questions?.[askUser.step];

  if (!question) return null;

  const entry = askUser.answers.get(askUser.step);
  const total = askUser.questions?.length ?? 0;

  return (
    <AskUser.Root className="flex flex-col gap-1 p-2">
      <AskUser.Header className="flex h-7 items-center gap-2 px-2">
        <AskUser.Label className="text-sm font-medium">{question.question}</AskUser.Label>
        {!askUser.isSingle && total > 1 && (
          <AskUser.Navigation className="ml-auto flex items-center gap-1 text-[#949494] dark:text-[#6f6f6f]">
            <AskUser.Previous
              onClick={askUser.goBack}
              disabled={askUser.step === 0}
              aria-label="Previous question"
              className="cursor-pointer rounded-md px-1.5 disabled:opacity-30"
            >
              ‹
            </AskUser.Previous>
            <AskUser.StepLabel className="text-xs tabular-nums">
              {({ current, total: count }) => `${current} of ${count}`}
            </AskUser.StepLabel>
            <AskUser.Next
              onClick={askUser.goNext}
              disabled={askUser.step === total - 1}
              aria-label="Next question"
              className="cursor-pointer rounded-md px-1.5 disabled:opacity-30"
            >
              ›
            </AskUser.Next>
          </AskUser.Navigation>
        )}
      </AskUser.Header>
      {question.options && (
        <AskUser.Options
          ref={askUser.optionsRef}
          multiSelect={Boolean(question.multiSelect)}
          groupName={`question-${askUser.step}`}
          className="flex flex-col"
        >
          {question.options.map((option) => {
            const selected = Boolean(entry?.selected.has(option.label));
            return (
              <AskUser.Option
                key={option.label}
                value={option.label}
                selected={selected}
                onSelect={() => askUser.toggleOption(option.label)}
                className="flex cursor-pointer items-start gap-2 rounded-[10px] p-2 outline-none transition-colors data-highlighted:bg-[#f4f4f4] dark:data-highlighted:bg-[#232323]"
              >
                {/* Decorative: the Option itself carries the radio/checkbox role. */}
                <span
                  aria-hidden="true"
                  className={`mt-px flex size-4 shrink-0 items-center justify-center rounded border text-[10px] ${
                    selected
                      ? "border-[#1a1a1a] bg-[#1a1a1a] text-white dark:border-[#fcfcfc] dark:bg-[#fcfcfc] dark:text-[#111111]"
                      : "border-[#ececec] dark:border-[#2d2d2d]"
                  }`}
                >
                  {selected ? "✓" : ""}
                </span>
                <AskUser.OptionContent className="flex flex-col gap-0.5">
                  <AskUser.OptionLabel className="text-sm leading-tight">
                    {option.label}
                  </AskUser.OptionLabel>
                  {option.description && (
                    <AskUser.OptionDescription className="text-xs text-[#949494] dark:text-[#6f6f6f]">
                      {option.description}
                    </AskUser.OptionDescription>
                  )}
                </AskUser.OptionContent>
              </AskUser.Option>
            );
          })}
        </AskUser.Options>
      )}
    </AskUser.Root>
  );
};

// Dismiss is a plain button you wire up; Continue is type=submit, so the
// enclosing Composer.Root form drives it.
const Controls = () => {
  const askUser = useComposer((composer) => composer.askUser);

  return (
    <>
      <AskUser.Dismiss
        onClick={askUser.dismissStep}
        className="cursor-pointer rounded-full px-3 py-1.5 text-sm text-[#949494] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:text-[#6f6f6f] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]"
      >
        Skip
      </AskUser.Dismiss>
      <AskUser.Continue className="cursor-pointer rounded-full bg-[#1a1a1a] px-3.5 py-1.5 text-sm font-medium text-white dark:bg-[#fcfcfc] dark:text-[#111111]">
        {askUser.isLastStep ? "Done" : "Continue"}
      </AskUser.Continue>
    </>
  );
};

const SendIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M8 13V3m0 0L3.5 7.5M8 3l4.5 4.5" />
  </svg>
);
```

### Attachments

`Composer.Attachments` carries the accept/limit policy and the hidden file
input — it renders no strip of its own. The visible list is yours: read
`useComposer(c => c.attachments)` and lay the items out with the
`@intentface/chat/attachments` parts. `Composer.AttachmentTrigger` opens the
file dialog, and files can also be dropped onto the composer.

```tsx title="primitives/composer/demos/attachments.tsx"
"use client";

import { Attachments } from "@intentface/chat/attachments";
import { Composer, type ComposerSubmitData, useComposer } from "@intentface/chat/composer";
import type { ComponentProps } from "react";

// Composer.Attachments carries the policy and the hidden file input; the strip
// itself is yours. Files can be picked with the trigger or dropped on the composer.
export const AttachmentsDemo = () => {
  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind === "message") {
      console.log(data.files);
    }
  };

  return (
    // Reserve height so the strip appearing grows the composer upward.
    <div className="flex min-h-[220px] w-full max-w-xl flex-col justify-end">
      <Composer.Root onSubmit={handleSubmit} className="flex flex-col">
        <Composer.Container className="cursor-text rounded-2xl border border-[#f0f0f0] bg-white shadow-xs transition-colors focus-within:border-[#ececec] dark:border-[#262626] dark:bg-[#181818] dark:focus-within:border-[#2d2d2d]">
          <Composer.Attachments accept="image/*,application/pdf" maxFiles={4}>
            <Strip />
          </Composer.Attachments>
          <Composer.Textarea className="max-h-32 min-h-8 overflow-y-auto px-4 pt-3 text-sm **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-[1.7] [&_[data-composer-editor]:focus]:outline-none">
            <Composer.Placeholder
              placeholder="Attach a file, or drag one in…"
              className="leading-[1.7] text-[#949494] dark:text-[#6f6f6f]"
            />
          </Composer.Textarea>
          <Composer.Actions className="flex items-center justify-end gap-1 p-2">
            <Composer.AttachmentTrigger
              aria-label="Attach a file"
              className="flex size-8 cursor-pointer items-center justify-center rounded-full text-[#949494] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:text-[#6f6f6f] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]"
            >
              <PaperclipIcon />
            </Composer.AttachmentTrigger>
            <Composer.Submit className="flex size-8 items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-opacity disabled:opacity-40 dark:bg-[#fcfcfc] dark:text-[#111111]">
              <SendIcon />
            </Composer.Submit>
          </Composer.Actions>
        </Composer.Container>
      </Composer.Root>
    </div>
  );
};

// The store holds the items; the parts are structural slots with no opinion
// about how a file should look.
const Strip = () => {
  const attachments = useComposer((composer) => composer.attachments);

  if (attachments.items.length === 0) return null;

  return (
    <Attachments.Root className="flex flex-wrap gap-2 px-3 pt-3">
      {attachments.items.map((item) => (
        <Attachments.Item
          key={item.id}
          className="flex items-center gap-2 rounded-xl border border-[#f0f0f0] bg-[#fafafa] py-1.5 pr-1.5 pl-2.5 text-xs dark:border-[#262626] dark:bg-[#1f1f1f]"
        >
          <span className="max-w-40 truncate">{item.filename ?? "file"}</span>
          <span className="text-[#949494] dark:text-[#6f6f6f]">
            {formatFileSize(item.fileSize)}
          </span>
          <Attachments.Remove
            onRemove={() => attachments.remove(item.id)}
            filename={item.filename}
            className="flex size-5 cursor-pointer items-center justify-center rounded-full text-[#949494] transition-colors hover:bg-[#ececec] hover:text-[#1a1a1a] dark:hover:bg-[#2d2d2d] dark:hover:text-[#fcfcfc]"
          >
            <CrossIcon />
          </Attachments.Remove>
        </Attachments.Item>
      ))}
    </Attachments.Root>
  );
};

const formatFileSize = (bytes?: number) => {
  if (!bytes) return "";
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};

const PaperclipIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M13 7.5 8 12.5a3 3 0 0 1-4.5-4L9 3a2 2 0 0 1 3 3l-5.5 5.5a1 1 0 0 1-1.5-1.5L10 5" />
  </svg>
);

const CrossIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="12"
    height="12"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    aria-hidden="true"
    {...props}
  >
    <path d="m4.5 4.5 7 7m-7 0 7-7" />
  </svg>
);

const SendIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M8 13V3m0 0L3.5 7.5M8 3l4.5 4.5" />
  </svg>
);
```

### Controlled value

`Composer.Textarea` accepts a controlled plain-text `value` with
`onValueChange`. Here the parent's buttons drive the field and typing reports
back.

```tsx title="primitives/composer/demos/controlled.tsx"
"use client";

import { Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import { type ComponentProps, useState } from "react";

// The Textarea's plain-text value is controlled by the parent: the buttons
// drive it, and typing reports back through onValueChange.
export const Controlled = () => {
  const [text, setText] = useState("");

  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind === "message") {
      console.log(data.text);
    }
  };

  return (
    <div className="flex w-full max-w-xl flex-col items-center gap-3">
      <Composer.Root onSubmit={handleSubmit} className="flex w-full flex-col">
        <Composer.Container className="cursor-text rounded-2xl border border-[#f0f0f0] bg-white shadow-xs transition-colors focus-within:border-[#ececec] dark:border-[#262626] dark:bg-[#181818] dark:focus-within:border-[#2d2d2d]">
          <Composer.Textarea
            value={text}
            onValueChange={setText}
            className="max-h-32 min-h-8 overflow-y-auto px-4 pt-3 text-sm **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-[1.7] [&_[data-composer-editor]:focus]:outline-none"
          >
            <Composer.Placeholder
              placeholder="Controlled by the parent…"
              className="leading-[1.7] text-[#949494] dark:text-[#6f6f6f]"
            />
          </Composer.Textarea>
          <Composer.Actions className="flex justify-end gap-2 p-2">
            <Composer.Submit className="flex size-8 items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-opacity disabled:opacity-40 dark:bg-[#fcfcfc] dark:text-[#111111]">
              <SendIcon />
            </Composer.Submit>
          </Composer.Actions>
        </Composer.Container>
      </Composer.Root>
      <div className="flex items-center gap-2">
        <button
          type="button"
          onClick={() => setText("Summarize this thread")}
          className="cursor-pointer rounded-full border border-[#f0f0f0] bg-white px-4 py-1.5 text-sm font-medium text-[#686868] transition-colors hover:bg-[#fafafa] dark:border-[#262626] dark:bg-[#181818] dark:text-[#9b9b9b] dark:hover:bg-[#232323]"
        >
          Set prompt
        </button>
        <button
          type="button"
          onClick={() => setText("")}
          className="cursor-pointer rounded-full border border-[#f0f0f0] bg-white px-4 py-1.5 text-sm font-medium text-[#686868] transition-colors hover:bg-[#fafafa] dark:border-[#262626] dark:bg-[#181818] dark:text-[#9b9b9b] dark:hover:bg-[#232323]"
        >
          Clear
        </button>
      </div>
    </div>
  );
};

const SendIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M8 13V3m0 0L3.5 7.5M8 3l4.5 4.5" />
  </svg>
);
```

### External store

`Composer.createStore()` returns a handle you own. Pass it via `store` and
drive the composer from anywhere — a toolbar, a shortcut — through
`store.controller`, with no context or ref threading.

```tsx title="primitives/composer/demos/store.tsx"
"use client";

import { Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import type { ComponentProps } from "react";

// A store handle created outside the tree. The button drives the composer
// through store.controller — no context, no hook, no ref threading.
const store = Composer.createStore();

export const Store = () => {
  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind === "message") {
      console.log(data.text);
    }
  };

  return (
    <div className="flex w-full max-w-xl flex-col items-center gap-3">
      <Composer.Root store={store} onSubmit={handleSubmit} className="flex w-full flex-col">
        <Composer.Container className="cursor-text rounded-2xl border border-[#f0f0f0] bg-white shadow-xs transition-colors focus-within:border-[#ececec] dark:border-[#262626] dark:bg-[#181818] dark:focus-within:border-[#2d2d2d]">
          <Composer.Textarea className="max-h-32 min-h-8 overflow-y-auto px-4 pt-3 text-sm **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-[1.7] [&_[data-composer-editor]:focus]:outline-none">
            <Composer.Placeholder
              placeholder="Driven by an external store handle…"
              className="leading-[1.7] text-[#949494] dark:text-[#6f6f6f]"
            />
          </Composer.Textarea>
          <Composer.Actions className="flex justify-end gap-2 p-2">
            <Composer.Submit className="flex size-8 items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-opacity disabled:opacity-40 dark:bg-[#fcfcfc] dark:text-[#111111]">
              <SendIcon />
            </Composer.Submit>
          </Composer.Actions>
        </Composer.Container>
      </Composer.Root>
      <button
        type="button"
        onClick={() => store.controller.insertText("@channel ")}
        className="cursor-pointer rounded-full border border-[#f0f0f0] bg-white px-4 py-1.5 text-sm font-medium text-[#686868] transition-colors hover:bg-[#fafafa] dark:border-[#262626] dark:bg-[#181818] dark:text-[#9b9b9b] dark:hover:bg-[#232323]"
      >
        Insert from outside
      </button>
    </div>
  );
};

const SendIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="M8 13V3m0 0L3.5 7.5M8 3l4.5 4.5" />
  </svg>
);
```

## Multiple instances

Every `<Composer.Root>` creates its own isolated store, so several composers can
live on one page with no wiring. To reach a composer from outside its tree —
toolbars, keyboard shortcuts, status bars — create an explicit handle with
`Composer.createStore()`, pass it via the `store` prop, and use
`store.controller` (imperative) or `useComposerStore(store, selector)`
(reactive).

## Performance

Typing costs nothing outside the composer: editor state lives in the store and
parts subscribe by slice with `useComposer(selector)`, so a keystroke
re-renders only the parts that read the changed slice — never your app tree.

The integration risk runs the other way: a streaming chat re-rendering the
composer on every chunk. The editor is the most expensive thing to re-render
per token, so isolate it behind a thin bridge — subscribe to your messages
state in a small component, derive the panel props there, and hand them to a
memoized inner composer:

```tsx
// Reads the stream and derives only what the composer needs.
const ChatInput = ({ messages, status }: ChatInputProps) => {
  const panel = derivePanelState(messages, status);
  return <ChatInputInner panel={panel} status={status} />;
};

const ChatInputInner = memo(({ panel, status }: ChatInputInnerProps) => (
  <Composer.Root /* … the composer tree … */ />
));
```

`derivePanelState` is yours — what belongs in the panel (an ask-user prompt, a
status line) is your product's policy. Return a referentially stable value while
nothing has changed, so the inner composer bails on every chunk except real panel
transitions.

## Keyboard

`Composer.Root` is a `<form>`, so submission and key handling follow form
semantics. The editor interprets keys by mode — a command list being open, or
ask-user being active, takes priority over normal typing.

**Normal typing**

export const normalKeys = [
  { attribute: "Enter", description: "Submit the form (requestSubmit)." },
  { attribute: "Shift + Enter", description: "Insert a soft line break." },
  { attribute: "Backspace", description: "When the editor is empty and attachments exist, remove the last attachment." },
];

<AttributesTable rows={normalKeys} />

**Command list open**

export const commandKeys = [
  { attribute: "ArrowUp / ArrowDown", description: "Move the highlight through the items." },
  { attribute: "ArrowLeft / ArrowRight", description: "Move the caret within the trigger token." },
  { attribute: "Enter / Tab", description: "Select the highlighted item." },
  { attribute: "Escape", description: "Close the command list." },
];

<AttributesTable rows={commandKeys} />

**Ask-user active**

export const askUserKeys = [
  { attribute: "ArrowUp / ArrowDown", description: "Navigate options; up past the first refocuses the editor." },
  { attribute: "ArrowLeft / ArrowRight", description: "Go to the previous / next question." },
  { attribute: "Enter", description: "Select the highlighted option." },
  { attribute: "Escape", description: "Dismiss the current step." },
  { attribute: "Any character", description: "Focus the editor and start typing a free-text answer." },
];

<AttributesTable rows={askUserKeys} />

While `isGenerating`, `Escape` inside the composer calls `Submit`'s `onStop` (via
`useComposerSubmit`): from the editor, a button, or a part a positioned `Panel`
renders elsewhere on the page. An Escape outside the composer is left alone, as is
one something already handled, such as the command list closing. The composer
marks the Escape it uses with `preventDefault()`, so the same press does not also
close a surface around it, such as a floating `Tabs.Popup`.

To keep Escape for something else, call `event.preventPrimitiveHandler()` in
`Composer.Root`'s `onKeyDown`. To stop from anywhere in the chat, handle Escape on
its container — the floating dock on the Tabs page does this on `Thread.Root`.

## Accessibility

### Command popup (combobox)

The editor is a `role="textbox"` with combobox wiring: `aria-autocomplete="list"`,
`aria-haspopup="listbox"`, plus `aria-controls` and `aria-activedescendant`
while a trigger popup is open. The popup renders `role="listbox"` (labelled
"Suggestions", overridable) with `role="option"` rows carrying stable ids and
`aria-selected` on the highlight — keyboard selection stays in the editor, so
rows are never tab stops. Grouped lists wrap in `role="group"` labelled by
their `CommandGroupLabel`. Async resolution sets `aria-busy` on the listbox and
the empty state is a `role="status"` region. Committed mention chips announce
as atomic tokens ("Label, @ mention").

One deliberate deviation from the strict APG combobox pattern: `aria-expanded`
is omitted — ARIA 1.2 forbids it on `textbox`, and switching to
`role="combobox"` would forbid `aria-multiline`, which matters more for a
multiline chat field. The popup announces through `aria-haspopup` and live
`aria-activedescendant` narration instead. Tab selects the highlighted option
while the popup is open (Linear-style) rather than moving focus.

The placeholder overlay is `aria-hidden` — assistive tech hears the string
`placeholder` prop via `aria-placeholder`. When rich `children` replace the
string, keep a `placeholder` string alongside (children win visually) so the
hint still announces.

### Ask-user questions

While `questions` are active, the options form a labelled group: `AskUser.Options`
renders `role="radiogroup"` (single-select) or `role="group"` (multi-select),
named by `AskUser.Label` and described by `AskUser.StepLabel` automatically.
Each `AskUser.Option` is the real control — `role="radio"` / `role="checkbox"`
with `aria-checked` — and holds the group's single tab stop via roving
`tabindex`: DOM focus follows the highlight, entering the group on arrival and
moving with ArrowUp/ArrowDown. Enter and Space select; Escape dismisses the
step (from the options or the editor); typing any character returns focus to
the editor as a free-text answer. Never nest an interactive control inside an
option — the option itself is the control, and the composer's question-mode
key handling treats native inputs as foreign editables.

- **Form semantics.** The root renders a `<form>`; `Enter` submits and
  `Submit` is a real submit button, so the composer works with standard form
  and assistive-tech expectations.
- **Click-to-focus.** `Container` is a mouse-only focus passthrough: clicking
  its chrome focuses the editor, but clicks on nested buttons, links, and
  inputs pass through. It carries no role and no tab stop — keyboard users tab
  straight to the editor.
- **Ask-user focus.** Activating `questions` moves focus into the options
  group; typing any character returns focus to the editor for a free-text
  answer.
- **Default names.** `Submit` is named "Send message", flipping to
  "Stop generating" while generating so the morphed control announces
  correctly; `AttachmentTrigger` is named "Add attachment". Both are
  overridable via `aria-label`.
- **Busy states.** Submitting/generating are exposed as `data-submitting` on
  the root and the `Submit` label flip — the package emits no live-region copy;
  add a consumer-owned `role="status"` region if you want in-flight
  announcements beyond the button state.

## API reference

Every part accepts `className`, `style`, and `render` (see
[Styling](/handbook/styling)) and emits a bespoke part attribute (`data-<part>`) unless noted.
Only part-specific props and state-driven attributes are listed below. These
tables are hand-authored from the package source.

### Composer

The `<form>` that owns submission, store resolution, drag-and-drop, and the
prop → store bridges. Renders `data-composer-root`.

export const rootProps = [
  { name: "onSubmit", type: "(data: ComposerSubmitData) => void | Promise<void>", description: "Fires on message submit and on ask-user answers. Discriminate on data.kind." },
  { name: "commands", type: "ComposerCommandsMap", default: "{}", description: "Prefix → command config (kind, trigger, items, suggestion, placeholder). suggestion: false disables ghost-text completion; placeholder sets the per-prefix empty-query hint (never shown alongside a suggestion — the suggestion wins)." },
  { name: "questions", type: "AskUserQuestion[]", description: "When set, arms the ask-user flow (blurs the editor, flips askUser.active)." },
  { name: "isSubmitting", type: "boolean", default: "false", description: "Flips Submit to a stop affordance and gates re-submits." },
  { name: "value", type: "ComposerSnapshot", description: "Controlled editor content." },
  { name: "defaultValue", type: "ComposerSnapshot", description: "Uncontrolled initial editor content." },
  { name: "onValueChange", type: "(snapshot: ComposerSnapshot) => void", description: "Fires on editor updates with a fresh snapshot." },
  { name: "store", type: "ComposerStore", default: "per-mount instance", description: "An explicit Composer.createStore() handle. A per-mount instance is reset on unmount; an explicit handle is not." },
];

<PropsTable rows={rootProps} />

export const rootAttrs = [
  { attribute: "data-composer-root", description: "The form element." },
  { attribute: "data-submitting", description: "Present while isSubmitting is true." },
  { attribute: "data-dragging", description: "Present while files are dragged over the drop scope." },
];

<AttributesTable rows={rootAttrs} />

### Composer.createStore()

Returns a `ComposerStore` handle. Pass it to `store`, read it with
`useComposerStore(store, selector)`, and drive it imperatively through
`store.controller` (`focus`, `blur`, `clear`, `insertText`, `insertChip`,
`getText`, `setText`, `serialize`, `ensureFocus`).

`onSubmit` receives a discriminated `ComposerSubmitData`:

```ts
type ComposerSubmitData =
  | { kind: "message"; text: string; files: AttachmentItem[] }
  | { kind: "answers"; answers: ComposerAnswerEntry[] };
```

`files` are the generic attachment descriptors — your `onSubmit` adapts them to
your wire format (this app inlines blob URLs into AI SDK file parts with its
`prepareAttachmentsForSend` helper).

### Composer.Container

Focus proxy and layout frame. Renders `data-composer-container`; clicking its
chrome focuses the editor. Not focusable itself — it carries no role or tab
stop.

### Composer.Textarea

The editor surface — a contenteditable that acts like a native textarea with
atomic mention chips. Renders `data-composer-textarea` wrapping the
editable element (`data-composer-editor`). With `name` set, a hidden
input mirrors the serialized text into the surrounding form's FormData.

export const textareaProps = [
  { name: "value", type: "string", description: "Controlled plain-text value." },
  { name: "onValueChange", type: "(text: string) => void", description: "Fires on each editor update with plain text." },
  { name: "disabled", type: "boolean", default: "false", description: "Makes the editor non-editable." },
  { name: "autoFocus", type: "boolean", default: "false", description: "Focus the editor on mount." },
  { name: "placeholder", type: "string", description: "Native-textarea-style placeholder (also sets aria-placeholder). For rich content, pass children instead." },
  { name: "submitOn", type: '"enter" | "shift-enter"', default: '"enter"', description: "Which Enter chord sends the message; the other inserts a soft break. Pair \"shift-enter\" with enterKeyHint=\"enter\"." },
  { name: "renderChip", type: "(chip: ChipData) => ReactNode", description: "Custom renderer for a committed chip. ChipData carries prefix, value, label, and icon — the default renders label only; add chip.prefix in front to show the trigger." },
  { name: "children", type: "ReactNode", description: "Placeholder overlay, shown while empty." },
  { name: "maxLength", type: "number", description: "Logical-length cap — one per character, one per chip. IME input is capped at composition commit." },
  { name: "required", type: "boolean", default: "false", description: "Native form validation via the hidden input (requires name)." },
  { name: "name", type: "string", description: "Mirrors the serialized text (chips as chip-markdown) into FormData." },
  { name: "spellCheck", type: "boolean", default: "false", description: "Forwarded to the editable element, with autoCapitalize, enterKeyHint, inputMode, dir, and aria-* passthrough." },
  { name: "onFocus / onBlur / onKeyDown / onKeyUp / onPaste / onCopy / onCut", type: "React handlers", description: "Route to the editable element, native-textarea style: they run before the engine, and preventDefault overrides the engine's handling (keydown/clipboard)." },
];

<PropsTable rows={textareaProps} />

export const textareaAttrs = [
  { attribute: "data-composer-textarea", description: "The wrapper element." },
  { attribute: "data-composer-editor", description: "The contenteditable editor element." },
  { attribute: "data-filled", description: "Present when the editor has content." },
  { attribute: "data-disabled", description: "Present when disabled." },
  { attribute: "data-command-badge", description: "On the active trigger token decoration (e.g. the leading @)." },
  { attribute: "data-command-placeholder", description: "On the inline hint decoration after a trigger." },
  { attribute: "data-command-hint", description: "The badge's hint element — ghost-text completion of the highlighted item, or the empty-query placeholder. A real span, engine-owned like the badge." },
];

<AttributesTable rows={textareaAttrs} />

### Composer.Placeholder

Static or custom placeholder. Renders `data-composer-placeholder-text`.
Pass **either** `placeholder` or `children`, not both.

export const placeholderProps = [
  { name: "placeholder", type: "string", description: "Static placeholder text (mutually exclusive with children)." },
  { name: "children", type: "ReactNode", description: "Custom placeholder content (mutually exclusive with placeholder)." },
];

<PropsTable rows={placeholderProps} />

### Composer.ContextWindow

A slot above the input, open only when it has content and no panel is active.
Content-driven and exposing `data-open`/`data-closed` like `Panel`/`Popover`.
Renders `data-composer-context-window`.

export const contextWindowAttrs = [
  { attribute: "data-composer-context-window", description: "The context-window element." },
  { attribute: "data-open", description: "Present while the window has content and no panel is active." },
  { attribute: "data-closed", description: "Present while empty or yielding to an active panel." },
];

<AttributesTable rows={contextWindowAttrs} />

### Composer.Actions

Layout row for buttons. Renders `data-composer-actions`. No
part-specific props or state attributes.

### Composer.Submit

Submit button that morphs into a stop control while generating. Renders
`data-composer-submit`.

export const submitProps = [
  { name: "isGenerating", type: "boolean", default: "false", description: "Morphs into a stop control: type becomes \"button\" and clicking calls onStop." },
  { name: "onStop", type: "() => void", description: "Abort callback — on click while generating, or on Escape inside the composer (see Keyboard)." },
];

<PropsTable rows={submitProps} />

export const submitAttrs = [
  { attribute: "data-composer-submit", description: "The button element." },
  { attribute: "data-generating", description: "Present while isGenerating is true." },
];

<AttributesTable rows={submitAttrs} />

### Composer.Attachments

Owns the hidden file input and renders the file strip / drop zone as children.
This part does **not** take `className` / `style` / `render` and emits no
part attribute of its own.

export const attachmentsProps = [
  { name: "convert", type: "(file: File) => AttachmentItem", default: "blob ingestion", description: "Converts a picked/dropped/pasted File into an item. Override (with destroy) for custom ids, extra fields, or upload-backed items." },
  { name: "destroy", type: "(item: AttachmentItem) => void", default: "revoke blob URL", description: "Cleanup for a removed item." },
  { name: "accept", type: "string", default: '"" (everything)', description: "Accepted MIME types — the package imposes no policy; pass yours." },
  { name: "maxFiles", type: "number", default: "unlimited", description: "Maximum file count." },
  { name: "maxFileSize", type: "number", default: "unlimited", description: "Maximum size per file." },
  { name: "multiple", type: "boolean", default: "true", description: "Allow selecting multiple files." },
  { name: "globalDrop", type: "boolean", default: "false", description: "Accept drops anywhere in the document, not just over the composer." },
  { name: "children", type: "ReactNode", description: "The visible strip / drop zone." },
];

<PropsTable rows={attachmentsProps} />

### Composer.AttachmentTrigger

Button that opens the file dialog. Renders
`data-composer-attachment-trigger` named "Add attachment" by default. No
part-specific props.

### Composer.Panel

A surface region above the field. `children` is either plain nodes or a callback
`(composer) => ReactNode` receiving the composer state, so you pick what to show
by priority (`commands.active ? <Command/> : askUser.active ? <AskUser.Root/> :
null`). By default (`anchor`) it renders as a **collision-aware, portaled overlay**
anchored to the `Container` — flipping / shifting / sizing to stay on screen (via
`@floating-ui/dom`); anchor a ref/element elsewhere, or pass `anchor={false}` for an
**in-flow** block that grows the composer. Pass `pin` to hold the placement without
flip/shift. `open` — whether the resolved content is non-empty — arrives as the
`render` prop's second argument and is mirrored to `data-open`/`data-closed`; the
host stays mounted through its close animation (exposing
`data-starting-style`/`data-ending-style`) and, when positioned, publishes the
resolved `data-side`/`data-align` so the transition origin follows a flip.

When positioned, the overlay publishes the anchor's geometry as CSS variables —
opt in from your styling rather than having the primitive impose a size (Base
UI-style): `--anchor-width` / `--anchor-height` (the anchor's box, e.g.
`width: var(--anchor-width)` to match the composer) and `--anchor-available-height`
(free space toward the resolved side, e.g. `max-height: var(--anchor-available-height)`
so the content scrolls instead of overflowing).

export const panelProps = [
  { name: "children", type: "ReactNode | ((composer: ComposerState) => ReactNode)", description: "Panel content, or a callback that reads composer state and returns one branch by priority." },
  { name: "anchor", type: "boolean | Element | RefObject<Element>", description: "Positioned, portaled overlay (default true, anchored to the Container) or an in-flow block (false). A ref/element anchors elsewhere. Match its width with width: var(--anchor-width)." },
  { name: "side", type: '"top" | "bottom" | "left" | "right"', description: "Overlay preferred side; flips to the opposite on collision. Default \"top\"." },
  { name: "align", type: '"start" | "center" | "end"', description: "Overlay alignment along the side. Default \"center\"." },
  { name: "sideOffset", type: "number", description: "Overlay gap between the anchor and the panel, in px. Default 0." },
  { name: "pin", type: "boolean", description: "Hold side/align without collision repositioning (drops flip + shift). Default false." },
];

<PropsTable rows={panelProps} />

export const panelAttrs = [
  { attribute: "data-composer-panel", description: "The panel element." },
  { attribute: "data-open", description: "Present while the resolved content is non-empty." },
  { attribute: "data-closed", description: "Present while empty." },
  { attribute: "data-starting-style", description: "Present on the first open frame — the enter transition's from-state." },
  { attribute: "data-ending-style", description: "Present while the close transition runs, before unmount." },
  { attribute: "data-side", values: '"top" | "bottom" | "left" | "right"', description: "Resolved side when positioned (anchor) — the origin to animate from." },
  { attribute: "data-align", values: '"start" | "center" | "end"', description: "Resolved alignment when positioned." },
];

<AttributesTable rows={panelAttrs} />

### Composer.Popover

Floating alternative to `Composer.Panel`. Takes the same `children` (nodes or a
state callback) but portals them to the body, anchored to the active trigger
token — overlaying instead of growing the composer. It's collision-aware (via
`@floating-ui/dom`): opens upward by default and flips below / shifts / caps its
height to stay on screen, tracking the anchor across scroll, resize, and composer
growth. It stays mounted and exposes `open` the same way as `Panel` (the `render`
prop's second argument plus `data-open`/`data-closed`), and publishes the resolved
`data-side`/`data-align` so the transition origin follows a flip. Positioning is
written imperatively — the styled layer supplies only box and animation styling,
not `left`/`top`.

export const popoverProps = [
  { name: "children", type: "ReactNode | ((composer: ComposerState) => ReactNode)", description: "Same content as Panel — nodes or a state callback." },
  { name: "pin", type: "boolean", description: "Hold the placement without collision repositioning (drops flip + shift). Default false." },
];

<PropsTable rows={popoverProps} />

export const popoverAttrs = [
  { attribute: "data-composer-popover", description: "The portaled popover element." },
  { attribute: "data-open", description: "Present while the resolved content is non-empty." },
  { attribute: "data-closed", description: "Present while empty (stays mounted, holding its last position)." },
  { attribute: "data-side", values: '"top" | "bottom" | "left" | "right"', description: "Resolved side — flips to \"bottom\" when there's no room above; the origin to animate from." },
  { attribute: "data-align", values: '"start" | "center" | "end"', description: "Resolved alignment along the side." },
];

<AttributesTable rows={popoverAttrs} />

### Composer.Command

The command popup for one prefix. Renders `data-composer-command-list`
**only while that prefix is active** (returns nothing otherwise).

export const commandListProps = [
  { name: "prefix", type: "string", default: "(required)", description: "Which trigger prefix this list serves." },
];

<PropsTable rows={commandListProps} />

export const commandListAttrs = [
  { attribute: "data-composer-command-list", description: "The list element." },
  { attribute: "data-loading", description: "Present while async items are being fetched." },
  { attribute: "data-empty", description: "Present when no items match." },
];

<AttributesTable rows={commandListAttrs} />

### Composer.CommandList

Maps resolved items through a render-prop child. Renders
`data-composer-command-items`.

export const commandItemsProps = [
  { name: "children", type: "(item: Item) => ReactNode", default: "(required)", description: "Row renderer, called per resolved item." },
];

<PropsTable rows={commandItemsProps} />

### Composer.CommandItem

One selectable row. Renders `data-composer-command-item`. To disable a row, set
`disabled: true` on its item data (not on this component) — the row renders
inert (`aria-disabled` + `data-disabled`), the keyboard highlight skips it, and
mouse selection is a no-op. Disabled rows still match the filter.

export const commandItemProps = [
  { name: "value", type: "string", default: "(required)", description: "Item identity, matched against the highlight and selection." },
];

<PropsTable rows={commandItemProps} />

export const commandItemAttrs = [
  { attribute: "data-composer-command-item", description: "The row button." },
  { attribute: "data-highlighted", description: "Present when this row is the active highlight." },
  { attribute: "data-disabled", description: "Present when the item data marks this row disabled." },
];

<AttributesTable rows={commandItemAttrs} />

### Row content & states

`Composer.CommandItemIcon`, `Composer.CommandItemLabel`, and
`Composer.CommandItemDescription` render `<span>`s with
`data-composer-command-item-{icon,label,description}`.
`Composer.CommandLoading` (`composer-command-loading`),
`Composer.CommandEmpty` (`composer-command-empty`), and
`Composer.CommandDismiss` (`composer-command-dismiss`, a button) fill the list
states — all render only your children, so you supply the copy. To group, give
`Composer.CommandGroup` a `groupBy` and a render callback: it buckets the resolved
(already-filtered) items by your key — in first-appearance order, so keyboard nav
still flows top-to-bottom — and calls the callback once per group with
`(group, items)`, wrapping each in `data-command-group`. You render
`Composer.CommandGroupLabel` (`composer-command-group-label`) + the group's items:

```tsx
<Composer.CommandGroup groupBy={(item: Issue) => item.group}>
  {(group, items) => (
    <>
      <Composer.CommandGroupLabel>{group}</Composer.CommandGroupLabel>
      {items.map((item) => (
        <Composer.CommandItem key={item.value} value={item.value}>
          <Composer.CommandItemLabel>{item.label}</Composer.CommandItemLabel>
        </Composer.CommandItem>
      ))}
    </>
  )}
</Composer.CommandGroup>
```

### Ask-user

The composer owns the ask-user *state* — `questions`, the current step, the
answers — but renders none of the question UI. Compose that from the `AskUser`
namespace in `@intentface/chat/ask-user`, reading the step through
`useComposer((c) => c.askUser)` and gating the enclosing `Panel` or `Popover` on
`askUser.active`.

`AskUser.Dismiss` is a plain button you wire to `askUser.dismissStep`;
`AskUser.Continue` is `type="submit"`, so the enclosing `Composer.Root` form
drives it. Neither ships copy — supply the labels as children, and read
`askUser.isLastStep` to switch the continue wording.

### Hooks

export const hooks = [
  { name: "useComposer", type: "(selector?) => Selected", description: "Subscribe to a slice of the nearest composer's store. Throws outside <Composer.Root>." },
  { name: "useComposerStore", type: "(store, selector?) => Selected", description: "Same, for an explicit createStore() handle — the outside-the-tree twin." },
  { name: "useComposerController", type: "() => ComposerEditorState", description: "The nearest composer's imperative editor controls (focus, insert, clear, …)." },
  { name: "useComposerSubmit", type: "(options) => ComposerSubmitState", description: "Computes Submit's type/disabled/generating; auto-disables while empty or submitting; aborts on Escape inside the composer." },
  { name: "useCommandListItems", type: "() => { items, state }", description: "The resolved items and load state inside a Command. Throws outside one." },
];

<PropsTable rows={hooks} />
