- Accordion
- Alert
- Alert Dialog
- Autocomplete
- Avatar
- Badge
- Breadcrumb
- Button
- Calendar
- Card
- Checkbox
- Checkbox Group
- Collapsible
- Combobox
- ComposerNew
- Command
- Context Menu
- Date Picker
- Dialog
- Drawer
- Empty
- Field
- Fieldset
- Form
- Frame
- Group
- Input
- Input Group
- Kbd
- Label
- Menu
- Meter
- Number Field
- OTP Field
- Pagination
- Popover
- Preview Card
- Progress
- Radio Group
- Scroll Area
- Select
- Segmented Control
- Separator
- Sheet
- Skeleton
- Slider
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toast
- Toggle
- Toggle Group
- Toolbar
- Tooltip
Composer
A composer for chat and AI assistant interfaces, with a growing input, persistent actions, and optional mentions or slash commands.
"use client";
import { ArrowUpIcon, PaperclipIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Composer,
ComposerActionGroup,
ComposerActions,
ComposerInput,
ComposerSubmit,
type ComposerValue,
getComposerText,
} from "@/components/ui/composer";
export default function Particle() {
const [value, setValue] = useState<ComposerValue>([]);
const [notice, setNotice] = useState("");
return (
<div className="flex w-full max-w-xl flex-col gap-3">
<Composer
value={value}
onValueChange={setValue}
onSubmit={(draft) => {
setNotice(`Sent: ${getComposerText(draft)}`);
setValue([]);
}}
>
<ComposerInput
render={
<textarea
aria-label="Plain message"
placeholder="Write a message…"
/>
}
/>
<ComposerActions>
<ComposerActionGroup>
<Button
aria-label="Attach a file"
size="icon"
type="button"
variant="ghost"
onClick={() => setNotice("Attach a file")}
>
<PaperclipIcon aria-hidden="true" />
</Button>
</ComposerActionGroup>
<ComposerActionGroup align="end">
<ComposerSubmit
render={
<Button aria-label="Send message" size="icon">
<ArrowUpIcon aria-hidden="true" />
</Button>
}
/>
</ComposerActionGroup>
</ComposerActions>
</Composer>
<p className="min-h-5 text-muted-foreground text-sm" role="status">
{notice}
</p>
</div>
);
}
Installation
pnpm dlx shadcn@latest add @coss/composer
Choose an input
ComposerInput is the input surface in either case. By default it renders a contenteditable div; give it an accessible name:
<ComposerInput aria-label="Message" />For a plain-text message, render a native textarea in the same ComposerInput:
<ComposerInput render={<textarea aria-label="Message" />} />You can also use a render callback or a custom component that forwards its props and ref to the textarea:
<ComposerInput
aria-label="Message"
placeholder="Write a message…"
render={(props) => <textarea {...props} />}
/>Both use the same composition and draft API. Composer manages an empty draft by default. Add triggers and the popup parts for mentions or actions; these require the default contenteditable input. Use a textarea when the message should remain plain text. Put value, defaultValue, and onValueChange on Composer, rather than on the rendered textarea.
Anatomy
Composer provides the form, editing context, and visual frame. Clicking empty space inside its border focuses the input. ComposerInput defaults to contenteditable and contains a ScrollArea. Pass a textarea through render for native plain-text editing. The action row stays outside the scrolling draft.
import { ArrowUpIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Composer,
ComposerActionGroup,
ComposerActions,
ComposerInput,
ComposerSubmit,
} from "@/components/ui/composer"
<Composer onSubmit={handleSend}>
<ComposerInput aria-label="Message" placeholder="Write a message…" />
<ComposerActions>
<ComposerActionGroup>{/* Optional leading buttons */}</ComposerActionGroup>
<ComposerActionGroup align="end">
<ComposerSubmit render={
<Button aria-label="Send message" size="icon">
<ArrowUpIcon aria-hidden="true" />
</Button>
} />
</ComposerActionGroup>
</ComposerActions>
</Composer>Usage
Composer submits on Enter and inserts a line break on Shift+Enter by default. onSubmit always receives a structured draft, for either input. Use getComposerText when your application needs a string. The submit button automatically disables for empty drafts.
import { ArrowUpIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Composer,
ComposerActionGroup,
ComposerActions,
ComposerInput,
ComposerSubmit,
type ComposerValue,
getComposerText,
} from "@/components/ui/composer"
export function MessageComposer({ onSend }: { onSend: (message: string) => void }) {
const [value, setValue] = useState<ComposerValue>([])
return (
<Composer value={value} onValueChange={setValue} onSubmit={(draft) => {
onSend(getComposerText(draft))
setValue([])
}}>
<ComposerInput render={
<textarea aria-label="Message" placeholder="Write a message…" />
} />
<ComposerActions>
<ComposerActionGroup align="end">
<ComposerSubmit render={
<Button aria-label="Send message" size="icon">
<ArrowUpIcon aria-hidden="true" />
</Button>
} />
</ComposerActionGroup>
</ComposerActions>
</Composer>
)
}Omit value and onValueChange for an uncontrolled draft. Submission does not clear the message automatically. For controlled drafts, update value synchronously in onValueChange; debounce fetching or saving separately.
Submission and custom controls
Set submitDisabled on Composer while sending or whenever your application cannot accept the message. It blocks both Enter and the submit button while leaving the draft editable. Use disabled to disable the input as well, or readOnly to keep text selectable without allowing edits or submission.
<Composer submitDisabled={sending} allowEmpty={attachments.length > 0} onSubmit={handleSend}>
{/* Input and actions */}
</Composer>allowEmpty lets you send an attachment without text. ComposerSubmit accepts a custom button through render; choose its label, icon, size, and variant. Its own disabled prop only controls that button. Use Composer.submitDisabled for a rule that must also apply to Enter.
Inside another form
Render Composer as a div to avoid nested forms. name adds the canonical message text to the containing form's FormData, for either input.
<form onSubmit={handleSave}>
<Composer render={<div />} name="message" submitOnEnter="never">
<ComposerInput aria-label="Message" />
</Composer>
<Button type="submit">Save</Button>
</form>In this composition, the outer form owns saving. If you also add ComposerSubmit or enable Enter submission, those call the Composer's own onSubmit; they do not submit the outer form. Standalone Composer renders a form and runs native constraint validation before its onSubmit callback. Required-field validation is available on a rendered textarea; contenteditable does not support native required validation.
Resetting the containing form restores defaultValue (or an empty draft). Controlled owners receive onValueChange with reason: "reset" and decide whether to accept it. For custom controls within Composer, useComposerContext() exposes the draft, canSubmit, submit(), insertTrigger(), and reset().
Input size
Set row limits with CSS variables on Composer. They apply to both the default contenteditable input and a rendered textarea. By default, the input starts at one row below md and two rows from md upward. It grows to four rows below md and eight rows from md upward. Longer drafts scroll while the controls stay visible. The maximum also stays within half the viewport height. Add attachment, microphone, or other buttons inside ComposerActions.
Use responsive classes to change the limits at each breakpoint:
<Composer className="[--composer-min-rows:2] [--composer-max-rows:3] md:[--composer-min-rows:3] md:[--composer-max-rows:6]">
<ComposerInput render={<textarea aria-label="Message" />} />
<ComposerActions>{/* Buttons */}</ComposerActions>
</Composer>Here the input shows two to three rows below md and three to six rows from md upward. Keep the minimum at or below the maximum at each breakpoint. Set these variables on Composer so both the input and its scroll area inherit them.
Controls beside a short message
Set layout="adaptive" to place controls beside a one-line draft. When text wraps or you add a line break, the input takes the full width and the controls move below it. Both layouts use the same composition: ComposerInput followed by ComposerActions, with action groups aligned to start and end. Only the layout prop changes.
The adaptive layout starts at one visible row; setting --composer-min-rows above one keeps the controls below. Keyboard focus follows the same order in both layouts: input, leading actions, then trailing actions.
"use client";
import { ArrowUpIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Composer,
ComposerActionGroup,
ComposerActions,
ComposerInput,
ComposerSubmit,
type ComposerValue,
getComposerText,
} from "@/components/ui/composer";
export default function Particle() {
const [value, setValue] = useState<ComposerValue>([]);
const [notice, setNotice] = useState("");
return (
<div className="flex w-full max-w-xl flex-col gap-2">
<Composer
layout="adaptive"
value={value}
onValueChange={setValue}
onSubmit={(draft) => {
setNotice(`Sent: ${getComposerText(draft)}`);
setValue([]);
}}
>
<ComposerInput aria-label="Message" placeholder="Write a message…" />
<ComposerActions>
<ComposerActionGroup>
<Button
aria-label="Add an attachment"
size="icon"
type="button"
variant="ghost"
onClick={() => setNotice("Add an attachment")}
>
<PlusIcon aria-hidden="true" />
</Button>
</ComposerActionGroup>
<ComposerActionGroup align="end">
<ComposerSubmit
render={
<Button aria-label="Send message" size="icon">
<ArrowUpIcon aria-hidden="true" />
</Button>
}
/>
</ComposerActionGroup>
</ComposerActions>
</Composer>
<p className="min-h-5 text-muted-foreground text-sm" role="status">
{notice}
</p>
</div>
);
}
For a native textarea, use the same layout and render the textarea in ComposerInput:
"use client";
import { ArrowUpIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Composer,
ComposerActionGroup,
ComposerActions,
ComposerInput,
ComposerSubmit,
type ComposerValue,
getComposerText,
} from "@/components/ui/composer";
export default function Particle() {
const [value, setValue] = useState<ComposerValue>([]);
const [notice, setNotice] = useState("");
return (
<div className="flex w-full max-w-xl flex-col gap-2">
<Composer
layout="adaptive"
value={value}
onValueChange={setValue}
onSubmit={(draft) => {
setNotice(`Sent: ${getComposerText(draft)}`);
setValue([]);
}}
>
<ComposerInput
render={
<textarea
aria-label="Plain message"
placeholder="Write a message…"
/>
}
/>
<ComposerActions>
<ComposerActionGroup>
<Button
aria-label="Add an attachment"
size="icon"
type="button"
variant="ghost"
onClick={() => setNotice("Add an attachment")}
>
<PlusIcon aria-hidden="true" />
</Button>
</ComposerActionGroup>
<ComposerActionGroup align="end">
<ComposerSubmit
render={
<Button aria-label="Send message" size="icon">
<ArrowUpIcon aria-hidden="true" />
</Button>
}
/>
</ComposerActionGroup>
</ComposerActions>
</Composer>
<p className="min-h-5 text-muted-foreground text-sm" role="status">
{notice}
</p>
</div>
);
}
Mentions and actions
The same Composer composition supports mentions and slash actions.
"use client";
import {
ArrowUpIcon,
SparklesIcon,
UserRoundIcon,
UsersRoundIcon,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Composer,
ComposerActionGroup,
ComposerActions,
ComposerInput,
ComposerItem,
type ComposerItemValue,
ComposerList,
ComposerPopup,
ComposerSubmit,
type ComposerTrigger,
type ComposerValue,
getComposerText,
} from "@/components/ui/composer";
const people = [
{ id: "alex", label: "Alex Morgan", kind: "person" },
{ id: "sam", label: "Sam Rivera", kind: "person" },
];
const teams = [
{ id: "design", label: "Design team", kind: "team" },
{ id: "engineering", label: "Engineering team", kind: "team" },
];
const actions = [
{ id: "summarize", label: "Summarize", type: "action" as const },
{ id: "rewrite", label: "Rewrite", type: "action" as const },
];
const triggers: ComposerTrigger[] = [
{ character: "@", items: [...people, ...teams] },
{ character: "/", items: actions },
];
export default function Particle() {
const [value, setValue] = useState<ComposerValue>([]);
const [notice, setNotice] = useState("");
return (
<div className="flex w-full max-w-xl flex-col gap-3">
<Composer
value={value}
onValueChange={setValue}
triggers={triggers}
onSubmit={(draft) => {
setNotice(`Sent: ${getComposerText(draft)}`);
setValue([]);
}}
>
<ComposerInput aria-label="Message" placeholder="Write a message…" />
<ComposerPopup>
<ComposerList>
{(item: ComposerItemValue) => (
<ComposerItem key={item.id} value={item}>
{item.type === "action" ? (
<SparklesIcon
aria-hidden="true"
className="size-4 opacity-80"
/>
) : item.kind === "team" ? (
<UsersRoundIcon
aria-hidden="true"
className="size-4 opacity-80"
/>
) : (
<UserRoundIcon
aria-hidden="true"
className="size-4 opacity-80"
/>
)}
<span className="truncate">{item.label}</span>
</ComposerItem>
)}
</ComposerList>
</ComposerPopup>
<ComposerActions>
<ComposerActionGroup align="end">
<ComposerSubmit
render={
<Button aria-label="Send message" size="icon">
<ArrowUpIcon aria-hidden="true" />
</Button>
}
/>
</ComposerActionGroup>
</ComposerActions>
</Composer>
<p className="min-h-5 text-muted-foreground text-sm" role="status">
{notice}
</p>
</div>
);
}
To extend the anatomy above, pass triggers={triggers} to Composer and insert these parts immediately after ComposerInput. The demo's Code tab shows the complete composition.
<ComposerPopup>
<ComposerList>
{(item) => (
<ComposerItem key={item.id} value={item}>
{item.label}
</ComposerItem>
)}
</ComposerList>
</ComposerPopup>Configure only the triggers you need. Queries can contain spaces: @sam r finds Sam Rivera. A space immediately after the trigger closes the suggestions. Selecting an item inserts a token into the draft; it does not send the message or execute an action.
To open a trigger from a button inside ComposerActions, call useComposerContext().insertTrigger("@") or insertTrigger("/"). The trigger is inserted at the caret and opens the same suggestions as typing it.
const triggers = [
{ character: "@", items: [{ id: "sam", label: "Sam Rivera" }] },
{ character: "/", items: [{ id: "summarize", label: "Summarize", type: "action" as const }] },
]Omit triggers and the popup parts for plain contenteditable text. Use value and onValueChange for a controlled draft, or defaultValue for an uncontrolled draft. Submission does not clear the structured draft automatically.
Token appearance
By default, selected mentions and actions appear as text-primary font-medium text with their trigger (@Sam Rivera or /Summarize). To change their appearance, render each selected token from ComposerInput. This example uses the same people, teams, and actions as the one above, but gives them rounded chips with different icons.
"use client";
import {
ArrowUpIcon,
SparklesIcon,
UserRoundIcon,
UsersRoundIcon,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Composer,
ComposerActionGroup,
ComposerActions,
ComposerInput,
ComposerItem,
type ComposerItemValue,
ComposerList,
ComposerPopup,
ComposerSubmit,
ComposerToken,
type ComposerTrigger,
type ComposerValue,
getComposerText,
} from "@/components/ui/composer";
const people = [
{ id: "alex", label: "Alex Morgan", kind: "person" },
{ id: "sam", label: "Sam Rivera", kind: "person" },
];
const teams = [
{ id: "design", label: "Design team", kind: "team" },
{ id: "engineering", label: "Engineering team", kind: "team" },
];
const actions = [
{ id: "summarize", label: "Summarize", type: "action" as const },
{ id: "rewrite", label: "Rewrite", type: "action" as const },
];
const triggers: ComposerTrigger[] = [
{ character: "@", items: [...people, ...teams] },
{ character: "/", items: actions },
];
export default function Particle() {
const [value, setValue] = useState<ComposerValue>([]);
const [notice, setNotice] = useState("");
return (
<div className="flex w-full max-w-xl flex-col gap-3">
<Composer
value={value}
onValueChange={setValue}
triggers={triggers}
onSubmit={(draft) => {
setNotice(`Sent: ${getComposerText(draft)}`);
setValue([]);
}}
>
<ComposerInput
aria-label="Message"
placeholder="Write a message…"
renderToken={(token) => (
<ComposerToken
token={token}
variant="chip"
icon={
token.type === "action" ? (
<SparklesIcon aria-hidden="true" />
) : token.kind === "team" ? (
<UsersRoundIcon aria-hidden="true" />
) : (
<UserRoundIcon aria-hidden="true" />
)
}
/>
)}
/>
<ComposerPopup>
<ComposerList>
{(item: ComposerItemValue) => (
<ComposerItem key={item.id} value={item}>
{item.type === "action" ? (
<SparklesIcon
aria-hidden="true"
className="size-4 opacity-80"
/>
) : item.kind === "team" ? (
<UsersRoundIcon
aria-hidden="true"
className="size-4 opacity-80"
/>
) : (
<UserRoundIcon
aria-hidden="true"
className="size-4 opacity-80"
/>
)}
<span className="truncate">{item.label}</span>
</ComposerItem>
)}
</ComposerList>
</ComposerPopup>
<ComposerActions>
<ComposerActionGroup align="end">
<ComposerSubmit
render={
<Button aria-label="Send message" size="icon">
<ArrowUpIcon aria-hidden="true" />
</Button>
}
/>
</ComposerActionGroup>
</ComposerActions>
</Composer>
<p className="min-h-5 text-muted-foreground text-sm" role="status">
{notice}
</p>
</div>
);
}
The selected item's kind reaches renderToken, so a person and a team selected from the same @ menu can use different icons or styles. The structured draft keeps type, kind, id, label, and trigger; getComposerText(value) still returns text such as @Sam Rivera when a chip hides the trigger visually. An item's icon is available to the suggestion list renderer but is not stored in the draft. A native textarea remains plain text and does not render tokens.
Custom filtering
The default filter ignores case and accents. Set a trigger's filter to customize matching. useComposerFilter exposes Base UI's contains, startsWith, and endsWith helpers.
"use client";
import { ArrowUpIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Composer,
ComposerActionGroup,
ComposerActions,
ComposerInput,
ComposerItem,
type ComposerItemValue,
ComposerList,
ComposerPopup,
ComposerSubmit,
type ComposerValue,
useComposerFilter,
} from "@/components/ui/composer";
const people = [
{ id: "alex", label: "Álex Morgan", description: "Design" },
{ id: "sam", label: "Sam Rivera", description: "Engineering" },
{ id: "taylor", label: "Taylor Kim", description: "Product" },
{ id: "jordan", label: "Jordan Lee", description: "Marketing" },
{ id: "casey", label: "Casey Chen", description: "Support" },
{ id: "morgan", label: "Morgan Davis", description: "Operations" },
{ id: "jamie", label: "Jamie Wilson", description: "Finance" },
{ id: "riley", label: "Riley Patel", description: "Research" },
];
export default function Particle() {
const [value, setValue] = useState<ComposerValue>([]);
const { startsWith } = useComposerFilter();
return (
<Composer
className="w-full max-w-xl"
value={value}
onValueChange={setValue}
onSubmit={() => setValue([])}
triggers={[
{
character: "@",
items: people,
filter: (item, query) => startsWith(item.label, query),
},
]}
>
<ComposerInput
aria-label="Team message"
placeholder="Mention a teammate…"
/>
<ComposerPopup>
<ComposerList>
{(item: ComposerItemValue) => (
<ComposerItem key={item.id} value={item}>
<span className="flex min-w-0 flex-col">
<span className="truncate">{item.label}</span>
<span className="truncate text-muted-foreground text-xs">
{item.description}
</span>
</span>
</ComposerItem>
)}
</ComposerList>
</ComposerPopup>
<ComposerActions>
<ComposerActionGroup align="end">
<ComposerSubmit
render={
<Button aria-label="Send message" size="icon">
<ArrowUpIcon aria-hidden="true" />
</Button>
}
/>
</ComposerActionGroup>
</ComposerActions>
</Composer>
);
}
Async results
Fetch results in response to onQueryChange. Use filter: null for prefiltered results and itemsFor to identify the query they belong to. A completed query with no matches closes the popup.
Search by name or team. Try @design, or @zz for no menu.
"use client";
import { ArrowUpIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Composer,
ComposerActionGroup,
ComposerActions,
ComposerInput,
ComposerItem,
type ComposerItemValue,
ComposerList,
ComposerPopup,
ComposerStatus,
ComposerSubmit,
type ComposerValue,
useComposerFilter,
} from "@/components/ui/composer";
import { Spinner } from "@/components/ui/spinner";
const people = [
{ id: "alex", label: "Alex Morgan", description: "Design" },
{ id: "sam", label: "Sam Rivera", description: "Engineering" },
{ id: "taylor", label: "Taylor Kim", description: "Product" },
];
export default function Particle() {
const [value, setValue] = useState<ComposerValue>([]);
const [queryText, setQueryText] = useState<string | null>(null);
const [resultsFor, setResultsFor] = useState<string>();
const [items, setItems] = useState<typeof people>([]);
const [status, setStatus] = useState<"ready" | "loading">("ready");
const { contains } = useComposerFilter();
useEffect(() => {
if (queryText === null) return;
const timer = window.setTimeout(() => {
setItems(
people.filter(
(person) =>
contains(person.label, queryText) ||
contains(person.description, queryText),
),
);
setResultsFor(queryText);
setStatus("ready");
}, 500);
return () => window.clearTimeout(timer);
}, [queryText, contains]);
return (
<div className="flex w-full max-w-xl flex-col gap-3">
<Composer
value={value}
onValueChange={setValue}
onSubmit={() => setValue([])}
onQueryChange={(query) => {
setQueryText(query?.text ?? null);
setStatus(query ? "loading" : "ready");
}}
triggers={[
{ character: "@", items, itemsFor: resultsFor, filter: null, status },
]}
>
<ComposerInput aria-label="Message" placeholder="Mention someone…" />
<ComposerPopup aria-busy={status === "loading" || undefined}>
{status === "loading" && (
<ComposerStatus>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
Searching...
<Spinner className="size-4.5 sm:size-4" />
</span>
</ComposerStatus>
)}
<ComposerList>
{(item: ComposerItemValue) => (
<ComposerItem key={item.id} value={item}>
<span className="min-w-0">
<span className="block truncate">{item.label}</span>
{item.description && (
<span className="block truncate text-muted-foreground text-xs">
{item.description}
</span>
)}
</span>
</ComposerItem>
)}
</ComposerList>
</ComposerPopup>
<ComposerActions>
<ComposerActionGroup align="end">
<ComposerSubmit
render={
<Button aria-label="Send message" size="icon">
<ArrowUpIcon aria-hidden="true" />
</Button>
}
/>
</ComposerActionGroup>
</ComposerActions>
</Composer>
<p className="text-muted-foreground text-sm">
Search by name or team. Try @design, or @zz for no menu.
</p>
</div>
);
}
Keyboard shortcuts
While suggestions are open, ↑ and ↓ navigate, Enter selects, Escape dismisses, and Tab moves focus normally. Otherwise, Enter submits and Shift+Enter inserts a line break in the native contenteditable input or textarea. Set submitOnEnter="desktop" to submit only with a fine pointer, or "never" to let Enter insert a line break.
In a drawer
Bottom drawers automatically keep the input above the software keyboard. Place the composer after the scrolling DrawerPanel so it stays visible. Set the drawer height in your composition; Drawer handles keyboard and safe-area spacing automatically. Leave the Composer free to shrink in the drawer: its input scrolls in the remaining space while the action row stays visible.
"use client";
import { ArrowUpIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Composer,
ComposerActionGroup,
ComposerActions,
ComposerInput,
ComposerItem,
type ComposerItemValue,
ComposerList,
ComposerPopup,
ComposerSubmit,
type ComposerValue,
} from "@/components/ui/composer";
import {
Drawer,
DrawerHeader,
DrawerPanel,
DrawerPopup,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
const blocks = Array.from({ length: 32 }, (_, index) => `block-${index}`);
const people = [
{ id: "alex", label: "Alex Morgan" },
{ id: "sam", label: "Sam Rivera" },
];
const actions = [
{ id: "summarize", label: "Summarize", type: "action" as const },
{ id: "rewrite", label: "Rewrite", type: "action" as const },
];
export default function Particle() {
const [value, setValue] = useState<ComposerValue>([]);
return (
<Drawer>
<DrawerTrigger render={<Button variant="outline" />}>
Open composer drawer
</DrawerTrigger>
<DrawerPopup className="[--drawer-height:calc(100dvh-3rem)]" showBar>
<DrawerHeader>
<DrawerTitle>Chat</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1">
<DrawerPanel>
<div aria-hidden="true" className="flex flex-col gap-2">
{blocks.map((key) => (
<div className="h-12 shrink-0 rounded-xl bg-muted" key={key} />
))}
</div>
</DrawerPanel>
</div>
<Composer
className="mx-3 mb-3"
value={value}
onValueChange={setValue}
onSubmit={() => setValue([])}
triggers={[
{ character: "@", items: people },
{ character: "/", items: actions },
]}
>
<ComposerInput aria-label="Message" placeholder="Write a message…" />
<ComposerPopup>
<ComposerList>
{(item: ComposerItemValue) => (
<ComposerItem key={item.id} value={item}>
{item.label}
</ComposerItem>
)}
</ComposerList>
</ComposerPopup>
<ComposerActions>
<ComposerActionGroup align="end">
<ComposerSubmit
render={
<Button aria-label="Send message" size="icon">
<ArrowUpIcon aria-hidden="true" />
</Button>
}
/>
</ComposerActionGroup>
</ComposerActions>
</Composer>
</DrawerPopup>
</Drawer>
);
}
API Reference
Composer
The styled frame and state owner for one input. It renders a form by default and works without any state props. Use render={<div />} when embedding it inside another form.
| Prop | Type | Default | Description |
|---|---|---|---|
onSubmit | (value: ComposerValue) => void | — | Receives the draft when submission is allowed; does not clear it |
value | ComposerValue | — | Controlled draft for either input |
defaultValue | ComposerValue | [] | Initial uncontrolled draft |
onValueChange | (value: ComposerValue, details: ComposerChangeDetails) => void | — | Called with the next draft and the change reason |
triggers | readonly ComposerTrigger[] | [] | Suggestion sources for contenteditable; omit for plain text |
onQueryChange | (query: { character: string; text: string } | null) => void | — | Active suggestion query |
submitOnEnter | "desktop" | "always" | "never" | "always" | Enter behavior for either input |
layout | "stacked" | "adaptive" | "stacked" | Keep actions below the input, or place them beside a one-line draft and move them below when it wraps |
disabled | boolean | false | Disables editing, suggestions, and submission |
readOnly | boolean | false | Allows selection and copying, but prevents editing and submission |
submitDisabled | boolean | false | Blocks all submission while allowing editing |
allowEmpty | boolean | false | Allows submission without text, for example with an attachment |
name | string | — | Adds the plain-text draft to the containing form's data |
getSuggestionsAnnouncement | (count: number) => string | English result count and navigation hint | Customizes or translates the suggestion announcement; return an empty string to silence it |
render | Base UI render prop | form | Replaces the frame; a div supports embedding in another form |
className | string | — | Additional classes on the form |
ref | Ref<HTMLFormElement> | — | Ref to the form; can anchor ComposerPopup |
...props | Form props | — | Other native form attributes |
ComposerInput
The editable input with an internal coss ScrollArea. It connects to its parent Composer automatically. Give it an accessible name with aria-label or aria-labelledby.
| Prop | Type | Default | Description |
|---|---|---|---|
render | Base UI render prop | Contenteditable div | Replaces the default element with a native textarea |
placeholder | string | — | Placeholder for the input |
renderToken | (token: ComposerTokenPart) => ReactNode | — | Renders each selected token in the contenteditable input; defaults to ComposerToken |
className | string | — | Additional classes on the editable element |
ref | Ref<HTMLDivElement> | — | Ref to the default div; set other element refs on render |
...props | Div props | — | Other props forwarded to the editable element |
Set the following CSS variables on Composer using className. Row counts follow the input's actual line height, including the change from text-base below md to text-sm on larger screens. Vertical padding is included automatically. A rendered textarea does not need a native rows attribute.
| CSS variable | Default | Description |
|---|---|---|
--composer-min-rows | 1 below md, 2 from md; 1 in adaptive layout | Minimum visible text rows |
--composer-max-rows | 4 below md, 8 from md | Maximum visible text rows before scrolling |
--composer-padding | --spacing(2) | Top content spacing and bottom control spacing; the top spacing scrolls with the text |
--composer-max-height | Calculated from maximum rows and padding | Optional CSS height that replaces the row-based maximum |
The maximum height is also capped at 50dvh. The four-row mobile default leaves more room for surrounding content, such as a conversation inside a drawer.
The default div has multiline textbox semantics. A rendered textarea uses the same parent-owned draft and submission policy. Use one ComposerInput per Composer. Forward supplied props and the ref when rendering a custom wrapper around the native textarea.
ComposerToken
A presentational token for renderToken or read-only previews. Pass the selected token and choose its appearance per item.
import { UserRoundIcon, UsersRoundIcon } from "lucide-react"
import { ComposerInput, ComposerToken } from "@/components/ui/composer"
<ComposerInput
renderToken={(token) => (
<ComposerToken
token={token}
variant="chip"
icon={token.kind === "team" ? <UsersRoundIcon /> : <UserRoundIcon />}
/>
)}
/>| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "chip" | "default" | Text with trigger, or rounded chip without a visible trigger |
token | ComposerTokenPart | required | Selected token, including its trigger, label, and optional kind |
icon | ReactNode | — | Optional icon in the chip variant |
children | ReactNode | token label | Overrides the visible label |
className | string | — | Additional classes on the token |
...props | Span props | — | Other native span attributes; the token is non-editable |
ComposerActions
Contains the action groups in either layout. Place it after ComposerInput so controls remain outside the scrolling draft. The standard layout always shows a bottom row; the adaptive layout places its groups beside short drafts and below longer ones. Apply button spacing and appearance to the groups and buttons.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Leading and trailing action groups |
className | string | — | Additional classes on the row |
...props | Div props | — | Other native div attributes |
ComposerActionGroup
A group of controls within ComposerActions. Put the leading group first and the trailing group after it in both layouts.
| Prop | Type | Default | Description |
|---|---|---|---|
align | "start" | "end" | "start" | Places the group on the leading or trailing side of the input in the compact layout, or of the bottom row when expanded |
children | ReactNode | — | Buttons or other controls |
className | string | — | Additional classes on the group |
...props | Div props | — | Other native div attributes |
ComposerSubmit
A submit control that renders a native button by default. Pass a coss Button through render to choose its appearance, icon, and accessible name. It uses the parent's submission policy for either input. Setting disabled changes this button's state; it never bypasses the parent submission gate.
| Prop | Type | Default | Description |
|---|---|---|---|
render | Base UI render prop | Native button | Replaces the button element, for example with a coss Button |
nativeButton | boolean | true | Set to false only when rendering a non-button element; Base UI supplies keyboard behavior |
focusableWhenDisabled | boolean | false | Keeps a disabled control focusable, using Base UI Button behavior |
disabled | boolean | !canSubmit | Overrides this button’s disabled state; use submitDisabled on Composer to block Enter too |
children | ReactNode | — | Button content when using the native button |
...props | Button element props | — | Native button attributes, including aria-label; type is "button"; clicking requests submission through Composer |
ComposerPopup
A styled Base UI Popover popup with its portal and positioner included. Composer controls its visibility.
| Prop | Type | Default | Description |
|---|---|---|---|
anchor | Positioner anchor | — | Element or ref used to position and size the popup |
side | Positioner side | "top" | Preferred side of the anchor |
align | Positioner align | "start" | Alignment along the anchor |
sideOffset | number | 4 | Space between popup and anchor |
alignOffset | number | — | Alignment adjustment |
positionerClassName | string | — | Additional classes on the positioner |
portalProps | Popover portal props | — | Props for the internal portal |
className | string | — | Additional classes on the popup |
...props | Popover popup props | — | Other popup props, including focus behavior |
The popup anchors to the Composer frame and preserves editor focus automatically. Pass anchor to override its position.
ComposerList
A scrollable result surface. It supplies listbox semantics and maps its items through a render function.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode or (item, index) => ReactNode | — | Result items or a renderer for the current suggestions |
render | Base UI render prop | div | Replaces the list element |
className | string | — | Additional classes on the list element |
ref | Ref<HTMLDivElement> | — | Ref to the list element |
...props | Div props | — | Other props forwarded to the list element |
ComposerItem
A styled button for a result. Pass value to connect selection and keyboard highlighting in its parent Composer.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Item content |
value | ComposerItemValue | — | Candidate item to select in the list |
disabled | boolean | false | Prevents selection |
render | Base UI render prop | button | Replaces the button element |
className | string | — | Additional classes on the item |
ref | Ref<HTMLButtonElement> | — | Ref to the button |
...props | Button element props | — | Other props, including data-highlighted and data-disabled; type defaults to "button" |
ComposerStatus
A status message inside a popup, such as loading feedback.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Status text |
render | Base UI render prop | div | Replaces the status element |
className | string | — | Additional classes on the status |
ref | Ref<HTMLDivElement> | — | Ref to the status element |
...props | Div props | — | Other props; role defaults to "status" |
Drafts and triggers
Draft and trigger types
import type { ReactNode } from "react"
type ComposerValue = readonly ComposerPart[]
type ComposerPart =
| { type: "text"; text: string }
| { type: "mention" | "action"; id: string; label: string; trigger: string; kind?: string }
type ComposerTokenPart = Exclude<ComposerPart, { type: "text" }>
type ComposerItemValue = {
id: string
label: string
disabled?: boolean
description?: string
icon?: ReactNode
type?: "mention" | "action"
kind?: string
}Item IDs must be unique within each trigger. Set disabled: true on an item to skip it during keyboard navigation and prevent selection. type defaults to "mention"; set "action" for slash actions. Use kind for your own categories, such as "person" and "team", even when both come from @. The selected part retains kind. getComposerText(value) returns the plain-text representation with trigger characters, including when chips hide them visually.
ComposerTrigger configures one source:
| Field | Type | Default | Description |
|---|---|---|---|
character | string | required | Trigger, such as "@" or "/" |
items | readonly ComposerItemValue[] | required | Candidate results |
filter | ((item, query) => boolean) | null | label contains query | Custom matching; null uses prefiltered results |
itemsFor | string | — | Query the async items belong to |
status | "ready" | "loading" | "error" | "ready" | Loading/error feedback keeps the popup open; zero ready matches hide it |
Draft lifecycle
Copying or cutting a token includes its trigger in plain text, even when a chip hides it. Pasted or dropped text stays plain text and never acquires a mention ID. Keep application records keyed by the token's id and kind; display icons are not part of the submitted document.
Contenteditable edits have a local undo history, including token insertion, paste, and cut. Replacing the draft externally or resetting it starts a new history. Selection is preserved and clamped to the updated draft. If an external value changes during IME composition, it is applied after composition finishes. A native textarea keeps its browser editing history.
ComposerChangeDetails.reason is "input", "selection" (selecting a suggestion), "history" (contenteditable undo/redo), or "reset".
useComposerContext
Import this hook from the same Composer module and use it inside a descendant component to build custom controls.
function ClearMessage() {
const { isEmpty, reset } = useComposerContext()
return <Button type="button" disabled={isEmpty} onClick={reset}>Reset</Button>
}| Return value | Description |
|---|---|
value | Current ComposerValue |
isEmpty | Whether the draft contains no text or tokens |
canSubmit | Whether the shared submission policy permits sending |
disabled | Whether the Composer is disabled |
readOnly | Whether editing is read-only |
status | Active trigger's loading state |
submit() | Requests submission, including native validation for a form frame |
insertTrigger(character) | Inserts a configured trigger at the caret, separating it from surrounding text when needed, then opens its suggestions. Available with the contenteditable input. |
reset() | Restores the initial draft, or requests a reset from the controlled owner |
Input event handlers run before Composer's handlers. Call event.preventDefault() to override a supported keyboard or clipboard action. Base UI's event.preventBaseUIHandler() also stops the merged internal handler. Keep application business rules on Composer so they apply to both keyboard and pointer submission.
On This Page