RichTextEditor

RichTextEditor is a rich-text input built on tiptap, split into two parts. The useRichTextEditor() hook creates the editor and returns a handle. The RichTextEditor component and its parts (RichTextEditorToolbar, RichTextEditorContent, RichTextEditorAttachments, RichTextEditorActionBar) just display what that handle gives them.

Every capability (bold, links, lists, headings, attachments, and more) is opt-in. You enable each one by passing a richTextEditor*() feature factory into the hook's features array. Nothing you don't ask for gets bundled: a surface that only needs attachments won't ship marks, lists, headings, or the emoji dataset.

Import RichTextEditor from @adaptavant/eds-core. It must always be wrapped in Field, which handles the label, message, status, and character counter for you.

Quick Start

Installation
npm install @adaptavant/eds-core
Import
import { RichTextEditor } from '@adaptavant/eds-core';

Basic Editor​

This is the smallest working editor: just an editable area, nothing else. There's no toolbar and no action bar, but formatting still works: you can type or paste it in (see Choosing features). There's just no button to click for it.

const [value, setValue] = React.useState('');

const editor = useRichTextEditor({
  features: richTextEditorDefaultFeatures(),
  onChange: setValue,
  value,
});

return (
  <Field className="w-full" label="Notes" labelVisibility="hidden">
    <RichTextEditor editor={editor}>
      <RichTextEditorContent />
    </RichTextEditor>
  </Field>
);

See Composition for adding a toolbar and action bar on top of this.

Composition​

The editor splits into two parts. useRichTextEditor() owns the tiptap instance, the controlled value, and the feature set. RichTextEditor is presentation only: it just reads everything from the handle you pass it. You compose the parts you want as children.

RichTextEditor must be wrapped in Field. Field owns the label, the message, the counter, and every id.

Toolbar, Content, and ActionBar are independent. Compose only the ones a surface needs.

Toolbar - Renders the formatting controls derived from features.

Content - The editable area itself, the one part every composition needs.

ActionBar - The right-aligned row for cancel/submit actions.

richTextEditorDefaultFeatures() is the "give me everything" starting point. It turns on every zero-config feature at once, except richTextEditorEmoji(), which you add yourself when you want it.

const [value, setValue] = React.useState('<p>Start typing.</p>');

const editor = useRichTextEditor({
  features: richTextEditorDefaultFeatures(),
  onChange: setValue,
  value,
});

return (
  <Field className="w-full" label="Notes" labelVisibility="hidden">
    <RichTextEditor editor={editor}>
      <RichTextEditorToolbar />
      <RichTextEditorContent />
      <RichTextEditorActionBar>
        <RichTextEditorSubmitButton>
          {({ actionProps }) => (
            <IconButton
              {...actionProps}
              aria-label="Submit"
              icon={ArrowUpIcon}
              onClick={() => {}}
              variant="accentPrimary"
            />
          )}
        </RichTextEditorSubmitButton>
      </RichTextEditorActionBar>
    </RichTextEditor>
  </Field>
);

See Toolbar, ActionBar, and Size for how to customize each part.

Choosing features​

Capabilities are opt-in. An editor with no features is a plain text box: nothing is enabled by default, and the bundler drops the code for anything you never import. Each feature contributes its own schema, keyboard shortcuts, paste behavior, and, where it has one, its toolbar button.

Six features are deliberately schema-only and ship no toolbar button: strike, code, codeBlock, blockquote, horizontalRule, and emoji. The toolbar's button set is fixed by design, so you reach these by typing instead. In the example below, codeBlock has no button. Type three backticks at the start of a line to use it.

Only the features passed to the hook are enabled.
const [value, setValue] = React.useState(
  '<p>Here only bold, italic, unordered list, link, and code block are enabled, with four buttons in the toolbar.</p>'
);

const editor = useRichTextEditor({
  features: [
    richTextEditorBold(),
    richTextEditorItalic(),
    richTextEditorUnorderedList(),
    richTextEditorLink(),
    richTextEditorCodeBlock(),
  ],
  onChange: setValue,
  placeholder: 'Four buttons above — type three backticks for a code block',
  value,
});

return (
  <Field
    className="w-full"
    description="Only the features passed to the hook are enabled."
    label="Notes"
    labelVisibility="hidden"
  >
    <RichTextEditor editor={editor}>
      <RichTextEditorToolbar />
      <RichTextEditorContent />
    </RichTextEditor>
  </Field>
);

A childless RichTextEditorToolbar derives its buttons from the features you passed. It uses a canonical order that EDS owns: the order of your features array is ignored. Dividers between groups collapse on their own when a group turns out to be empty.

Toolbar​

Where you put the toolbar decides its layout. By default it goes directly in RichTextEditor, above the content, like the first editor below. Place it inside RichTextEditorActionBar instead and it sits below the content, alongside the actions, like the second editor below. To hide it, just leave it out. See the Notes example for chrome that only appears on focus.

