Composer

Rich-text chat input with chips, slash/mention commands, attachments, and questions for the user, from clarifying questions to tool approvals.

"use client";

import { Composer, type ComposerSubmitData } from "@intentface/ui/composer";
import { ArrowUp } from "@keyline-icons/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-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.08),0_6px_16px_-6px_rgb(0_0_0/0.1)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.2),0_1px_2px_rgb(0_0_0/0.12),0_6px_16px_-6px_rgb(0_0_0/0.22)]">
        {/* 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-12 overflow-y-auto px-2.5 pt-2.5 text-sm text-zinc-900 dark:text-zinc-100 **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none">
          <Composer.Placeholder
            placeholder="Send a message…"
            className="text-zinc-400 leading-6 dark:text-zinc-500"
          />
        </Composer.Textarea>
        <Composer.Actions className="flex h-12 items-center justify-end px-2.5">
          <Composer.Submit className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.28),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)] transition-opacity focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40">
            <ArrowUp className="size-[15px]" />
          </Composer.Submit>
        </Composer.Actions>
      </Composer.Container>
    </Composer.Root>
  );
};

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 a question prompt above the field.
  • Headless — behavior lives in @intentface/ui; the demos below show one way to style it, and every class in them is yours to change.
  • Isolate it from your stream — see Why the composer needs isolating. The editor is the most expensive thing on the page to re-render per token.
  • Get started — see 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.questions.active ? (
        <Composer.Question>
          <Composer.QuestionHeader>
            <Composer.QuestionLabel />
            <Composer.QuestionNavigation>
              <Composer.QuestionPrevious />
              <Composer.QuestionStepLabel />
              <Composer.QuestionNext />
            </Composer.QuestionNavigation>
          </Composer.QuestionHeader>
          <Composer.QuestionOptions>
            <Composer.QuestionOption>
              <Composer.QuestionOptionContent>
                <Composer.QuestionOptionLabel />
                <Composer.QuestionOptionDescription />
              </Composer.QuestionOptionContent>
            </Composer.QuestionOption>
          </Composer.QuestionOptions>
          <Composer.QuestionHints />
        </Composer.Question>
      ) : null
    }
  </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, questions.active, …) and the panel opens and closes to match — priority is 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.

"use client";

import { type CommandItemData, Composer, type ComposerSubmitData } from "@intentface/ui/composer";
import { ArrowUp } from "@keyline-icons/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-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]"
        >
          {(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">
                <Composer.CommandEmpty className="hidden h-8 items-center rounded-lg px-2 text-[13px] text-zinc-400 group-data-empty/list:flex dark:text-zinc-500">
                  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 select-none items-center gap-2.5 rounded-lg px-2 text-[13px] outline-none data-highlighted:bg-zinc-950/5 dark:data-highlighted:bg-white/8"
                    >
                      <Composer.CommandItemLabel className="font-medium text-zinc-900 dark:text-zinc-100">
                        {item.label}
                      </Composer.CommandItemLabel>
                      {item.description && (
                        <Composer.CommandItemDescription className="truncate text-xs text-zinc-400 dark:text-zinc-500">
                          {item.description}
                        </Composer.CommandItemDescription>
                      )}
                    </Composer.CommandItem>
                  )}
                </Composer.CommandList>
              </Composer.Command>
            ) : null
          }
        </Composer.Panel>
        <Composer.Container className="cursor-text rounded-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.08),0_6px_16px_-6px_rgb(0_0_0/0.1)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.2),0_1px_2px_rgb(0_0_0/0.12),0_6px_16px_-6px_rgb(0_0_0/0.22)]">
          <Composer.Textarea className="max-h-32 min-h-12 overflow-y-auto px-2.5 pt-2.5 text-sm text-zinc-900 dark:text-zinc-100 **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none **:data-command-badge:rounded-sm **:data-command-badge:bg-[#0169cc]/10 **:data-command-badge:px-0.5 **:data-command-badge:text-[#0169cc] **:data-command-badge:dark:bg-[#4c9bea]/15 **:data-command-badge:dark:text-[#4c9bea] **:data-command-hint:text-zinc-400 **:data-command-hint:dark:text-zinc-500">
            <Composer.Placeholder
              placeholder="Type @ to mention a file…"
              className="text-zinc-400 leading-6 dark:text-zinc-500"
            />
          </Composer.Textarea>
          <Composer.Actions className="flex h-12 items-center justify-end px-2.5">
            <Composer.Submit className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.28),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)] transition-opacity focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40">
              <ArrowUp className="size-[15px]" />
            </Composer.Submit>
          </Composer.Actions>
        </Composer.Container>
      </Composer.Root>
    </div>
  );
};

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.

"use client";

import { type CommandItemData, Composer, type ComposerSubmitData } from "@intentface/ui/composer";
import { ArrowUp } from "@keyline-icons/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. It stays mounted, so
          data-closed hides it; it rises in from @starting-style on open. */}
      <Composer.Popover className="absolute z-50 w-72 overflow-hidden rounded-xl bg-white p-1 transition-[opacity,translate] duration-150 ease-out starting:translate-y-1.5 starting:opacity-0 data-closed:hidden shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_-8px_rgb(0_0_0/0.16)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.16),0_12px_32px_-8px_rgb(0_0_0/0.4)]">
        {(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">
              <Composer.CommandEmpty className="hidden h-8 items-center rounded-lg px-2 text-[13px] text-zinc-400 group-data-empty/list:flex dark:text-zinc-500">
                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 select-none items-center gap-2.5 rounded-lg px-2 text-[13px] outline-none data-highlighted:bg-zinc-950/5 dark:data-highlighted:bg-white/8"
                  >
                    <Composer.CommandItemLabel className="font-medium text-zinc-900 dark:text-zinc-100">
                      {item.label}
                    </Composer.CommandItemLabel>
                    {item.description && (
                      <Composer.CommandItemDescription className="truncate text-xs text-zinc-400 dark:text-zinc-500">
                        {item.description}
                      </Composer.CommandItemDescription>
                    )}
                  </Composer.CommandItem>
                )}
              </Composer.CommandList>
            </Composer.Command>
          ) : null
        }
      </Composer.Popover>
      <Composer.Container className="cursor-text rounded-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.08),0_6px_16px_-6px_rgb(0_0_0/0.1)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.2),0_1px_2px_rgb(0_0_0/0.12),0_6px_16px_-6px_rgb(0_0_0/0.22)]">
        <Composer.Textarea className="max-h-32 min-h-12 overflow-y-auto px-2.5 pt-2.5 text-sm text-zinc-900 dark:text-zinc-100 **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none **:data-command-badge:rounded-sm **:data-command-badge:bg-[#0169cc]/10 **:data-command-badge:px-0.5 **:data-command-badge:text-[#0169cc] **:data-command-badge:dark:bg-[#4c9bea]/15 **:data-command-badge:dark:text-[#4c9bea] **:data-command-hint:text-zinc-400 **:data-command-hint:dark:text-zinc-500">
          <Composer.Placeholder
            placeholder="Type @ to mention a file…"
            className="text-zinc-400 leading-6 dark:text-zinc-500"
          />
        </Composer.Textarea>
        <Composer.Actions className="flex h-12 items-center justify-end px-2.5">
          <Composer.Submit className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.28),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)] transition-opacity focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40">
            <ArrowUp className="size-[15px]" />
          </Composer.Submit>
        </Composer.Actions>
      </Composer.Container>
    </Composer.Root>
  );
};

