Skip to content

Headless editing

'use client'


import {
  useMarkdownEditor,
  MarkdownEditorProvider,
  MarkdownEditorContent,
} from '@react-markdown-kit/editor'


export function Composer({ value, onChange }) {
  const editor = useMarkdownEditor({ value, onChange })


  return (
    <MarkdownEditorProvider editor={editor}>
      <MyToolbar />
      <MarkdownEditorContent aria-label="Message" />
      <MyStatusBar />
    </MarkdownEditorProvider>
  )
}

<MarkdownEditor> is a thin component built from these same pieces. The default chrome doesn't use anything private, so anything it does you can do too.

Use this when the editor has to sit inside your own layout, for example in a CMS, a report builder, a chat composer or a panel in a bigger admin page.

The four exports#

useMarkdownEditor(options) owns the value, the mode, the document identity and the parsing. It renders nothing and returns an editor instance.

It takes the same options as <MarkdownEditor> minus the chrome: value, defaultValue, onChange, mode, defaultMode, onModeChange, preset, extensions, readOnly, documentKey, onUploadImage and onDiagnostics.

MarkdownEditorProvider puts the instance in context.

MarkdownEditorContent renders the surface for the active mode. It accepts aria-label, aria-labelledby and aria-describedby, plus an editor prop if you'd rather skip the provider.

useMarkdownEditorContext() reads the instance from context. Outside a provider it throws a named error instead of returning null. useOptionalMarkdownEditorContext() returns null there instead, for chrome that takes an editor prop as well, the way <MarkdownEditorContent> does.

The editor instance#

interface MarkdownEditorInstance {
  getMarkdown(): string
  getDocument(): MarkdownDocument
  focus(): void
  blur(): void
  undo(): void
  redo(): void
  setMode(mode: MarkdownEditorMode): void
  readonly mode: MarkdownEditorMode
  readonly commands: MarkdownEditorCommands
  readonly diagnostics: readonly MarkdownDiagnostic[]
  readonly readOnly: boolean
  getNativeEditor(): unknown
}

getMarkdown() returns the current source. getDocument() returns the compiled MarkdownDocument, which you can pass straight to the renderer.

Commands#

Every button in the default toolbar calls one of these.

interface MarkdownEditorCommands {
  toggleMark(mark: 'strong' | 'emphasis' | 'delete' | 'code'): void
  setBlockType(type: 'paragraph' | 'heading1' | 'blockquote' | 'codeBlock' | ...): void
  toggleBulletList(): void
  toggleOrderedList(): void
  toggleTaskList(): void
  insertLink(url: string, options?: { title?: string; text?: string }): void
  removeLink(): void
  insertImage(image: { src: string; alt?: string; title?: string }): void
  insertThematicBreak(): void
  insertMarkdown(source: string): void
  setMarkdown(source: string): void
  uploadImage(file: File): Promise<void>
}

insertMarkdown parses with the active preset and inserts at the selection. That's what you'd use for a slash command or a snippet menu that inserts a table.

setMarkdown replaces the whole document.

A custom toolbar#

'use client'


import { useMarkdownEditorContext } from '@react-markdown-kit/editor'
import { Button, ButtonGroup } from './my-design-system'


function MyToolbar() {
  const editor = useMarkdownEditorContext()
  const { commands } = editor


  return (
    <ButtonGroup aria-label="Formatting">
      <Button onClick={() => commands.toggleMark('strong')}>Bold</Button>
      <Button onClick={() => commands.toggleMark('emphasis')}>Italic</Button>
      <Button onClick={() => commands.setBlockType('heading2')}>Heading</Button>
      <Button onClick={() => commands.toggleBulletList()}>List</Button>
      <Button onClick={() => commands.insertMarkdown('\n| a | b |\n| - | - |\n| 1 | 2 |\n')}>
        Table
      </Button>
      <Button onClick={() => editor.undo()}>Undo</Button>
      <Button onClick={() => editor.setMode('source')}>Markdown</Button>
    </ButtonGroup>
  )
}

None of these components come from this package, so use whatever component library you already have.

Loading editor

Active state#

A custom toolbar built on commands can run every command, but you don't get active-button highlighting with it.

If you want highlighting without writing your own selection listener, use the toolbar render prop on <MarkdownEditor>. Each item comes with active, disabled, label, icon, group and run(), and you supply the markup.

<MarkdownEditor
  value={value}
  onChange={setValue}
  toolbar={(items) =>
    items.map((item) => (
      <Button key={item.id} onClick={item.run} pressed={item.active} disabled={item.disabled}>
        {item.icon}
        {item.label}
      </Button>
    ))
  }
/>

You keep your own markup and only lose the default container.

A status bar#

function MyStatusBar() {
  const editor = useMarkdownEditorContext()
  return (
    <footer>
      <span>{editor.getMarkdown().length} characters</span>
      {editor.diagnostics.length > 0 ? <span>{editor.diagnostics.length} notes</span> : null}
    </footer>
  )
}

getDocument() gives you the tree, so building a table of contents is mostly a walk over document.tree.children looking for heading nodes.

No Lexical types required#

Lexical is what the rich surface uses internally, but it isn't part of the public API.

Nothing this package exports needs a Lexical type. You can type every prop, handler and the instance itself without lexical in your type path.

A test asserts that src/index.ts and src/types.ts import nothing from lexical or @lexical/* (packages/editor/tests/styling.test.tsx). The opt-in @react-markdown-kit/editor/lexical entry is the exception, for extension authors.

There's one escape hatch, on purpose:

const native = editor.getNativeEditor()

It returns unknown so Lexical doesn't leak into your types by accident. You cast it yourself.

getNativeEditor() has weaker stability guarantees than the rest of the instance. We might replace the editing engine in a future major version, and that wouldn't count as a breaking change to the rest of the API.

Only use it for things the public commands can't do, and expect to revisit that code on a major upgrade. If you find you need it a lot, please report it as a gap in commands.

Headless without React#

There's also a lower level with no React and no DOM.

import { createMarkdownBridge } from '@react-markdown-kit/editor'


const bridge = createMarkdownBridge({ preset: appMarkdown, headless: true })
bridge.load(source)
const out = bridge.getMarkdown()

This is the same pipeline the React editor runs, which makes it handy on a server, in a worker or in tests. A round trip that passes here also holds in the browser, and that's how the preservation gate is tested. See Round-trip preservation.

The ready-made component and its props are covered in Editor basics.

Styling the surface and your own chrome is covered in Styling.

The three modes and a round-trip check you can run on your own Markdown: Editor demo.

Last updated on