A childless RichTextEditorToolbar derives its buttons from features, in a canonical order EDS owns. Dividers between groups collapse on their own when a group turns out empty.

const features = richTextEditorDefaultFeatures();

const [top, setTop] = React.useState('<p>Here the toolbar is placed at the top.</p>');
const [bottom, setBottom] = React.useState('<p>Here the toolbar is placed at the bottom.</p>');

const topEditor = useRichTextEditor({ features, onChange: setTop, value: top });
const bottomEditor = useRichTextEditor({ features, onChange: setBottom, value: bottom });

return (
  <Box className="flex w-full flex-col items-start gap-4">
    <Field className="w-full" label="Toolbar at the top (default)" labelVisibility="hidden">
      <RichTextEditor editor={topEditor}>
        <RichTextEditorToolbar />
        <RichTextEditorContent />
      </RichTextEditor>
    </Field>

    <Field className="w-full" label="Toolbar at the bottom" labelVisibility="hidden">
      <RichTextEditor editor={bottomEditor}>
        <RichTextEditorContent />
        <RichTextEditorActionBar>
          <RichTextEditorToolbar />
          <RichTextEditorSubmitButton>
            {({ actionProps }) => (
              <IconButton
                {...actionProps}
                aria-label="Submit"
                icon={ArrowUpIcon}
                onClick={() => {}}
                variant="accentPrimary"
              />
            )}
          </RichTextEditorSubmitButton>
        </RichTextEditorActionBar>
      </RichTextEditor>
    </Field>
  </Box>
);

Custom toolbar​

Give RichTextEditorToolbar children instead, and it renders exactly what you compose: your own order, your own subset, with dividers placed by hand using RichTextEditorToolbarSeparator. To add one control without hand-listing the rest, drop RichTextEditorDefaultToolbarControls in alongside it. See the pattern below.

For a control EDS does not ship, you have two options:

  • RichTextEditorToolbarCustomIconButton is the icon button. It reads size and isDisabled from context, forwards a ref, and passes through any extra prop: spread a DropdownMenu render prop's triggerProps onto it to use it as a dropdown/popover trigger too. Use it for any custom control, plain click or panel-opening.
  • useRichTextEditorContext() hands you the tiptap editor itself, for controls that need to read or mutate document state. See the Emoji Picker example.
const [value, setValue] = React.useState(
  '<p>Here the toolbar shows every default control plus a Quick reply button at the end.</p>'
);

const editor = useRichTextEditor({
  features: richTextEditorDefaultFeatures(),
  onChange: setValue,
  value,
});

return (
  <Field className="w-full" label="Notes" labelVisibility="hidden">
    <RichTextEditor editor={editor}>
      <RichTextEditorToolbar>
        <RichTextEditorDefaultToolbarControls />
        <RichTextEditorToolbarSeparator />
        <RichTextEditorToolbarCustomIconButton
          aria-label="Quick reply"
          icon={ReplyIcon}
          onClick={() => setValue((current) => `${current}<p>Thanks, on it.</p>`)}
        />
      </RichTextEditorToolbar>
      <RichTextEditorContent />
    </RichTextEditor>
  </Field>
);

RichTextEditorDefaultToolbarControls renders the same set a childless RichTextEditorToolbar derives on its own: every button for every passed feature, in EDS's canonical order. That's why adding "Quick reply" here needed no hand-listing of the other ten. Full hand-composed reordering works the same way (say, putting link before the marks). Just list the controls you want instead of RichTextEditorDefaultToolbarControls, in the order you want them.

ActionBar​

RichTextEditorActionBar is the right-aligned row for cancel/submit actions. If you compose the toolbar inside it, the toolbar sits alongside them too (see Toolbar).

RichTextEditorCancelButton and RichTextEditorSubmitButton are render-prop wrappers. Each hands back actionProps, carrying the size and isDisabled the button needs. RichTextEditorSubmitButton folds in more than just the editor's own isDisabled (it also checks isEmpty and the field's invalid state), so an empty or over-limit document can't be submitted without any extra wiring from you.

A fully custom action needs neither wrapper. Anything you compose inside RichTextEditorActionBar renders in that same right-aligned row. It just won't get actionProps for free.

const [value, setValue] = React.useState(
  '<p>Here the action bar holds Save draft, Cancel, and Submit together.</p>'
);

const editor = useRichTextEditor({
  features: [richTextEditorBold(), richTextEditorItalic(), richTextEditorLink()],
  onChange: setValue,
  value,
});

return (
  <Field className="w-full" label="Notes" labelVisibility="hidden">
    <RichTextEditor editor={editor}>
      <RichTextEditorContent />
      <RichTextEditorActionBar>
        <Button
          onClick={() => console.log('draft saved')}
          size="standard"
          variant="neutralTertiary"
        >
          Save draft
        </Button>
        <RichTextEditorCancelButton>
          {({ actionProps }) => (
            <Button {...actionProps} onClick={() => setValue('')} variant="neutralTertiary">
              Cancel
            </Button>
          )}
        </RichTextEditorCancelButton>
        <RichTextEditorSubmitButton>
          {({ actionProps }) => (
            <IconButton
              {...actionProps}
              aria-label="Submit"
              icon={ArrowUpIcon}
              onClick={() => {}}
              variant="accentPrimary"
            />
          )}
        </RichTextEditorSubmitButton>
      </RichTextEditorActionBar>
    </RichTextEditor>
  </Field>
);

