Editor
A rich text editor with a floating toolbar, a fixed toolbar, a slash menu and mentions, built on ProseMirror.
1(function EditorPreview() {2 const people = [3 { id: "u1", label: "Maya Chen", type: "user" },4 { id: "u2", label: "Arjun Rao", type: "user" },5 { id: "u3", label: "Dana Whitfield", type: "user" },6 ];78 return (9 <div style={{ width: 560 }}>10 <Editor11 defaultValue={{12 type: "doc",13 content: [14 {15 type: "heading",
Anatomy
Import and assemble the editor. The root owns the editor state, so a toolbar can sit anywhere inside it, above or below the content.
1import { Editor, Toolbar } from '@raystack/apsara'23<Editor>4 <Editor.Toolbar>5 <Editor.HistoryButton action="undo" />6 <Editor.HeadingMenu />7 <Editor.ListMenu />8 <Editor.BlockButton block="blockquote" />9 <Toolbar.Separator />10 <Editor.MarkButton mark="bold" />11 <Editor.LinkButton />12 </Editor.Toolbar>13 <Editor.Content />14 <Editor.FloatingToolbar>15 <Editor.MarkButton mark="bold" />16 </Editor.FloatingToolbar>17 <Editor.SlashMenu />18 <Editor.Mentions />19</Editor>
The controls work the same in Editor.Toolbar and Editor.FloatingToolbar, so you build either toolbar from the same parts. Use Toolbar.Group and Toolbar.Separator to group them.
Playground
API Reference
Root
Groups all parts and owns the editor state. Renders a div with data-focused, data-empty, data-disabled and data-readonly.
Prop
Type
Content
The editable document. It renders the ProseMirror view with role="textbox" and aria-multiline. Give it an accessible name with aria-label.
Prop
Type
Toolbar
A toolbar that stays in place. It renders an Apsara Toolbar and takes its props. It does not render when the editor is read only.
Prop
Type
FloatingToolbar
A toolbar that shows above a text selection while the editor has focus. It waits for the mouse button to come up, and it shows at once for a keyboard selection. Escape hides it until the selection changes.
Prop
Type
MarkButton
Toggles a mark. The button is pressed when the selection has the mark, and it is disabled where the mark is not allowed, for example in a code block.
Prop
Type
BlockButton
Toggles a quote, a code block or a list, or inserts a divider.
Prop
Type
HeadingMenu
A menu with regular text and the headings. The trigger shows the current style, and each row shows its shortcut.
Prop
Type
ListMenu
A menu with the list types. The trigger icon follows the active list.
Prop
Type
LinkButton
Adds, edits or removes a link. In the floating toolbar the URL field replaces the buttons. In the fixed toolbar it opens in a popover. Mod-k opens the same field. Only http, https, mailto and relative links are allowed.
Prop
Type
HistoryButton
Undoes or redoes. It is disabled when there is nothing to undo or redo.
Prop
Type
SlashMenu
A menu of block commands that opens on / at the start of a block or after a space. It filters on the label and the keywords, and a space in the query closes it. Picking a command removes the typed /query, and one undo brings it back.
Prop
Type
Prop
Type
Mentions
A menu that inserts a mention chip. It takes the same props as PromptInput.Mentions. Mount one per trigger, for example @ for people and # for issues.
Prop
Type
Prop
Type
Hooks
useEditor() returns the editor API inside <Editor>. useEditorState(selector, isEqual?) selects a value from the ProseMirror state and re-renders only when that value changes. actionsRef gives you the same API from outside the editor.
Prop
Type
Prop
Type
Change details
The second argument of onValueChange. The methods convert the doc from that change each time you call them, so call a method only when you need its result.
Prop
Type
Markdown
MarkdownAdapter.create(options) returns an adapter for the markdown prop. MarkdownAdapter.toEditor(markdown) and MarkdownAdapter.fromEditor(value) convert without an editor, for example on the server. An app that never imports MarkdownAdapter ships no Markdown code.
Prop
Type
Examples
Fixed toolbar
A toolbar above the content, like a document editor.
1<div style={{ width: 600 }}>2 <Editor placeholder="Start writing…">3 <Editor.Toolbar>4 <Toolbar.Group>5 <Editor.HistoryButton action="undo" />6 <Editor.HistoryButton action="redo" />7 </Toolbar.Group>8 <Toolbar.Separator />9 <Toolbar.Group>10 <Editor.HeadingMenu levels={[1, 2, 3]} />11 <Editor.ListMenu />12 <Editor.BlockButton block="blockquote" />13 <Editor.BlockButton block="codeBlock" />14 </Toolbar.Group>15 <Toolbar.Separator />
Comment box
formats limits the nodes and marks, and input rules and paste follow it. details.empty gates the submit button, and actionsRef reads and clears the editor.
1(function CommentBox() {2 const editor = React.useRef(null);3 const [empty, setEmpty] = React.useState(true);4 const [comments, setComments] = React.useState([]);5 const people = [6 { id: "u1", label: "Maya Chen", type: "user" },7 { id: "u2", label: "Arjun Rao", type: "user" },8 { id: "u3", label: "Dana Whitfield", type: "user" },9 ];1011 const send = () => {12 const html = editor.current.getHTML();13 setComments((current) => [...current, html]);14 editor.current.commands.clear();15 setEmpty(true);
Mentions
Two triggers: @ filters a list, and # searches asynchronously. onSearch gets an AbortSignal for requests that a newer query replaces.
1(function MentionsDemo() {2 const people = [3 { id: "u1", label: "Maya Chen", type: "user" },4 { id: "u2", label: "Arjun Rao", type: "user" },5 { id: "u3", label: "Dana Whitfield", type: "user" },6 ];7 const issues = [8 { id: "ENG-214", label: "ENG-214 Improve onboarding", type: "issue" },9 { id: "ENG-230", label: "ENG-230 Calendar range", type: "issue" },10 ];1112 const searchIssues = (query, { signal }) =>13 new Promise((resolve, reject) => {14 const timer = setTimeout(15 () =>
Custom slash commands
Spread defaultSlashItems and add your own. run gets the editor API after the menu removes the typed query.
1(function SlashItems() {2 const items = [3 ...defaultSlashItems,4 {5 id: "date",6 label: "Today's date",7 group: "Insert",8 keywords: ["today", "time"],9 icon: <CalendarIcon />,10 run: (editor) =>11 editor.commands.insertText(new Date().toLocaleDateString()),12 },13 ];1415 return (
Controlled
onValueChange emits editor JSON, and details.getHTML() converts it to HTML. Pass the emitted value back to value, and the editor keeps its selection.
1(function ControlledEditor() {2 const [value, setValue] = React.useState({3 type: "doc",4 content: [5 {6 type: "heading",7 attrs: { level: 2 },8 content: [{ type: "text", text: "Release notes" }],9 },10 {11 type: "paragraph",12 content: [13 { type: "text", text: "Select this text to format it, type " },14 { type: "text", marks: [{ type: "code" }], text: "/" },15 { type: "text", text: " for commands, or " },
Markdown
With the markdown prop, value can be a Markdown string, pasted plain-text Markdown turns into rich content, and details.getMarkdown() returns Markdown. The editor still emits JSON.
1(function MarkdownEditor() {2 const adapter = React.useMemo(() => MarkdownAdapter.create(), []);3 const [markdown, setMarkdown] = React.useState(4 "## Notes\n\nPaste **Markdown** here, or use _shortcuts_ like `- ` and `## `.\n\n- [x] Load Markdown\n- [ ] Save Markdown"5 );67 return (8 <Flex direction="column" gap={4} style={{ width: 520 }}>9 <Editor10 markdown={adapter}11 value={markdown}12 onValueChange={(_, details) => setMarkdown(details.getMarkdown())}13 >14 <Editor.Content15 style={{
Read only
readOnly renders the content and hides the toolbars and menus. Use editorToHTML(value) to render stored content without an editor, for example on the server.
1<div style={{ width: 520 }}>2 <Editor3 readOnly4 defaultValue={{5 type: "doc",6 content: [7 {8 type: "heading",9 attrs: { level: 2 },10 content: [{ type: "text", text: "Release notes" }],11 },12 {13 type: "paragraph",14 content: [15 { type: "text", text: "Select this text to format it, type " },
Custom control
Build your own control with useEditor and useEditorState. Prevent the default on mouse down, so a click does not move the selection.
1function ClearFormattingButton() {2 const editor = useEditor();3 const canClear = useEditorState(() => editor.can.clearFormatting());45 return (6 <Toolbar.Button7 disabled={!canClear}8 onMouseDown={(event) => event.preventDefault()}9 onClick={() => editor.commands.clearFormatting()}10 >11 Clear12 </Toolbar.Button>13 );14}15
Data model
The value is ProseMirror JSON, the output of doc.toJSON(). Node and mark names match Tiptap, so content from Tiptap loads as is.
| Name | Kind | Attributes | Markdown |
|---|---|---|---|
paragraph | block | text | |
heading | block | level: 1 to 4 | # to #### |
blockquote | block | > | |
codeBlock | block | language | fenced code |
bulletList, listItem | block | - | |
orderedList | block | start | 1. |
taskList, taskItem | block | checked | - [ ] |
horizontalRule | block | --- | |
hardBreak | inline | trailing \ | |
mention | inline | id, label, type, trigger | @[label](type:id) |
bold, italic, strike, code | mark | **, _, ~~, ` | |
underline | mark | <u> | |
link | mark | href | [text](href) |
On load, a node the schema does not have becomes a paragraph with its text, and an unknown mark is dropped.
Typing shortcuts: # to #### and a space make a heading, - or * a bulleted list, 1. a numbered list, [] a checklist, > a quote, ``` a code block, and --- a divider. **bold**, _italic_, `code` and ~~strike~~ apply marks. Backspace right after a shortcut undoes it.
Keyboard shortcuts
Mod is Cmd on macOS and Ctrl elsewhere. Override or turn off a key with the shortcuts prop. Tooltips and menu rows show the key you set.
| Action | Key |
|---|---|
| Bold | Mod-b |
| Italic | Mod-i |
| Underline | Mod-u |
| Strikethrough | Mod-Shift-x |
| Inline code | Mod-e |
| Link | Mod-k |
| Text | Mod-Alt-0 |
| Heading 1 to 4 | Mod-Alt-1 to Mod-Alt-4 |
| Bulleted list | Mod-Shift-8 |
| Numbered list | Mod-Shift-9 |
| Checklist | Mod-Shift-7 |
| Quote | Alt-Shift-. |
| Code block | Mod-Shift-\ |
| Undo, redo | Mod-z, Mod-Shift-z |
| Focus the toolbar | Alt-F10 |
In a list, Enter splits the item, and Tab and Shift+Tab indent and outdent it. Shift+Enter adds a line break.
Slots
Every rendered part carries a stable data-slot attribute for styling and testing:
| Slot | Element |
|---|---|
editor | The root element |
editor-content | Editor.Content |
editor-toolbar | Editor.Toolbar |
editor-floating-toolbar | The toolbar inside Editor.FloatingToolbar |
editor-mark-button | Editor.MarkButton |
editor-block-button | Editor.BlockButton |
editor-heading-menu | The Editor.HeadingMenu trigger |
editor-list-menu | The Editor.ListMenu trigger |
editor-link-button | Editor.LinkButton |
editor-link-form | The URL form of Editor.LinkButton |
editor-link-input | The URL field |
editor-history-button | Editor.HistoryButton |
editor-slash-menu | The Editor.SlashMenu listbox |
editor-mention-menu | The Editor.Mentions listbox |
Accessibility
- The content is a
textboxwitharia-multiline. While a menu is open it also hasaria-expanded,aria-controlsandaria-activedescendant, and focus stays in the text. - The toolbars have
role="toolbar"and move focus with the arrow keys.Alt-F10moves focus from the text to the toolbar, and Escape in the floating toolbar returns focus to the text. - Toggle buttons use
aria-pressed. Each control's accessible name is its label, and the tooltip adds the shortcut. - Heading and list menu rows use
menuitemradiowitharia-checked. - The slash and mention menus use
listboxandoptionroles. - Checklist checkboxes are real
inputelements.