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.
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:
RichTextEditorToolbarCustomIconButtonis the icon button. It readssizeandisDisabledfrom context, forwards aref, and passes through any extra prop: spread aDropdownMenurender prop'striggerPropsonto 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.
Loaded from storage
This content was already here, with bold, italic and a link.
- First item
- Second item
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'sJSONContentdocument instead. Use it when a display surface needs to render a node as a real React component: JSON keeps each node'stype, where HTML flattens it into markup you'd have to match on. Switching means retypingvalueandonChangetogether:useRichTextEditor({ features, onChange: setDoc, outputFormat: 'json', value: doc }), wheredocis aJSONContentobject ({ 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:
- At rest, only the content shows.
- Focusing the editor expands it, and it stays expanded after focus leaves.
- 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.
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.
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
| Prop | Default | Description |
|---|---|---|
editor | _ | RichTextEditorHandleThe handle returned by useRichTextEditor. RichTextEditor is presentation only; the hook owns the tiptap instance, the controlled value contract, and file routing. |
children | _ | React.ReactNodeToolbar, content, attachments dock, and action bar, composed in JSX order. |
height? | _ | number | stringFixed height applied to the content area only. |
maxHeight? | 312 | number | stringMax 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)
| Prop | Default | Description |
|---|---|---|
value | _ | string | JSONContentControlled value, in whichever format outputFormat selects. |
onChange | _ | (value: string | JSONContent) => voidCalled 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? | false | boolean |
useRichTextEditor (return)
| Prop | Default | Description |
|---|---|---|
focus | _ | () => void |
blur | _ | () => void |
clear | _ | () => voidEmits through onChange. |
isEmpty | _ | boolean |
isFocused | _ | boolean |
textLength | _ | numberText length, not markup length, from getText(), not a character-count extension. |
tiptap | _ | Editor | nullThe 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.
| Prop | Default | Description |
|---|---|---|
editor | _ | Editor | nullThe raw tiptap instance, to run commands on. |
isDisabled | _ | booleanWhether the editor is disabled. Built-in controls disable with it. |
size | _ | RichTextEditorSizeThe 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.
| Prop | Default | Description |
|---|---|---|
aria-label? | 'Text formatting' | stringThe toolbar's accessible name. |
children? | _ | React.ReactNodeWhen 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.
RichTextEditorBoldIconButtonRichTextEditorItalicIconButtonRichTextEditorUnderlineIconButtonRichTextEditorUnorderedListIconButtonRichTextEditorOrderedListIconButtonRichTextEditorLinkIconButtonRichTextEditorAttachIconButtonRichTextEditorAlignLeftIconButtonRichTextEditorAlignCenterIconButtonRichTextEditorAlignRightIconButtonRichTextEditorTextSizeIconButtonRichTextEditorToolbarCustomIconButton
| Component | Requires feature | Description |
|---|---|---|
RichTextEditorBoldIconButton | richTextEditorBold() | Bold mark. |
RichTextEditorItalicIconButton | richTextEditorItalic() | Italic mark. |
RichTextEditorUnderlineIconButton | richTextEditorUnderline() | Underline mark. |
RichTextEditorUnorderedListIconButton | richTextEditorUnorderedList() | Unordered list. |
RichTextEditorOrderedListIconButton | richTextEditorOrderedList() | Ordered list. |
RichTextEditorLinkIconButton | richTextEditorLink() | Link mark, opens the link popover. |
RichTextEditorAttachIconButton | richTextEditorAttachments() | Opens the file picker for the attachment dock. |
RichTextEditorAlignLeftIconButton | richTextEditorTextAlign() | Left paragraph alignment. |
RichTextEditorAlignCenterIconButton | richTextEditorTextAlign() | Center paragraph alignment. |
RichTextEditorAlignRightIconButton | richTextEditorTextAlign() | Right paragraph alignment. |
RichTextEditorTextSizeIconButton | richTextEditorHeading() | 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.
| Prop | Default | Description |
|---|---|---|
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.
| Component | Prop | Description |
|---|---|---|
RichTextEditorCancelButton | children: (props: { actionProps }) => ReactNode | Required, no default element to fall back to. |
RichTextEditorSubmitButton | children: (props: { actionProps }) => ReactNode | Same contract, with the composed isDisabled. |
Features
| Feature | Config | Description |
|---|---|---|
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()].