Question flow

Setting the questions prop — a clarifying question, a tool approval, any choice you put to the user — arms the question flow and flips questions.active; compose the Composer.Question parts inside a Panel (or Popover), reading the current step from useComposer(c => c.questions). The flow steps through each question (single- or multi-select), and answering or skipping the last one fires onSubmit with { kind: "answers" } — one flat answer per question, keyed by the id you minted, carrying option values. Passing a fresh questions array re-arms it from the first step.

"use client";

import { Composer, type ComposerQuestion, type ComposerSubmitData } from "@intentface/ui/composer";
import { ArrowUp } from "@keyline-icons/react";
import { useState } from "react";
import { Controls, Prompt } from "./question";

// Setting `questions` arms the flow and flips questions.active. Answering or
// skipping the last one fires onSubmit with { kind: "questions" } — entries
// keyed by the ids minted here, carrying option values (label fallback).
const QUESTIONS: ComposerQuestion[] = [
  {
    id: "framework",
    label: "Which framework are you deploying to?",
    options: [
      { value: "next", label: "Next.js", description: "App Router on Vercel." },
      { value: "vite", label: "Vite", description: "SPA on any static host." },
      { value: "remix", label: "Remix", description: "Full-stack on a Node server." },
    ],
  },
  {
    id: "features",
    label: "Which features do you need?",
    multiSelect: true,
    options: [
      { value: "auth", label: "Auth", description: "Sessions and sign-in." },
      { value: "database", label: "Database", description: "Persistent storage." },
      { value: "uploads", label: "File uploads", description: "Attachments and media." },
    ],
  },
  {
    id: "priority",
    label: "What matters most for this project?",
    options: [
      { value: "speed", label: "Speed", description: "Ship as fast as possible." },
      { value: "scale", label: "Scale", description: "Handle heavy traffic." },
      { value: "cost", label: "Cost", description: "Keep the bill low." },
    ],
  },
];

