---
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 `isSubmitting`, `Submit` (via `useComposerSubmit`) also listens document-wide
for `Escape` to call `onStop`, unless the event was already handled.

## 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 Escape." },
];

<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." },
  { name: "useCommandListItems", type: "() => { items, state }", description: "The resolved items and load state inside a Command. Throws outside one." },
];

<PropsTable rows={hooks} />
