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 Suggestion plugin, which handles the open and close lifecycle for / and @ pickers, tracks the query under the caret, and fires a command callback when the user picks.
  • A predictable JSON document model. defaultValue and a controlled value round-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.

TSX

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.

TSX

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 /foo text and runs onSelect, 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.

TSX

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 /foo range 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 need clearTrigger alongside it.
  • clearTrigger() deletes only the typed /foo range, 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 after onSelect returns. 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.

TSX

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.

TSX

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.

TSX

Pass renderGroup on ComposerSuggestions to customize the header. The default header is a small label in the muted color.

TSX

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.

TSX

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.

TSX

A few things to know about how the editor reconciles to the prop:

  • The editor compares the incoming value against the last value it emitted from onValueChange. 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 value back to an empty ComposerValue clears 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.

TS

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.

  • text is 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.
  • segments is the structured view. Each text segment carries a run of plain text (including newlines). Each chip segment carries the full picked item, including icon and data. 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.

TSX

A few rules for hand-built values:

  • Each chip in segments should have a matching {{trigger:id}} placeholder in text, 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 text segment turn into paragraph breaks. To start with two blank lines, pass { type: "text", value: "\n\n" }.
  • The icon and data on the chip's item survive in memory but are not part of text. 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.

TSX

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.

TSX

The two consumer patterns:

  • Cheap metadata on the item itself. Drop data: { path, mime, size } on the item. Read segment.item.data directly at submit.
  • Identity plus lookup. Put only id and label on the item, and resolve heavier content from your store or an API at submit time. Best when the payload is large or stale-prone.
TSX

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:

  • Enter submits. Shift+Enter adds a newline.

While the suggestion list is open:

  • ArrowUp and ArrowDown move the highlight, clamping at the ends.
  • Enter or Tab picks the highlighted item.
  • ArrowRight drills into a submenu when the highlighted item has children.
  • ArrowLeft or Escape steps out of a submenu, then closes the list.
  • Typing past the trigger char filters live. Spaces are allowed, so multi-word queries like /plan mode keep 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.

TSX

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.

Prop

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.

Prop

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.

Prop

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.

Prop

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.

Prop

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.

Prop

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.

Prop