export const QuestionFlow = () => {
  const [questions, setQuestions] = useState<ComposerQuestion[]>(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-xl bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]"
        >
          {!done && <Prompt />}
        </Composer.Panel>
        <Composer.Container className="cursor-text rounded-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.08),0_6px_16px_-6px_rgb(0_0_0/0.1)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.2),0_1px_2px_rgb(0_0_0/0.12),0_6px_16px_-6px_rgb(0_0_0/0.22)]">
          <Composer.Textarea className="max-h-32 min-h-12 overflow-y-auto px-2.5 pt-2.5 text-sm text-zinc-900 dark:text-zinc-100 **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none">
            <Composer.Placeholder
              placeholder={done ? "All set — reset to try again" : "Or type your own answer…"}
              className="text-zinc-400 leading-6 dark:text-zinc-500"
            />
          </Composer.Textarea>
          <Composer.Actions className="flex h-12 items-center justify-end gap-1.5 px-2.5">
            {done ? (
              <Composer.Submit className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.28),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)] transition-opacity focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40">
                <ArrowUp className="size-[15px]" />
              </Composer.Submit>
            ) : (
              <Controls />
            )}
          </Composer.Actions>
        </Composer.Container>
      </Composer.Root>
      {/* A fixed slot: the button appearing must not push the composer up. */}
      <div className="h-8">
        {done && (
          <button
            type="button"
            onClick={reset}
            className="h-8 cursor-pointer rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-3 font-medium text-[13px] text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] transition-colors hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134]"
          >
            Reset questions
          </button>
        )}
      </div>
    </div>
  );
};

A tool approval is the same mechanism, not a feature: mint the tool call id as the question id, put your decision vocabulary in the option values, and route the answer back to the tool in onSubmit. A single question mounts no step-navigation parts.

"use client";

import {
  Composer,
  type ComposerAnswer,
  type ComposerQuestion,
  type ComposerQuestionOption,
  type ComposerSubmitData,
  useComposer,
} from "@intentface/ui/composer";
import { type ComponentProps, useState } from "react";

// A tool approval is just a question: the id is the tool call id, the option
// values are your decision vocabulary, and the answer routes back to the tool
// in onSubmit. The package never learns the word "approval" — and a single
// question simply doesn't mount the step-navigation parts.
const COMMAND = "pnpm test";
const TOOL_CALL_ID = "call_run_tests";

// Richer consumer options satisfy the contract structurally — `command` is
// this demo's own field, rendered as inline code below.
const OPTIONS = [
  { value: "yes", label: "Yes" },
  {
    value: "always",
    label: "Yes, and don't ask again for commands that start with",
    command: COMMAND,
  },
  { value: "no", label: "No" },
] satisfies readonly (ComposerQuestionOption & { command?: string })[];

