Rich Text Editor

The message box behind the chat composer. Enter sends, Shift+Enter adds a line, and headings, lists, links, and code fences work as you type. Put it in a composer InputGroup and hand it a toolbar.

Sizes

Default is an 80px to 256px box with 16px text and a toolbar row below. Compact is a 40px to 128px box with 14px text that keeps its toolbar inline. Enter sends; Shift+Enter adds a line.

default · formatting toolbar · Nothing has been sent.

compact · no toolbar · Cmd or Ctrl+Enter also sends

Formatting and states

Headings 1 to 3, bold, italic, links, lists, quotes, inline code, and fenced code blocks. Type ``` and a language, then Space, to start a code block; pasted fences become code blocks too. Disabled dims the editor and its toolbar.

Enter inside a list or code block adds a line.

disabled · toolbar disabled too

Enter sendShift Enter new line⌘ Enter send from a list or code block⌘ B bold⌘ I italic

Chat composer

Inside a composer InputGroup, the editor drives the group's focus and invalid states, and toolbarAddon becomes the toolbar row. Type @ for saved files or / for skills: Up and Down choose, Enter picks, Escape closes. Over 400 characters shows an error.

  1. Pull the Q3 numbers for Northwind and Contoso.
  2. Northwind grew 14% and Contoso held flat. Want a renewal summary for each?
Q3-forecast.xlsx

Nothing has been sent.

Compact composer

Side panels use size compact: one 40px row with 14px text and only the send or stop button inline. Saved file chips still render, but @ and / suggestions are off.

Nothing has been sent.

Question composer and read-only

While an agent waits on a question, an empty composer condenses to one row with only send. Focus it and the full toolbar returns. A read-only chat disables the editor and every toolbar control.

The agent asked: Which region should the Q3 report cover?

Nothing has been sent.

This chat is read-only. composer · disabled

Usage

Rich Text Editor usageTSX
import {
  RichTextEditor,
  type RichTextEditorHandle,
  type RichTextValue,
} from "@tulipfarm/sprout/rich-text-editor";

// A local draft with a formatting toolbar
const editor = useRef<RichTextEditorHandle>(null);
const [value, setValue] = useState<RichTextValue>({ type: "doc", content: [{ type: "paragraph" }] });

<RichTextEditor ref={editor} value={value} onChange={setValue} onSend={send}
  placeholder="Write a local draft…"
  toolbarAddon={<>
    <Button variant="ghost" size="icon-sm" aria-label="Bold"
      aria-pressed={editor.current?.editor()?.isActive("bold") ?? false}
      onClick={() => editor.current?.editor()?.chain().focus().toggleMark("bold").run()}>
      <Bold aria-hidden="true" />
    </Button>
    <Button variant="outline" size="sm" onClick={send}>Send <Send aria-hidden="true" /></Button>
  </>} />

// Compact, without a toolbar
<RichTextEditor size="compact" value={value} onChange={setValue} onSend={send} />

// Inside the chat composer
<InputGroup variant={questionPending ? "question-composer" : "composer"}>
  {attachments && <InputGroupAddon align="block-start">{attachments}</InputGroupAddon>}
  <RichTextEditor
    key={compact ? "compact" : "full"}
    ref={inputRef}
    size={compact ? "compact" : "default"}
    condensed={questionPending && !focused && !attachments && !canSend}
    placeholder={questionPending ? "Send a separate message…" : "Ask anything…"}
    value={value}
    onChange={setValue}
    onSend={send}
    disabled={disabled || sending}
    ariaInvalid={!!validation}
    ariaDescribedBy={validation ? errorId : undefined}
    ariaExpanded={!!suggestions}
    ariaControls={suggestions?.id}
    ariaActiveDescendant={suggestions?.activeId}
    extensions={compact ? mentionNodes : [...mentionNodes, fileSuggestion, skillSuggestion]}
    toolbarAddon={<>
      <FileChoices />
      <ModelPicker />
      <InputGroupButton type="submit" size="icon-sm"
        variant={canSend ? "default" : "secondary"} aria-label="Send message">
        <ArrowUp aria-hidden="true" />
      </InputGroupButton>
    </>}
  />
</InputGroup>

Props

value is a Tiptap JSON document and onChangegets the new one on every edit. A new value from outside replaces the content without calling onChange.onSend runs on Enter and on Cmd or Ctrl+Enter.

size is default (80px to 256px, 16px text, a toolbar row below) or compact (40px to 128px, 14px text, the toolbar inline at the end). condensed shrinks a default editor to the same single row, such as while an agent question waits. Past the maximum height the editor scrolls.

toolbarAddon holds buttons and pickers.extensions adds Tiptap extensions, such as mention nodes and@ or / suggestion triggers. Pass a stable array withuseMemo; a new array rebuilds the editor.placeholder defaults to Ask anything… and can change while mounted.

disabled stops editing and dims the editor.ariaLabel (default Message),ariaInvalid, ariaDescribedBy,ariaControls, and ariaActiveDescendant go on the textbox.ariaExpanded tells the editor a suggestion list is open; it isn't written to the DOM, because ARIA doesn't allowaria-expanded on a textbox.

The ref handle has focus(),editor() for Tiptap commands, andsetContent(doc), which replaces the document and callsonChange.

Behavior

Enter sends unless the caret is in a list item or code block, a suggestion list is open (ariaExpanded), or the line is a bare code fence. Shift+Enter adds a line break; Cmd or Ctrl+Enter always sends. Enter during IME composition never sends.

Headings go up to level 3. Typing ```sql and Space starts a code block, even after a Shift+Enter line break. Pasted text with``` or ~~~ fences becomes code blocks. Links allow only http, https, andmailto, don't open on click, and stop extending once you type past them. There is no underline or horizontal rule.

Accessibility

The editor is a multi-line textbox witharia-autocomplete="list". PointariaControls at a suggestion listbox andariaActiveDescendant at its active option, so screen readers follow arrow keys while focus stays in the text. Name icon-only toolbar buttons witharia-label and show pressed formats with aria-pressed.

On its own the editor shows a 2px focus outline. Inside anInputGroup it is markeddata-slot="input-group-control", so the group draws focus and invalid borders instead. Fades take 120ms and stop when reduced motion is requested. Forced colors mode keeps a system focus outline.

Theme tokens

Text uses --sprout-text, the placeholder and quotes--sprout-muted, code backgrounds--sprout-hover, and links --sprout-primary. The toolbar divider is --sprout-line and focus--sprout-focus. The opt-in TulipFarm theme tints links and the send button through these tokens.