"Save draft" is a plain Button. It has no actionProps and no editor awareness: it's just another child in the row. Order it wherever it should sit relative to Cancel and Submit.

Size​

size lives on Field, not on RichTextEditor. RichTextEditor always renders whatever size Field resolves to (default 'standard'). height and maxHeight are RichTextEditor's own props, and they apply to the content area only.

Large

Standard

const [largeValue, setLargeValue] = React.useState(
  '<p>Here this editor is large, with its toolbar above the content.</p>'
);
const [standardValue, setStandardValue] = React.useState(
  '<p>Here this editor is standard, with its toolbar above the content.</p>'
);

const largeEditor = useRichTextEditor({
  features: richTextEditorDefaultFeatures(),
  onChange: setLargeValue,
  value: largeValue,
});

const standardEditor = useRichTextEditor({
  features: richTextEditorDefaultFeatures(),
  onChange: setStandardValue,
  value: standardValue,
});

return (
  <Box className="flex w-full flex-col gap-4">
    <Box className="flex flex-col gap-2">
      <Heading as="h4">Large</Heading>
      <Field className="w-full" label="Notes" labelVisibility="hidden" size="large">
        <RichTextEditor editor={largeEditor} maxHeight={200}>
          <RichTextEditorToolbar />
          <RichTextEditorContent />
        </RichTextEditor>
      </Field>
    </Box>
    <Box className="flex flex-col gap-2">
      <Heading as="h4">Standard</Heading>
      <Field className="w-full" label="Notes" labelVisibility="hidden" size="standard">
        <RichTextEditor editor={standardEditor} maxHeight={200}>
          <RichTextEditorToolbar />
          <RichTextEditorContent />
        </RichTextEditor>
      </Field>
    </Box>
  </Box>
);

Use height for a fixed content area. Use maxHeight (default 312) to let it grow up to a limit and then scroll instead. If you set both, height wins.

Chat Editor​

This is a chat-style composer: content on top, with a bottom action bar holding the toolbar and the send action together.

const [value, setValue] = React.useState(
  '<p>Here the content sits on top, with the toolbar and send action together in the bottom bar.</p>'
);
const [files, setFiles] = React.useState([]);

const editor = useRichTextEditor({
  features: [
    richTextEditorAttachments({
      attachments: files,
      onChange: setFiles,
      onRemove: (file) => setFiles((current) => current.filter((f) => f !== file)),
    }),
  ],
  onChange: setValue,
  placeholder: 'Message...',
  value,
});

return (
  <Field className="w-full" label="Message" labelVisibility="hidden">
    <RichTextEditor editor={editor}>
      <RichTextEditorContent />
      <RichTextEditorAttachments />
      <RichTextEditorActionBar>
        <RichTextEditorToolbar>
          <RichTextEditorAttachIconButton />
          <RichTextEditorToolbarCustomIconButton
            aria-label="Reply"
            icon={ReplyIcon}
            onClick={() => setValue((current) => `${current}<p>Thanks, on it.</p>`)}
          />
        </RichTextEditorToolbar>
        <RichTextEditorSubmitButton>
          {({ actionProps }) => (
            <IconButton
              {...actionProps}
              aria-label="Submit"
              icon={ArrowUpIcon}
              onClick={() => {}}
              variant="accentPrimary"
            />
          )}
        </RichTextEditorSubmitButton>
      </RichTextEditorActionBar>
    </RichTextEditor>
  </Field>
);

The send button is a plain IconButton, not text: that's the pattern this composition exists to show. There is no RichTextEditorCancelButton here, because a chat composer has nothing to cancel back to, only something to send. The reply button is a plain RichTextEditorToolbarCustomIconButton. See Toolbar for the same pattern.

Content Preview​

Here the editor sits above its read-only rendered preview.

Most surfaces need to get one thing right: load existing content into the editor, save what the user writes, then show it again somewhere read-only. That's the full round trip.

Loading existing content just means passing a non-empty initial value when the editor is created. Formatting is preserved. Because this isn't a real edit, onChange does not fire when the editor first loads. So if a consumer is tracking whether the document has unsaved changes, loading saved content won't trigger that on its own.

Showing it again needs no extra component. Editor content is styled by @tailwindcss/typography, themed with EDS tokens and exposed as the prose-rte-standard and prose-rte-large variants. To show it elsewhere, just render the saved HTML in a plain element with the class prose prose-rte-standard, the same class the editor itself uses. That's what keeps the two looking identical.

Edit here.
Read-only render — a plain div with `prose prose-rte-standard`

