Composer
Rich-text chat input with chips, slash/mention commands, attachments, and an ask-user flow.
"use client";
import { Composer, type ComposerSubmitData } from "@intentface/chat/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 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. - 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:
<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 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/chat/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/chat/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>
);
};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.
"use client";
import { type AskUserQuestion, Composer, type ComposerSubmitData } from "@intentface/chat/composer";
import { ArrowUp } from "@keyline-icons/react";
import { useState } from "react";
import { Controls, Prompt } from "./ask-user";
// 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-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>
{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>
);
};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.
"use client";
import { Attachments } from "@intentface/chat/attachments";
import { Composer, type ComposerSubmitData, useComposer } from "@intentface/chat/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/chat/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/chat/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:
// 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
| Key | Description |
|---|---|
| Enter | |
| Shift + Enter | |
| Backspace |
Command list open
| Key | Description |
|---|---|
| ArrowUp / ArrowDown | |
| ArrowLeft / ArrowRight | |
| Enter / Tab | |
| Escape |
Ask-user active
| Key | Description |
|---|---|
| ArrowUp / ArrowDown | |
| ArrowLeft / ArrowRight | |
| Enter | |
| Escape | |
| Any character |
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.
| Prop | Type | Default | Details |
|---|---|---|---|
onSubmit | (data: ComposerSubmitData) => void | Promise<void> | — | |
commands | ComposerCommandsMap | {} | |
questions | AskUserQuestion[] | — | |
isSubmitting | boolean | false | |
value | ComposerSnapshot | — | |
defaultValue | ComposerSnapshot | — | |
onValueChange | (snapshot: ComposerSnapshot) => void | — | |
store | ComposerStore | per-mount instance |
| Attribute | Description |
|---|---|
data-composer-root | |
data-submitting | |
data-dragging |
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).
import { Composer, useComposerStore } from "@intentface/chat/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:
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.
| Prop | Type | Default | Details |
|---|---|---|---|
value | string | — | |
onValueChange | (text: string) => void | — | |
disabled | boolean | false | |
autoFocus | boolean | false | |
placeholder | string | — | |
submitOn | "enter" | "shift-enter" | "enter" | |
renderChip | (chip: ChipData) => ReactNode | — | |
children | ReactNode | — | |
maxLength | number | — | |
required | boolean | false | |
name | string | — | |
spellCheck | boolean | false | |
onFocus / onBlur / onKeyDown / onKeyUp / onPaste / onCopy / onCut | React handlers | — |
| Attribute | Description |
|---|---|
data-composer-textarea | |
data-composer-editor | |
data-filled | |
data-disabled | |
data-command-badge | |
data-command-placeholder | |
data-command-hint |
Composer.Placeholder
Static or custom placeholder. Renders data-composer-placeholder-text.
Pass either placeholder or children, not both.
| Prop | Type | Default | Details |
|---|---|---|---|
placeholder | string | — | |
children | ReactNode | — |
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.
| Attribute | Description |
|---|---|
data-composer-context-window | |
data-open | |
data-closed |
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.
| Prop | Type | Default | Details |
|---|---|---|---|
isGenerating | boolean | false | |
onStop | () => void | — |
| Attribute | Description |
|---|---|
data-composer-submit | |
data-generating |
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.
| Prop | Type | Default | Details |
|---|---|---|---|
convert | (file: File) => AttachmentItem | blob ingestion | |
destroy | (item: AttachmentItem) => void | revoke blob URL | |
accept | string | "" (everything) | |
maxFiles | number | unlimited | |
maxFileSize | number | unlimited | |
multiple | boolean | true | |
globalDrop | boolean | false | |
children | ReactNode | — |
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).
| Prop | Type | Default | Details |
|---|---|---|---|
children | ReactNode | ((composer: ComposerState) => ReactNode) | — | |
anchor | boolean | Element | RefObject<Element> | — | |
side | "top" | "bottom" | "left" | "right" | — | |
align | "start" | "center" | "end" | — | |
sideOffset | number | — | |
pin | boolean | — |
| Attribute | Description |
|---|---|
data-composer-panel | |
data-open | |
data-closed | |
data-starting-style | |
data-ending-style | |
data-side | |
data-align |
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.
| Prop | Type | Default | Details |
|---|---|---|---|
children | ReactNode | ((composer: ComposerState) => ReactNode) | — | |
pin | boolean | — |
| Attribute | Description |
|---|---|
data-composer-popover | |
data-open | |
data-closed | |
data-side | |
data-align |
Composer.Command
The command popup for one prefix. Renders data-composer-command-list
only while that prefix is active (returns nothing otherwise).
| Prop | Type | Default | Details |
|---|---|---|---|
prefix | string | (required) |
| Attribute | Description |
|---|---|
data-composer-command-list | |
data-loading | |
data-empty |
Composer.CommandList
Maps resolved items through a render-prop child. Renders
data-composer-command-items.
| Prop | Type | Default | Details |
|---|---|---|---|
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.
| Prop | Type | Default | Details |
|---|---|---|---|
value | string | (required) |
| Attribute | Description |
|---|---|
data-composer-command-item | |
data-highlighted | |
data-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:
<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
| Prop | Type | Default | Details |
|---|---|---|---|
useComposer | (selector?) => Selected | — | |
useComposerStore | (store, selector?) => Selected | — | |
useComposerController | () => ComposerEditorState | — | |
useComposerSubmit | (options) => ComposerSubmitState | — | |
useCommandListItems | () => { items, state } | — |