const QUESTIONS: ComposerQuestion[] = [
  {
    id: TOOL_CALL_ID,
    label: `Do you want to allow me to run ${COMMAND} for this workspace?`,
    options: OPTIONS,
  },
];

const DECISION_COPY: Record<string, string> = {
  yes: "approved once",
  always: `approved for commands starting with "${COMMAND}"`,
  no: "denied",
};

export const ApprovalFlow = () => {
  const [entry, setEntry] = useState<ComposerAnswer | null>(null);

  const handleSubmit = (data: ComposerSubmitData) => {
    if (data.kind !== "answers") return;
    // Route by the id we minted — this is where a real app resolves the call
    // (e.g. addToolOutput / executing or rejecting the tool).
    const resolved = data.answers.find((answer) => answer.id === TOOL_CALL_ID);
    if (resolved) setEntry(resolved);
  };

  const decision = entry?.selected[0];
  const resolvedCopy = decision
    ? (DECISION_COPY[decision] ?? decision)
    : entry?.text
      ? `answered: "${entry.text}"`
      : "skipped";

  return (
    // Reserve height and bottom-anchor so the panel opening never shifts the page.
    <div className="flex min-h-100 w-full max-w-xl flex-col items-center justify-end gap-3">
      <Composer.Root
        questions={entry ? undefined : QUESTIONS}
        onSubmit={handleSubmit}
        className="flex w-full flex-col"
      >
        <Composer.Panel
          anchor={false}
          className="mb-2 overflow-hidden rounded-2xl border border-[#f0f0f0] bg-white dark:border-[#262626] dark:bg-[#181818]"
        >
          {!entry && <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={entry ? `${resolvedCopy} — reset to try again` : "Or reply directly…"}
              className="leading-[1.7] text-[#949494] dark:text-[#6f6f6f]"
            />
          </Composer.Textarea>
          <Composer.Actions className="flex items-center justify-end gap-2 p-2">
            {entry ? (
              <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>
      {entry && (
        <button
          type="button"
          onClick={() => setEntry(null)}
          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 approval
        </button>
      )}
    </div>
  );
};

// Single question: no Navigation, no StepLabel — the label, the command being
// approved (free-form children inside Composer.Question), and numbered options.
const Prompt = () => {
  const questions = useComposer((composer) => composer.questions);
  const question = questions.items?.[questions.step];

  if (!question) return null;

  const draft = questions.drafts.get(questions.step);

  return (
    <Composer.Question className="flex flex-col gap-1 p-2">
      <Composer.QuestionHeader className="flex h-7 items-center gap-2 px-2">
        <Composer.QuestionLabel className="text-sm font-medium">
          Do you want to allow me to run{" "}
          <code className="rounded bg-[#f4f4f4] px-1 py-0.5 text-xs dark:bg-[#111111]">
            {COMMAND}
          </code>{" "}
          for this workspace?
        </Composer.QuestionLabel>
      </Composer.QuestionHeader>
      <div className="rounded-lg bg-[#f4f4f4] px-3 py-2.5 font-mono text-sm text-[#686868] dark:bg-[#111111] dark:text-[#9b9b9b]">
        {COMMAND}
      </div>
      <Composer.QuestionOptions
        ref={questions.optionsRef}
        multiSelect={false}
        groupName="approval"
        className="flex flex-col"
      >
        {OPTIONS.map((option, index) => {
          const optionValue = option.value ?? option.label;
          const selected = Boolean(draft?.selected.has(optionValue));
          return (
            <Composer.QuestionOption
              key={optionValue}
              value={optionValue}
              selected={selected}
              onSelect={() => questions.toggleOption(optionValue)}
              className="group flex cursor-pointer items-start gap-2 rounded-[10px] p-2 outline-none transition-colors data-highlighted:bg-[#f4f4f4] dark:data-highlighted:bg-[#232323]"
            >
              <span
                aria-hidden="true"
                className={`mt-px flex size-4 shrink-0 items-center justify-center rounded border text-[10px] tabular-nums ${
                  selected
                    ? "border-[#1a1a1a] bg-[#1a1a1a] text-white dark:border-[#fcfcfc] dark:bg-[#fcfcfc] dark:text-[#111111]"
                    : "border-[#ececec] text-[#949494] dark:border-[#2d2d2d] dark:text-[#6f6f6f]"
                }`}
              >
                {index + 1}
              </span>
              <Composer.QuestionOptionContent className="flex flex-col gap-0.5">
                <Composer.QuestionOptionLabel className="text-sm leading-snug">
                  {option.label}
                  {"command" in option && (
                    <>
                      {" "}
                      <code className="rounded bg-[#e8e8e8] px-1 py-0.5 font-mono text-xs text-[#686868] dark:bg-[#111111] dark:text-[#9b9b9b]">
                        {option.command}
                      </code>
                    </>
                  )}
                </Composer.QuestionOptionLabel>
              </Composer.QuestionOptionContent>
              {/* Enter selects the highlighted row — surface that on it. */}
              <span
                aria-hidden="true"
                className="ml-auto self-center text-xs text-[#949494] opacity-0 transition-opacity group-data-highlighted:opacity-100 dark:text-[#6f6f6f]"
              >
                ↵
              </span>
            </Composer.QuestionOption>
          );
        })}
      </Composer.QuestionOptions>
    </Composer.Question>
  );
};

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>
);

const Controls = () => {
  const questions = useComposer((composer) => composer.questions);

  return (
    <>
      <Composer.QuestionDismiss
        onClick={questions.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
      </Composer.QuestionDismiss>
      <Composer.QuestionContinue 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]">
        Submit
      </Composer.QuestionContinue>
    </>
  );
};

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/ui/attachments parts. Composer.AttachmentTrigger opens the file dialog, and files can also be dropped onto the composer.

"use client";

import { Attachments } from "@intentface/ui/attachments";
import { Composer, type ComposerSubmitData, useComposer } from "@intentface/ui/composer";
import { ArrowUp, Paperclip, X } from "@keyline-icons/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-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.08),0_6px_16px_-6px_rgb(0_0_0/0.1)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.2),0_1px_2px_rgb(0_0_0/0.12),0_6px_16px_-6px_rgb(0_0_0/0.22)]">
          <Composer.Attachments accept="image/*,application/pdf" maxFiles={4}>
            <Strip />
          </Composer.Attachments>
          <Composer.Textarea className="max-h-32 min-h-12 overflow-y-auto px-2.5 pt-2.5 text-sm text-zinc-900 dark:text-zinc-100 **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none">
            <Composer.Placeholder
              placeholder="Attach a file, or drag one in…"
              className="text-zinc-400 leading-6 dark:text-zinc-500"
            />
          </Composer.Textarea>
          <Composer.Actions className="flex h-12 items-center justify-between px-2.5">
            <Composer.AttachmentTrigger
              aria-label="Attach a file"
              className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] text-zinc-700 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] transition-colors hover:from-[#fafafa] hover:to-[#f6f6f6] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-300 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134] dark:hover:text-zinc-100"
            >
              <Paperclip className="size-[15px]" />
            </Composer.AttachmentTrigger>
            <Composer.Submit className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.28),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)] transition-opacity focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40">
              <ArrowUp className="size-[15px]" />
            </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-1.5 px-2.5 pt-2.5">
      {attachments.items.map((item) => (
        <Attachments.Item
          key={item.id}
          className="flex h-6 items-center gap-1.5 rounded-[7px] bg-white bg-linear-to-b from-white to-[#fdfdfd] pr-1 pl-2 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]"
        >
          <span className="max-w-40 truncate font-medium text-xs text-zinc-900 dark:text-zinc-100">
            {item.filename ?? "file"}
          </span>
          <span className="font-mono text-[11px] text-zinc-400 dark:text-zinc-500">
            {formatFileSize(item.fileSize)}
          </span>
          <Attachments.Remove
            onRemove={() => attachments.remove(item.id)}
            filename={item.filename}
            className="flex size-4 cursor-pointer items-center justify-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100"
          >
            <X className="size-[11px]" />
          </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`;
};

Controlled value

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

"use client";

import { Composer, type ComposerSubmitData } from "@intentface/ui/composer";
import { ArrowUp } from "@keyline-icons/react";
import { 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-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.08),0_6px_16px_-6px_rgb(0_0_0/0.1)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.2),0_1px_2px_rgb(0_0_0/0.12),0_6px_16px_-6px_rgb(0_0_0/0.22)]">
          <Composer.Textarea
            value={text}
            onValueChange={setText}
            className="max-h-32 min-h-12 overflow-y-auto px-2.5 pt-2.5 text-sm text-zinc-900 dark:text-zinc-100 **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none"
          >
            <Composer.Placeholder
              placeholder="Controlled by the parent…"
              className="text-zinc-400 leading-6 dark:text-zinc-500"
            />
          </Composer.Textarea>
          <Composer.Actions className="flex h-12 items-center justify-end px-2.5">
            <Composer.Submit className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.28),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)] transition-opacity focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40">
              <ArrowUp className="size-[15px]" />
            </Composer.Submit>
          </Composer.Actions>
        </Composer.Container>
      </Composer.Root>
      <div className="flex items-center justify-center gap-2">
        <button
          type="button"
          onClick={() => setText("Summarize this thread")}
          className={BUTTON_CLASS}
        >
          Set prompt
        </button>
        <button type="button" onClick={() => setText("")} className={BUTTON_CLASS}>
          Clear
        </button>
      </div>
    </div>
  );
};

const BUTTON_CLASS =
  "h-8 cursor-pointer rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-3 font-medium text-[13px] text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] transition-colors hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134]";

Reaching a composer from outside

Every Composer.Root creates its own isolated store, so several composers can share a page with no wiring at all. Reaching into one from outside its tree is the case that needs a handle: Composer.createStore() returns one you own, and passing it as store lets a toolbar, a shortcut or a status bar drive the composer through store.controller or useComposerStore, with no context or ref threading.

"use client";

import { Composer, type ComposerSubmitData } from "@intentface/ui/composer";
import { ArrowUp } from "@keyline-icons/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-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.08),0_6px_16px_-6px_rgb(0_0_0/0.1)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.2),0_1px_2px_rgb(0_0_0/0.12),0_6px_16px_-6px_rgb(0_0_0/0.22)]">
          <Composer.Textarea className="max-h-32 min-h-12 overflow-y-auto px-2.5 pt-2.5 text-sm text-zinc-900 dark:text-zinc-100 **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none">
            <Composer.Placeholder
              placeholder="Driven by an external store handle…"
              className="text-zinc-400 leading-6 dark:text-zinc-500"
            />
          </Composer.Textarea>
          <Composer.Actions className="flex h-12 items-center justify-end px-2.5">
            <Composer.Submit className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.28),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)] transition-opacity focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40">
              <ArrowUp className="size-[15px]" />
            </Composer.Submit>
          </Composer.Actions>
        </Composer.Container>
      </Composer.Root>
      <button
        type="button"
        onClick={() => store.controller.insertText("@channel ")}
        className="h-8 cursor-pointer rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-3 font-medium text-[13px] text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] transition-colors hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134]"
      >
        Insert from outside
      </button>
    </div>
  );
};

Why the composer needs isolating

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 (a question 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 the question flow being active, takes priority over normal typing.

Normal typing

KeyDescription
Enter
Submit the form (requestSubmit).
Shift + Enter
Insert a soft line break.
Backspace
When the editor is empty and attachments exist, remove the last attachment.

Command list open

KeyDescription
ArrowUp / ArrowDown
Move the highlight through the items.
ArrowLeft / ArrowRight
Move the caret within the trigger token.
Enter / Tab
Select the highlighted item.
Escape
Close the command list.

Question flow active

KeyDescription
ArrowUp / ArrowDown
Navigate options; up past the first refocuses the editor.
ArrowLeft / ArrowRight
Go to the previous / next question.
Enter
Select the highlighted option.
Escape
Dismiss the current step.
Any character
Focus the editor and start typing a free-text answer.

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.

API reference

Every part accepts className, style, and render (see 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.

PropTypeDefaultDetails
onSubmit(data: ComposerSubmitData) => void | Promise<void>—
commandsComposerCommandsMap{}
questionsComposerQuestion[]—
isSubmittingbooleanfalse
valueComposerSnapshot—
defaultValueComposerSnapshot—
onValueChange(snapshot: ComposerSnapshot) => void—
storeComposerStoreper-mount instance
AttributeDescription
data-composer-root
The form element.
data-submitting
Present while isSubmitting is true.
data-dragging
Present while files are dragged over the drop scope.

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). focus takes the same options as HTMLElement.focus(): pass { preventScroll: true } to focus a composer that's already on screen without scrolling the page around it.

tsx
import { Composer, useComposerStore } from "@intentface/ui/composer";

// Created outside the tree, so anything can reach it: no context, no ref.
const store = Composer.createStore();

export const Chat = () => {
  const hasContent = useComposerStore(store, (composer) => composer.textarea.hasContent);

  return (
    <>
      <Composer.Root store={store} onSubmit={send}>{/* … */}</Composer.Root>
      <button type="button" onClick={() => store.controller.insertText("@channel ")}>
        Mention the channel
      </button>
      <button type="button" disabled={!hasContent} onClick={() => store.controller.clear()}>
        Clear
      </button>
    </>
  );
};

onSubmit receives a discriminated ComposerSubmitData:

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

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.

PropTypeDefaultDetails
valuestring—
onValueChange(text: string) => void—
disabledbooleanfalse
autoFocusbooleanfalse
placeholderstring—
submitOn"enter" | "shift-enter""enter"
renderChip(chip: ChipData) => ReactNode—
childrenReactNode—
maxLengthnumber—
requiredbooleanfalse
namestring—
spellCheckbooleanfalse
onFocus / onBlur / onKeyDown / onKeyUp / onPaste / onCopy / onCutReact handlers—
AttributeDescription
data-composer-textarea
The wrapper element.
data-composer-editor
The contenteditable editor element.
data-filled
Present when the editor has content.
data-disabled
Present when disabled.
data-command-badge
On the active trigger token decoration (e.g. the leading @).
data-command-placeholder
On the inline hint decoration after a trigger.
data-command-hint
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.

Composer.Placeholder

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

PropTypeDefaultDetails
placeholderstring—
childrenReactNode—

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.

AttributeDescription
data-composer-context-window
The context-window element.
data-open
Present while the window has content and no panel is active.
data-closed
Present while empty or yielding to an active panel.

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.

PropTypeDefaultDetails
isGeneratingbooleanfalse
onStop() => void—
AttributeDescription
data-composer-submit
The button element.
data-generating
Present while isGenerating is true.

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.

PropTypeDefaultDetails
convert(file: File) => AttachmentItemblob ingestion
destroy(item: AttachmentItem) => voidrevoke blob URL
acceptstring"" (everything)
maxFilesnumberunlimited
maxFileSizenumberunlimited
multiplebooleantrue
globalDropbooleanfalse
childrenReactNode—

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/> : questions.active ? <Composer.Question/> : 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).

PropTypeDefaultDetails
childrenReactNode | ((composer: ComposerState) => ReactNode)—
anchorboolean | Element | RefObject<Element>—
side"top" | "bottom" | "left" | "right"—
align"start" | "center" | "end"—
sideOffsetnumber—
pinboolean—
AttributeDescription
data-composer-panel
The panel element.
data-open
Present while the resolved content is non-empty.
data-closed
Present while empty.
data-starting-style
Present on the first open frame — the enter transition's from-state.
data-ending-style
Present while the close transition runs, before unmount.
data-side
Resolved side when positioned (anchor) — the origin to animate from."top" | "bottom" | "left" | "right"
data-align
Resolved alignment when positioned."start" | "center" | "end"

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.

PropTypeDefaultDetails
childrenReactNode | ((composer: ComposerState) => ReactNode)—
pinboolean—
AttributeDescription
data-composer-popover
The portaled popover element.
data-open
Present while the resolved content is non-empty.
data-closed
Present while empty (stays mounted, holding its last position).
data-side
Resolved side — flips to "bottom" when there's no room above; the origin to animate from."top" | "bottom" | "left" | "right"
data-align
Resolved alignment along the side."start" | "center" | "end"

Composer.Command

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

PropTypeDefaultDetails
prefixstring(required)
AttributeDescription
data-composer-command-list
The list element.
data-loading
Present while async items are being fetched.
data-empty
Present when no items match.

Composer.CommandList

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

PropTypeDefaultDetails
children(item: Item) => ReactNode(required)

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.

PropTypeDefaultDetails
valuestring(required)
AttributeDescription
data-composer-command-item
The row button.
data-highlighted
Present when this row is the active highlight.
data-disabled
Present when the item data marks this row disabled.

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>

Composer.Question

The composer owns the question state — items, the current step, the drafts — but renders none of the prompt UI. Compose that from the Composer.Question parts, reading the step through useComposer((c) => c.questions) and gating the enclosing Panel or Popover on questions.active.

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

Composer.QuestionHeader

Row container for Composer.QuestionLabel and an optional Composer.QuestionNavigation. Renders data-composer-question-header. No part-specific props or state attributes.

Composer.QuestionLabel

The question text. Renders data-composer-question-label and carries the id that Composer.QuestionOptions uses as its accessible name, so the group is labelled with no wiring. No part-specific props or state attributes.

Composer.QuestionNavigation

Row container for Composer.QuestionPrevious, Composer.QuestionStepLabel and Composer.QuestionNext. A single question needs none of them. Renders data-composer-question-navigation. No part-specific props or state attributes.

Composer.QuestionPrevious

Button that moves to the previous step. Wire onClick to questions.goBack and disable it on the first step. Its default accessible name is "Previous question"; override it with aria-label. Renders data-composer-question-previous. No part-specific props or state attributes.

Composer.QuestionStepLabel

The step indicator. It reads the current step and the total from the composer, and you supply the text, because there is no default format. Renders data-composer-question-step-label and carries the id that Composer.QuestionOptions uses as its description.

PropTypeDefaultDetails
childrenReactNode | ((state: { current: number; total: number }) => ReactNode)—

Composer.QuestionNext

Button that moves to the next step. Wire onClick to questions.goNext and disable it on the last step. Its default accessible name is "Next question"; override it with aria-label. Renders data-composer-question-next. No part-specific props or state attributes.

Composer.QuestionOptions

Fieldset wrapper for Composer.QuestionOption items. It gives its options multiSelect, groupName and the highlight, and each option registers itself on mount. It renders as a radiogroup, or a group when multiSelect is set, labelled by Composer.QuestionLabel and described by Composer.QuestionStepLabel.

PropTypeDefaultDetails
multiSelectbooleanfalse
groupNamestring""
refRefObject<ComposerQuestionOptionsHandle | null>—
AttributeDescription
data-composer-question-options
The fieldset element.

Composer.QuestionOption

A selectable option. It registers itself with Composer.QuestionOptions on mount and takes its highlight from there. The option is the control itself: it carries the radio or checkbox role and aria-checked, and the highlighted option is the group's only tab stop.

PropTypeDefaultDetails
valuestring""
selectedbooleanfalse
onSelect() => void—
AttributeDescription
data-composer-question-option
The option element.
data-highlighted
Present while the option is highlighted by keyboard or hover.
data-selected
Present while the option is selected.

Composer.QuestionOptionContent

Column wrapper for Composer.QuestionOptionLabel and Composer.QuestionOptionDescription. Renders data-composer-question-option-content. No part-specific props or state attributes.

Composer.QuestionOptionLabel

The option's title text. Renders data-composer-question-option-label. No part-specific props or state attributes.

Composer.QuestionOptionDescription

The option's description text. Renders data-composer-question-option-description. No part-specific props or state attributes.

Composer.QuestionHints

Keyboard shortcut hints shown below the options. It ships no copy, so supply the hints as children. Renders data-composer-question-hints. No part-specific props or state attributes.

Hooks

PropTypeDefaultDetails
useComposer(selector?) => Selected—
useComposerStore(store, selector?) => Selected—
useComposerController() => ComposerEditorState—
useComposerSubmit(options) => ComposerSubmitState—
useCommandListItems() => { items, state }—