Loaded from storage

This content was already here, with bold, italic and a link.

  • First item
  • Second item
<h1>Loaded from storage</h1><p>This content was already here, with <strong>bold</strong>, <em>italic</em> and a <a href="https://earth.anywhere.co">link</a>.</p><ul><li>First item</li><li>Second item</li></ul>
const [value, setValue] = React.useState(
  '<h1>Loaded from storage</h1><p>This content was already here, with <strong>bold</strong>, <em>italic</em> and a <a href="https://earth.anywhere.co">link</a>.</p><ul><li>First item</li><li>Second item</li></ul>'
);

const editor = useRichTextEditor({
  features: richTextEditorDefaultFeatures(),
  onChange: setValue,
  value,
});

return (
  <Box className="flex w-full flex-col items-start gap-4">
    <Field
      className="w-full"
      description="Edit here."
      label="Editor"
      labelVisibility="hidden"
    >
      <RichTextEditor editor={editor}>
        <RichTextEditorToolbar />
        <RichTextEditorContent />
      </RichTextEditor>
    </Field>

    <Box className="w-full">
      <Text className="text-body-10 text-tertiary">
        Read-only render — a plain div with `prose prose-rte-standard`
      </Text>
      <div
        className="prose prose-rte-standard rounded-8px border border-tertiary p-3"
        dangerouslySetInnerHTML={{ __html: value }}
      />
    </Box>

    <Box className="bg-neutral-secondary p-2 rounded-8px">
      <Text className="break-all text-body-10 text-secondary whitespace-pre-wrap">
        {value}
      </Text>
    </Box>
  </Box>
);

Choosing a format​

outputFormat decides what value and onChange carry, and it retypes them together.

  • 'html' (default) is what most surfaces store and index. It's also what the read-only render above consumes directly.
  • 'json' gives you tiptap's JSONContent document instead. Use it when a display surface needs to render a node as a real React component: JSON keeps each node's type, where HTML flattens it into markup you'd have to match on. Switching means retyping value and onChange together: useRichTextEditor({ features, onChange: setDoc, outputFormat: 'json', value: doc }), where doc is a JSONContent object ({ type: 'doc', content: [...] }) rather than a string.

Notes​

This is the reference example for focus-driven chrome. One piece of state that you own gates the toolbar, the action bar, and the counter:

  1. At rest, only the content shows.
  2. Focusing the editor expands it, and it stays expanded after focus leaves.
  3. Cancel is the only thing that collapses it again.

There is no prop for this. It's conditional rendering over a boolean you own, and that's exactly why step 2 is possible. Chrome tied directly to isFocused would unmount the cancel button on mousedown, before its click could ever fire.

const [body, setBody] = React.useState(
  '<p>Here only the content shows until the editor is focused.</p>'
);
const [isExpanded, setIsExpanded] = React.useState(false);

const editor = useRichTextEditor({
  features: [
    richTextEditorBold(),
    richTextEditorItalic(),
    richTextEditorLink(),
    richTextEditorEmoji(),
  ],
  onChange: setBody,
  placeholder: 'Write a comment',
  value: body,
});

// One-way latch: nothing here watches for blur, which is what keeps the
// chrome up while the user reaches for a button.
React.useEffect(() => {
  if (editor.isFocused) setIsExpanded(true);
}, [editor.isFocused]);

return (
  <Field
    className="w-full"
    counter={{
      isAlwaysVisible: isExpanded,
      maxValue: 500,
      value: editor.textLength,
    }}
    label="Comment"
    labelVisibility="hidden"
  >
    <RichTextEditor editor={editor}>
      <RichTextEditorContent />
      {isExpanded ? (
        <RichTextEditorActionBar>
          <RichTextEditorToolbar />
          <RichTextEditorCancelButton>
            {({ actionProps }) => (
              <Button
                {...actionProps}
                onClick={() => {
                  setBody('');
                  setIsExpanded(false);
                }}
                variant="neutralTertiary"
              >
                Cancel
              </Button>
            )}
          </RichTextEditorCancelButton>
          <RichTextEditorSubmitButton>
            {({ actionProps }) => (
              <IconButton
                {...actionProps}
                aria-label="Submit"
                icon={ArrowUpIcon}
                onClick={() => editor.clear()}
                variant="accentPrimary"
              />
            )}
          </RichTextEditorSubmitButton>
        </RichTextEditorActionBar>
      ) : null}
    </RichTextEditor>
  </Field>
);

richTextEditorEmoji() is what turns a typed shortcode like :smile: or an emoticon like :) into an emoji as you write. That's separate from the picker in Emoji Picker, which inserts a character on click.

Error States​

Field's status and counter are how error state surfaces on the editor. The editor supplies the numbers; Field renders them.

Wire the counter to editor.textLength, which counts text rather than markup. As you approach maxLength, status="warning" colors the border and message without blocking submit. Once you're over it, status="error" takes over: maxLength doesn't truncate anything, so text keeps flowing and the count goes negative, but submit is disabled while you're over and the border turns critical.

