Composer Rich
A rich input for Composer with slash commands, mentions, and inline chips.
Why Tiptap
The picker, the chips, and the keyboard wiring are non-trivial in a raw contentEditable. Tiptap brings three things this component leans on:
- A schema-driven editor where a chip is a real node, not styled text. The caret treats it as one atom, copy and paste preserves it, and rendering through React is just a node view.
- The
Suggestionplugin, which handles the open and close lifecycle for/and@pickers, tracks the query under the caret, and fires acommandcallback when the user picks. - A predictable JSON document model.
defaultValueand a controlledvalueround-trip cleanly because every chip is a node with attributes, not an HTML span the editor has to parse.
If you only need plain text, ComposerInput from composer is the right pick. Use ComposerRichInput when chips, mentions, or commands are part of the input.
What is a chip?
A chip is an inline pill in the editor that represents one picked item, like an @file reference or a mentioned user. It looks like text but behaves as a single unit: the caret skips over it, a single Backspace deletes the whole thing, and the icon and label come from the item that produced it. Each chip carries the item's full data through submit, so the consumer can read attachments, mentions, or references without parsing the text back out.
Anatomy
ComposerRichInput drops in for ComposerInput. The root, toolbar, and submit button are the same in both inputs, so swapping is a one-line change. Pair it with ComposerSuggestions for the popup that floats above (or below) the editor.
Items are plain objects, not JSX. Each item has an id, a label, an optional description, an optional icon, and an optional children array for a nested list. data is carried through to the submit payload, so a mention's full record can ride along without a side lookup.
Triggers
The same prop drives both pickers. The only thing that differs between / and @ is the default action when an item is picked.
action: "insert"replaces the trigger text with a chip. Default for@.action: "execute"deletes the typed/footext and runsonSelect, if any. Default for every other character.
Pass onSelect to take full control of what selection does. The handler receives the picked item and a SelectContext with three helpers: insertChip, clearTrigger, and close.
The three selection helpers
The helpers exist because the editor and the popup are independent surfaces. Picking an item may need to do anything to either one, in any combination.
insertChip(item)replaces the typed/foorange with a chip and adds a trailing space. Use it when the picked item should become an inline reference. It does both the deletion and the insertion, so you do not needclearTriggeralongside it.clearTrigger()deletes only the typed/foorange, leaving nothing in its place. Use it after running a side effect (set state, open a dialog, send a request) so the leftover trigger text disappears from the editor.close()closes the popup. It is provided for completeness, since the popup also closes automatically afteronSelectreturns. Calling it makes the order of operations explicit when several things happen in the handler.
The pattern in the example above (clearTrigger() then close()) reflects how the two things relate: first clean up the editor, then close the floating UI. The order does not change the outcome here because both happen synchronously inside the handler, but writing it this way reads cleanly and matches the mental model of "remove the command text, then dismiss the picker."
For an insertChip flow, neither helper is required. The chip replaces the range and the popup tears down by itself.
Submenus
An item with children opens a submenu instead of firing the trigger's action. The query under the trigger is cleared so the user can keep typing to filter inside the submenu. ArrowLeft or Escape steps back to the parent list.
The chain of parents is exposed to ComposerSuggestions through renderHeader, so a breadcrumb falls out naturally.
Mentions
A mention picks an item and lays it down as a chip. Pass renderChip on the trigger config to control how the chip renders. The chip wrapper (rounded background, ring, padding) stays consistent so chips look uniform across triggers, but anything inside is yours.
Items keep their data payload, so the renderer can branch on it. The chip stores identity (the original item) and renders fresh on every paint, so swapping renderChip after the fact updates every chip immediately.
Grouping
Items can carry an optional group label. Consecutive items with the same group render under a single header. Grouping is purely visual: keyboard navigation walks the full flat list, so a user pressing ArrowDown moves through items regardless of group boundaries.
Pass renderGroup on ComposerSuggestions to customize the header. The default header is a small label in the muted color.
Order matters. Ungrouped items can appear before, between, or after groups by leaving group undefined. Filtering preserves order, so a group with all items filtered out collapses naturally.
Async items
items accepts a function that returns a promise. The function receives the current query, so a server-fed mention list can debounce by query length on the caller side. While the promise resolves, the suggestion list shows the loading state. Stale results are dropped if a newer query has already started, so a slow response from an earlier keystroke never overwrites the latest results.
Pass filter: () => true when the server already returns matches so the client does not filter on top of it.
Controlled mode
For most cases, the uncontrolled version is enough: the editor owns its content, optionally seeded by defaultValue, and clears itself after submit. Reach for the controlled version when the parent needs to mutate the input externally, like loading a draft, prefilling with a template, or clearing after a custom submit flow.
A few things to know about how the editor reconciles to the prop:
- The editor compares the incoming
valueagainst the last value it emitted fromonValueChange. If they are structurally equal, the editor leaves the DOM alone. This avoids tearing down the caret on every keystroke. - When the prop differs, the editor rebuilds its document from
value.segments. Chips, paragraph breaks, and text runs are all reconstructed in order. - Setting
valueback to an emptyComposerValueclears the editor. This is how a controlled submit handler resets the input.
The shape of ComposerValue
ComposerValue is intentionally a pair: a flat text and a structured segments array. Both fields describe the same content, viewed differently.
The two views exist because the editor sits between two worlds. On one side, models and APIs want a plain string. On the other, the editor's own document is a tree of nodes that needs to round-trip without losing chip identity. Keeping both lets each consumer pick what fits without rebuilding the other.
textis the easy path. Chips collapse to a{{trigger:id}}placeholder so the value stays serializable through JSON, logs, query strings, or wire formats. Send it to a model verbatim, or post-process the placeholders into whatever format the model expects.segmentsis the structured view. Eachtextsegment carries a run of plain text (including newlines). Eachchipsegment carries the full pickeditem, includingiconanddata. Walk it when you need attachments in order, or when reconstructing the editor for an edit flow.
The two fields move together by construction. The editor produces both at once and round-trips them as a pair, so a controlled parent that stores whatever onValueChange emits can hand it straight back as value without surgery.
Constructing a value by hand
For a hand-built seed like a template or a saved draft, assemble the segments in document order. Text runs become text segments. Chips become chip segments that carry the original ComposerItem, including its icon and data. The matching text field uses the same {{trigger:id}} placeholder the editor would have emitted, in the same spot.
A few rules for hand-built values:
- Each chip in
segmentsshould have a matching{{trigger:id}}placeholder intext, with surrounding text segments concatenated around it. The editor does not enforce this for hand-built input, but a mismatch means the round-trip view will not match the rendered view. - Newlines inside a
textsegment turn into paragraph breaks. To start with two blank lines, pass{ type: "text", value: "\n\n" }. - The
iconanddataon the chip'sitemsurvive in memory but are not part oftext. If you persist a draft to localStorage and reload, the icon needs to be re-attached when reconstructing the value, since JSON cannot serialize a React element.
Submission payload
onSubmit receives the same ComposerValue that flows through onValueChange.
text is the easy path. Walk the placeholders or send the raw string to the model.
segments is the structured view. Walk it when the call site needs the picked items in order with their full data attached.
Custom data
A chip stores identity, not content. Whatever sits on the original ComposerItem survives intact through onSubmit, because the chip references the item in editor storage rather than serializing it through JSON. Non-serializable fields like the React icon round-trip too.
The two consumer patterns:
- Cheap metadata on the item itself. Drop
data: { path, mime, size }on the item. Readsegment.item.datadirectly at submit. - Identity plus lookup. Put only
idandlabelon the item, and resolve heavier content from your store or an API at submit time. Best when the payload is large or stale-prone.
A pragmatic split is to keep small identifiers on data (path, kind, thumbnail URL), and lazy-resolve heavy content right before the API call.
Keyboard
While the suggestion list is closed:
Entersubmits.Shift+Enteradds a newline.
While the suggestion list is open:
ArrowUpandArrowDownmove the highlight, clamping at the ends.EnterorTabpicks the highlighted item.ArrowRightdrills into a submenu when the highlighted item has children.ArrowLeftorEscapesteps out of a submenu, then closes the list.- Typing past the trigger char filters live. Spaces are allowed, so multi-word queries like
/plan modekeep filtering. - Backspace on the trigger char or before it closes the list.
By default the list hides itself once a query has zero matches, so a stale popup does not hover under the caret while typing freely. Enter then falls through to submit. Set hideOnEmpty: false on a trigger to keep the empty state visible for that trigger only.
A chip is one atom under the caret. Backspace right after a chip deletes the whole chip rather than a character.
API
Reference for each part of the component, including its available props and behavior.
ComposerRichInput
The Tiptap-based input. Registers a submit handler with the surrounding Composer root, so the toolbar's submit button drives it the same way it would the plain textarea. Configures one Tiptap suggestion plugin per trigger character. Chips are a custom node type rendered through a React node view, so renderChip is a normal React function and gets all React features.
The input runs in either mode. Leave value unset and the editor owns its content, optionally seeded by defaultValue, and clears itself after submit. Pass value together with onValueChange to hand state ownership to the parent. The editor reconciles to the prop when its segments structurally differ from the last emitted value, so a parent setting value to an empty ComposerValue resets the editor.
ComposerSuggestions
The floating list above (or below) the input. Renders only when a trigger is active. Anchors to the rich input wrapper and flips placement depending on available space. renderItem, renderEmpty, renderLoading, renderHeader, and renderGroup cover every cell of the list.
type ComposerTrigger
The shape of a single entry in the triggers map. One trigger per character. The generic T flows through to ComposerItem<T> and SelectContext<T>, so a per-trigger data shape stays typed end to end.
type ComposerItem
The shape of a row in the suggestion list. Plain object, not JSX. The same shape is reused for a chip's identity after it lands in the editor, and the picked item surfaces on the chip segment in onSubmit.
type SelectContext
The second argument to onSelect. Helpers for the three things a custom selection handler tends to do: insert the picked item as a chip, clear the typed trigger text, or close the popup. The T generic from the trigger flows in, so insertChip is typed against your item shape.
type ComposerValue
The payload that flows through onSubmit, onValueChange, value, and defaultValue. Two views over the same content: text for serializing to a model or API, and segments for round-tripping the editor and reading chip data.
type ComposerSegment
A discriminated union for a single span in ComposerValue.segments. A text segment carries a run of plain text including newlines. A chip segment carries the trigger character and the picked item, which keeps any custom data and non-serializable fields like a React icon alive across submit and reload.