Getting started

Frameworks

Matra is a document model and a view that attaches to an element. It has no opinion about what renders around it, so every framework already works — the only question is how much sugar there is.

Four have bindings

PackageWhat you get
React @matrajs/react useEditor, useEditorState, useEditorFocus, EditorContent
Vue @matrajs/vue useEditor, EditorContent
Svelte @matrajs/svelte matra — a use: action, the editor, and a store
Solid @matrajs/solid createMatra — the editor, a ref and a signal

All four are MIT, all four are under a hundred lines, and none re-implements anything. They exist for two reasons: turning the editor's changes into a re-render is the one piece of boilerplate worth removing, and mounting twice into one element is the mistake everybody makes once. Each binding guards it.

Everything else is three calls

Create it, mount it into an element, destroy it when the element goes away.

import { createEditor, starterKit } from '@matrajs/core'

const editor = createEditor({ extensions: starterKit, content: '<p>Hello</p>' })
editor.mount(document.querySelector('#editor'))

// later
editor.destroy()

That is the whole of it. destroy() removes the listeners and the contenteditable attribute, so the element is safe to reuse — which is what a hot reload, a route change and a recycled list row all rely on.

The guard worth copying is editor.unsafe.view. It is truthy once mounted, so a component that renders twice does not attach a second view to one element. Every binding uses it and so should yours.

Svelte

An action, which is exactly the shape Svelte has for this. Stores rather than runes, so the same package works in Svelte 4 and 5.

<script>
  import { starterKit } from '@matrajs/core'
  import { matra } from '@matrajs/svelte'

  const { action, editor, state } = matra({
    extensions: starterKit,
    content: '<p>Hello</p>',
  })
</script>

<button onclick={() => editor.commands.toggleBold()}
        aria-pressed={$state.isActive('bold')}>Bold</button>

<div use:action></div>

Solid

A signal rather than an external store · Solid's reactivity is not a render loop, so everything reading state() re-runs and nothing else does.

import { starterKit } from '@matrajs/core'
import { createMatra } from '@matrajs/solid'

export function Editor() {
  const { editor, mount, state } = createMatra({
    extensions: starterKit,
    content: '<p>Hello</p>',
  })

  return (
    <>
      <button onClick={() => editor.commands.toggleBold()}
              aria-pressed={state().isActive('bold')}>Bold</button>
      <div ref={mount} />
    </>
  )
}

Angular

A directive, so the element stays in the template where it belongs. There is no @matrajs/angular package, and the reason is worth saying rather than leaving you to wonder: an Angular library has to be published through ng-packagr in Angular's partial-compilation format, or it fails in every consumer's AOT build. Shipping one that breaks in production builds would be worse than these fifteen lines, which your compiler handles correctly because it compiles them.

import { Directive, ElementRef, OnDestroy, OnInit, inject } from '@angular/core'
import { createEditor, starterKit } from '@matrajs/core'

@Directive({ selector: '[matra]', standalone: true, exportAs: 'matra' })
export class MatraDirective implements OnInit, OnDestroy {
  private host = inject(ElementRef<HTMLElement>)
  editor = createEditor({ extensions: starterKit, content: '<p>Hello</p>' })

  ngOnInit() {
    if (!this.editor.unsafe.view) this.editor.mount(this.host.nativeElement)
  }

  ngOnDestroy() {
    this.editor.destroy()
  }
}

// <div matra #ed="matra"></div>
// <button (click)="ed.editor.commands.toggleBold()">Bold</button>

Qwik, Astro, Lit, or no framework

The same three calls, wherever you can get an element and a teardown hook. In Astro or a plain page, a <script> tag is enough — every editor on this site is exactly that.

Keeping a toolbar in step

The one thing worth knowing without a binding: editor.on('change') and editor.on('selectionChange') both return an unsubscribe function, and editor.isActive('bold') reads the state from the document. That is everything a toolbar needs, in any framework:

const off = editor.on('selectionChange', () => {
  boldButton.setAttribute('aria-pressed', String(editor.isActive('bold')))
})

// on teardown
off()

Server rendering

createEditor needs no DOM. Build a document, read getHTML() or getJSON(), and send it — the view only exists once something calls mount. That is also why toMarkdown runs on a server with no jsdom in sight.

One condition: content loaded on a server must be JSON or Markdown, not an HTML string. Reading HTML is a DOM job, so content: '<p>…</p>' needs a browser · content: storedJson and content: fromMarkdown(text) do not. Writing HTML out with getHTML() works either way.

The paid packages, anywhere

@matrajs/ai, @matrajs/collab and @matrajs/versions are extensions, not components. They go in the same array and know nothing about what renders around them, in every framework on this page.

Edit this page on GitHub