80
const [value, setValue] = React.useState(
  '<p>Toolbar above content, Submit below, counter always visible.</p>'
);
const maxLength = 80;

const editor = useRichTextEditor({
  features: [richTextEditorBold(), richTextEditorItalic(), richTextEditorLink()],
  onChange: setValue,
  placeholder: 'Type past 64 characters for the warning, past 80 for the error',
  value,
});

const isOverLimit = editor.textLength > maxLength;
const isNearLimit = !isOverLimit && editor.textLength > maxLength * 0.8;

return (
  <Field
    className="w-full"
    counter={{
      isAlwaysVisible: true,
      maxValue: maxLength,
      value: editor.textLength,
    }}
    fieldMessage={
      isOverLimit ? 'Too long' : isNearLimit ? 'Getting long' : 'Keep it short and specific.'
    }
    label="Notes"
    labelVisibility="hidden"
    status={isOverLimit ? 'error' : isNearLimit ? 'warning' : undefined}
  >
    <RichTextEditor editor={editor}>
      <RichTextEditorToolbar />
      <RichTextEditorContent />
      <RichTextEditorActionBar>
        <RichTextEditorSubmitButton>
          {({ actionProps }) => (
            <IconButton
              {...actionProps}
              aria-label="Submit"
              icon={ArrowUpIcon}
              onClick={() => {}}
              variant="accentPrimary"
            />
          )}
        </RichTextEditorSubmitButton>
      </RichTextEditorActionBar>
    </RichTextEditor>
  </Field>
);

RichTextEditorSubmitButton folds isEmpty and the field's invalid state into the isDisabled it hands you. So an empty or over-limit document can't be submitted without any extra wiring from you.

Emoji Picker​

Here the toolbar shows every default control plus the emoji trigger at the end.

EDS ships no emoji picker, but it ships the one thing you need to wire one up yourself: useRichTextEditorContext() hands you the tiptap editor, and editor?.chain().focus().insertContent(…).run() is the whole integration. Call .focus() first. Clicking the panel moves DOM focus out of the editor, but ProseMirror still remembers the selection in its own state. .focus() is what brings DOM focus back so the insert lands in the right place.

const EMOJIS = ['😀', '🎉', '🔥', '👍', '🙏', '🚀'];

function EmojiControl() {
  const { editor } = useRichTextEditorContext();

  return (
    <DropdownMenu popoverMatchReferenceWidth={false} popoverMaxWidth={40}>
      {({ isMenuOpen, triggerProps }) => (
        <Fragment>
          <RichTextEditorToolbarCustomIconButton
            {...triggerProps}
            aria-label="Emoji"
            icon={EmojiIcon}
            isPressed={isMenuOpen}
          />
          <DropdownMenuPopover>
            <DropdownMenuList aria-label="Emoji">
              {EMOJIS.map((emoji) => (
                <DropdownMenuItem
                  key={emoji}
                  onClick={() => editor?.chain().focus().insertContent(emoji).run()}
                >
                  {emoji}
                </DropdownMenuItem>
              ))}
            </DropdownMenuList>
          </DropdownMenuPopover>
        </Fragment>
      )}
    </DropdownMenu>
  );
}

const [value, setValue] = React.useState(
  '<p>Put the caret anywhere in this sentence, then pick an emoji.</p>'
);

const editor = useRichTextEditor({
  features: richTextEditorDefaultFeatures(),
  onChange: setValue,
  value,
});

return (
  <Field className="w-full" label="Notes" labelVisibility="hidden">
    <RichTextEditor editor={editor}>
      <RichTextEditorToolbar>
        <RichTextEditorDefaultToolbarControls />
        <RichTextEditorToolbarSeparator />
        <EmojiControl />
      </RichTextEditorToolbar>
      <RichTextEditorContent />
    </RichTextEditor>
  </Field>
);

A panel must be a popover. The toolbar has overflow-x-auto, so anything positioned inside it gets clipped and never appears. The trigger is RichTextEditorToolbarCustomIconButton, spreading triggerProps onto it like any other prop. It still gets size and isDisabled for free from context, the same as any other custom control, no manual context reads needed.

Swap the dropdown above for emoji-mart. Only what's inside DropdownMenuPopover changes, RichTextEditorToolbarCustomIconButton stays exactly as the trigger:

  • import Picker from '@emoji-mart/react'
  • import data from '@emoji-mart/data'
  • in place of DropdownMenuList: <Picker data={data} onEmojiSelect={(emoji) => editor?.chain().focus().insertContent(emoji.native).run()} />

The insert call is the same one-liner as above, just reading the character from emoji.native (what onEmojiSelect hands back) instead of the hardcoded array item. This is separate from richTextEditorEmoji(), also included via richTextEditorDefaultFeatures(): typing :smile: here converts it as you write, same as in Notes. The picker above is a second, independent way in, inserting only on click.

Disabled State​

isDisabled must be passed to both useRichTextEditor and Field. A dev-mode warning fires if the two disagree.

const [value, setValue] = React.useState(
  '<p>Here the toolbar sits above the content, with a button below the field to toggle disabled.</p>'
);
const [isDisabled, setIsDisabled] = React.useState(false);

const editor = useRichTextEditor({
  features: richTextEditorDefaultFeatures(),
  isDisabled,
  onChange: setValue,
  value,
});

return (
  <Box className="flex w-full flex-col items-start gap-2">
    <Field className="w-full" isDisabled={isDisabled} label="Notes" labelVisibility="hidden">
      <RichTextEditor editor={editor}>
        <RichTextEditorToolbar />
        <RichTextEditorContent />
      </RichTextEditor>
    </Field>

    <Button
      onClick={() => setIsDisabled((current) => !current)}
      size="standard"
      variant="neutralSecondary"
    >
      {isDisabled ? 'Enable editor' : 'Disable editor'}
    </Button>
  </Box>
);

Field renders the disabled styling. The hook is what actually stops the editor from accepting input. If you pass isDisabled to only one of the two, they fall out of sync: you'd get an editor that's typeable but greyed out, or one that looks enabled but won't accept input. That's exactly what the dev-mode warning catches.

Attachments​

Here the attachments dock sits below the content, under the toolbar.

richTextEditorAttachments() contributes the toolbar's attach button and renders as a dock of file cards below the editor via RichTextEditorAttachments. The files are not part of the document.

onUpload must resolve before the card settles. It resolves to nothing. Once it does, the card swaps its spinner for the remove badge.

const [value, setValue] = React.useState(
  '<p>Use the attach button to add files.</p>'
);
const [files, setFiles] = React.useState([]);

function mockAttachmentUpload() {
  return new Promise((resolve) => setTimeout(resolve, 2000));
}

const editor = useRichTextEditor({
  features: [
    richTextEditorBold(),
    richTextEditorItalic(),
    richTextEditorAttachments({
      accept: ['image/*', '.pdf', '.csv'],
      attachments: files,
      onChange: setFiles,
      onRejected: (rejections) => console.log('rejected', rejections),
      onRemove: (file) => setFiles((current) => current.filter((f) => f !== file)),
      onUpload: mockAttachmentUpload,
    }),
  ],
  onChange: setValue,
  value,
});

return (
  <Field className="w-full" label="Notes" size="large" labelVisibility="hidden">
    <RichTextEditor editor={editor}>
      <RichTextEditorToolbar />
      <RichTextEditorContent />
      <RichTextEditorAttachments />
    </RichTextEditor>
  </Field>
);

While an attachment uploads, its card keeps its normal layout with a spinner in place of the remove badge. RichTextEditorAttachments renders nothing when there are no attachments, so it's safe to always compose it in.

onRemove isn't redundant with onChange. It tells you which file to delete on the server.

Custom Extensions​

Here the toolbar shows every default control plus a custom Highlight button at the end.

features accepts any object shaped like { name, extensions }, so a tiptap extension you wrote, or installed, can go in right beside the EDS features. tiptap's authoring primitives (Mark, Node, mergeAttributes, nodeInputRule, nodePasteRule, Plugin, ReactNodeViewRenderer, useEditorState) are re-exported from @adaptavant/eds-core/tiptap under their own names. Use those instead of installing a second, possibly mismatched copy of tiptap.

A custom feature has no group or control for a derived toolbar to key off. So you compose the toolbar by hand, and place its button yourself.

Saved value<p>Select some text, then click the button or press Mod-Alt-H.</p>
const CustomHighlight = Mark.create({
  name: 'highlight',
  parseHTML() {
    return [{ tag: 'mark' }];
  },
  renderHTML({ HTMLAttributes }) {
    return ['mark', mergeAttributes(HTMLAttributes), 0];
  },
  addKeyboardShortcuts() {
    return {
      'Mod-Alt-h': () => this.editor.commands.toggleMark('highlight'),
    };
  },
});

function HighlightButton() {
  const { editor, isDisabled, size } = useRichTextEditorContext();
  const isPressed = useEditorState({
    editor,
    selector: (ctx) => Boolean(ctx.editor?.isActive('highlight')),
  });

  return (
    <IconButton
      aria-label="Highlight"
      icon={EditIcon}
      isDisabled={isDisabled || !editor}
      isPressed={isPressed ?? undefined}
      onClick={() => editor?.chain().focus().toggleMark('highlight').run()}
      size={size}
      variant="neutralTertiary"
    />
  );
}

const [value, setValue] = React.useState(
  '<p>Select some text, then click the button or press Mod-Alt-H.</p>'
);

const editor = useRichTextEditor({
  features: [
    ...richTextEditorDefaultFeatures(),
    { extensions: [CustomHighlight], name: 'highlight' },
  ],
  onChange: setValue,
  value,
});

return (
  <Box className="flex w-full flex-col items-start gap-2">
    <Field className="w-full" label="Notes" labelVisibility="hidden">
      <RichTextEditor editor={editor}>
        <RichTextEditorToolbar>
          <RichTextEditorDefaultToolbarControls />
          <RichTextEditorToolbarSeparator />
          <HighlightButton />
        </RichTextEditorToolbar>
        <RichTextEditorContent className="[&_mark]:rounded-4px [&_mark]:bg-caution-secondary [&_mark]:px-[4px]" />
      </RichTextEditor>
    </Field>
    <Text className="text-body-10 text-tertiary">Saved value</Text>
    <Text className="text-body-12 text-secondary block break-all">{value}</Text>
  </Box>
);

Style the node from the content wrapper's className, not from renderHTML. Whatever renderHTML emits becomes both the editor's DOM and what getHTML() saves, so classes written there would end up in every stored row. Styling from the wrapper instead keeps the saved value as plain semantic HTML, here just a bare <mark>.

A feature is reachable when it has a control, an input rule, a paste rule, or a keyboard shortcut. A button is optional, and often unwanted: most custom extensions are triggered by typing (addInputRules), by pasting (addPasteRules), or by a shortcut like the one above. Custom controls land in a trailing group, after the derived defaults.

Beyond marks: an @-mention suggestion​

The full @-trigger mention feature is in Storybook: Mentions. It's an atom node with a <@id|label> markdown wire format, plus a caret-anchored popup built on @tiptap/suggestion's Suggestion plugin.

API Reference​

RichTextEditor

PropDefaultDescription
editor_RichTextEditorHandle
The handle returned by useRichTextEditor. RichTextEditor is presentation only; the hook owns the tiptap instance, the controlled value contract, and file routing.
children_React.ReactNode
Toolbar, content, attachments dock, and action bar, composed in JSX order.
height?_number | string
Fixed height applied to the content area only.
maxHeight?312number | string
Max height applied to the content area only, past which content scrolls instead of growing. Ignored if height is set.

Note: RichTextEditor must be wrapped in Field, which owns the label, the message, the counter, the size, and every id; useFieldContext throws otherwise. Pass the character-limit counter, any field message, and size to Field, not to RichTextEditor. There is no responsive fallback of its own: RichTextEditor always renders whatever size Field resolves to (default 'standard').

useRichTextEditor (options)

PropDefaultDescription
value_string | JSONContent
Controlled value, in whichever format outputFormat selects.
onChange_(value: string | JSONContent) => void
Called with the new value whenever the document changes.
features?[]RichTextEditorFeature[]
What capabilities the editor has, see the Features table below. Safe to pass as an inline array literal: the editor rebuilds off sorted feature names, not the array's own identity.
outputFormat?'html''html' | 'json'
What value and onChange carry. html is what most surfaces store and index. json is for a display surface that needs to render a node as a real component, since JSON keeps type where HTML flattens it into markup.
initialValueFormat?_'markdown'
Seeds value as markdown instead of outputFormat's html/json. Requires a markdown extension supplied through features; lossy in both directions (textAlign, emoji, and other nodes without a markdown spec don't survive), which is why it seeds a document rather than carrying one.
placeholder?'Type something here...'string
isDisabled?falseboolean

useRichTextEditor (return)

PropDefaultDescription
focus_() => void
blur_() => void
clear_() => void
Emits through onChange.
isEmpty_boolean
isFocused_boolean
textLength_number
Text length, not markup length, from getText(), not a character-count extension.
tiptap_Editor | null
The raw tiptap Editor, for anything the handle doesn't otherwise expose.

useRichTextEditorContext

What a custom toolbar control needs, read from the nearest RichTextEditor. Throws outside one.

PropDefaultDescription
editor_Editor | null
The raw tiptap instance, to run commands on.
isDisabled_boolean
Whether the editor is disabled. Built-in controls disable with it.
size_RichTextEditorSize
The size Field resolved to. Built-in controls render at it.

RichTextEditorToolbar

Childless, renders the fixed 11-control default set derived from features. With children, renders exactly what's composed instead (subset, regrouped, or a mix of named controls and consumer controls). Arrow keys, Home and End move focus between its controls.

PropDefaultDescription
aria-label?'Text formatting'string
The toolbar's accessible name.
children?_React.ReactNode
When present, renders only these controls instead of the default 11-control set. Dividers are composed in by hand with RichTextEditorToolbarSeparator.

RichTextEditorDefaultToolbarControls

The same default set RichTextEditorToolbar renders on its own, exported so one custom control can be added without hand-listing the other ten which takes no props.

<RichTextEditorToolbar>
    <RichTextEditorDefaultToolbarControls />
    <CustomToolbarControl />
</RichTextEditorToolbar>

RichTextEditorToolbarSeparator

A divider the consumer places between controls. Hides itself when it would land against nothing: a control whose feature is absent renders no DOM node at all, so the separator can end up genuinely first, last, or next to another separator.

Toolbar buttons

The individual buttons behind each default-toolbar cluster. Only needed when hand-composing a custom toolbar order or subset; each requires its matching feature enabled, or it renders nothing.

  • RichTextEditorBoldIconButton
  • RichTextEditorItalicIconButton
  • RichTextEditorUnderlineIconButton
  • RichTextEditorUnorderedListIconButton
  • RichTextEditorOrderedListIconButton
  • RichTextEditorLinkIconButton
  • RichTextEditorAttachIconButton
  • RichTextEditorAlignLeftIconButton
  • RichTextEditorAlignCenterIconButton
  • RichTextEditorAlignRightIconButton
  • RichTextEditorTextSizeIconButton
  • RichTextEditorToolbarCustomIconButton
ComponentRequires featureDescription
RichTextEditorBoldIconButtonrichTextEditorBold()Bold mark.
RichTextEditorItalicIconButtonrichTextEditorItalic()Italic mark.
RichTextEditorUnderlineIconButtonrichTextEditorUnderline()Underline mark.
RichTextEditorUnorderedListIconButtonrichTextEditorUnorderedList()Unordered list.
RichTextEditorOrderedListIconButtonrichTextEditorOrderedList()Ordered list.
RichTextEditorLinkIconButtonrichTextEditorLink()Link mark, opens the link popover.
RichTextEditorAttachIconButtonrichTextEditorAttachments()Opens the file picker for the attachment dock.
RichTextEditorAlignLeftIconButtonrichTextEditorTextAlign()Left paragraph alignment.
RichTextEditorAlignCenterIconButtonrichTextEditorTextAlign()Center paragraph alignment.
RichTextEditorAlignRightIconButtonrichTextEditorTextAlign()Right paragraph alignment.
RichTextEditorTextSizeIconButtonrichTextEditorHeading()Heading level control.
RichTextEditorToolbarCustomIconButton_Not feature-gated. Generic toolbar icon button for a fully custom control (e.g. an emoji-picker trigger); reads size/isDisabled from context.

RichTextEditorContent

The editable region, wrapping tiptap's EditorContent. height and maxHeight come from context (set on RichTextEditor), not from props here and takes no props of its own beyond style overrides.

RichTextEditorAttachments

The attachment dock, rendered below the content area. Renders nothing when there are no attachments. Requires the richTextEditorAttachments() feature. Takes no props beyond style overrides.

RichTextEditorActionBar

Right-aligned row for cancel/submit actions and, in the bottom-toolbar layout, the toolbar itself.

PropDefaultDescription
children_React.ReactNode

RichTextEditorCancelButton / RichTextEditorSubmitButton

Render-prop wrappers for the action bar's two buttons. The consumer renders the actual button element, onClick and styling go on it, not here. Both hand actionProps (isDisabled, size) to their children.

RichTextEditorSubmitButton's isDisabled folds in isEmpty and isInvalid, so an empty or over-limit document can't submit; that composed check is the reason this component exists instead of a plain button.

ComponentPropDescription
RichTextEditorCancelButtonchildren: (props: { actionProps }) => ReactNodeRequired, no default element to fall back to.
RichTextEditorSubmitButtonchildren: (props: { actionProps }) => ReactNodeSame contract, with the composed isDisabled.

Features

FeatureConfigDescription
richTextEditorBold()_Bold mark, toolbar button included.
richTextEditorItalic()_Italic mark, toolbar button included.
richTextEditorUnderline()_Underline mark, toolbar button included.
richTextEditorStrike()_Strikethrough mark. Typing ~~text~~ only, no toolbar button.
richTextEditorCode()_Inline code mark. Typing `text` only, no toolbar button.
richTextEditorCodeBlock()_Code block node. Typing only, no toolbar button.
richTextEditorBlockquote()_Blockquote node. Typing only, no toolbar button.
richTextEditorHorizontalRule()_Horizontal rule node. Typing only, no toolbar button.
richTextEditorHeading()_Heading levels 1–2, with a text-size toolbar control.
richTextEditorUnorderedList()_Unordered list, toolbar button included.
richTextEditorOrderedList()_Ordered list, toolbar button included.
richTextEditorTextAlign()_Left/center/right paragraph alignment, three toolbar buttons.
richTextEditorLink()_Link mark, toolbar button and popover included.
richTextEditorAttachments()RichTextEditorAttachmentsConfig
(accept, attachments, onChange, onUpload, onUploadError, onRemove, onRejected)
File-attachment dock rendered by RichTextEditorAttachments, with a toolbar attach button.
richTextEditorEmoji()_Typed shortcodes (:smile:) become an emoji node. No toolbar button. Opt-in only, and not part of richTextEditorDefaultFeatures(), because the bundled emoji dataset adds ~157 KB gzip.

richTextEditorDefaultFeatures() returns every zero-config feature above at once, except two: attachments, which needs a per-consumer dock, and emoji, whose dataset is large enough to be an explicit opt-in. Spread it and add either where needed: [...richTextEditorDefaultFeatures(), richTextEditorEmoji()].