Sign in

Integrate everywhere

Sumaq is one ES module. Import it, point it at a container, done. It works with anything that can load JavaScript: static sites, PHP backends, React, Vue, whatever you're building. No dependencies, no vendor lock-in, and fair pricing.

Vanilla JS

The simplest setup. A container, an import, and you're writing.

<div id="editor"></div>

<script type="module">
  import Sumaq from '/path/to/sumaq.js';

  const editor = new Sumaq('#editor', {
    toolbar: ['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link']
  });
</script>

The instance gives you access to content and events:

// Read content
editor.getHTML();
editor.getText();

// Set content
editor.setHTML('<p>New content</p>');

// Listen for events
editor.on('input', (html) => {
  console.log('Content changed:', html);
});

// Switch theme
editor.setTheme('elegant');

All methods and events are documented in the API reference.

All in one file

You can put it all together in a single HTML file. The editor on top, a live preview below. As you type, the output updates in real time:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Sumaq Editor</title>
</head>
<body>
  <div id="editor"></div>
  <h2>Preview</h2>
  <output id="preview"></output>

  <script type="module">
    import Sumaq from './sumaq.js';

    const editor = new Sumaq('#editor', {
      toolbar: ['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link']
    });

    editor.on('input', (html) => {
      document.getElementById('preview').innerHTML = html;
    });
  </script>
</body>
</html>

Native Web Component

A build script generates a self-contained <sumaq-editor> custom element: no external dependencies, no build process in your project. The script lives in the editor repository under adapters/.

./adapters/build-web-component.sh ./path/to/your/project/

The result is a single file. Include it, drop in the tag, done. Attributes control the configuration: toolbar (comma-separated), content (initial HTML content).

<script src="sumaq-editor.js"></script>

<sumaq-editor
  toolbar="h2,h3,p,|,bold,italic,|,link"
  content="<p>Start writing</p>">
</sumaq-editor>

Events work like any other HTML element:

document.querySelector('sumaq-editor')
  .addEventListener('sumaq:change', (e) => {
    console.log(e.detail.html);
  });

The methods .getHTML(), .getText() and .setHTML() are available directly on the element:

const el = document.querySelector('sumaq-editor');

// Read content
el.getHTML();
el.getText();

// Set content
el.setHTML('<p>New content</p>');

The .editor property gives you access to the full Sumaq instance with all methods and events documented in the API reference:

const el = document.querySelector('sumaq-editor');

// Listen for events
el.editor.on('input', (html) => {
  console.log('Content changed:', html);
});

// Switch theme
el.editor.setTheme('elegant');

PHP CMS

Your server renders the container with existing content. JavaScript attaches Sumaq on load. On form submit, getHTML() writes the sanitized output into a hidden input.

<form method="post">
  <div id="editor"><?= $content ?></div>
  <input type="hidden" name="content" id="content-field">
  <button type="submit">Save</button>
</form>

<script type="module">
  import Sumaq from '/assets/js/sumaq.js';

  const editor = new Sumaq('#editor', {
    toolbar: ['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link', 'image']
  });

  document.querySelector('form').addEventListener('submit', () => {
    document.getElementById('content-field').value = editor.getHTML();
  });
</script>

React

A build script generates a self-contained SumaqEditor component for React: the entire editor source is embedded in the file.

./adapters/build-react.sh ./path/to/your/project/

The result is a single JSX file. Import it, set props, done:

import SumaqEditor from './SumaqEditor';

function App() {
  return (
    <SumaqEditor
      toolbar={['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link']}
      content="<p>Initial content</p>"
      onChange={(html) => console.log(html)}
      onInput={(html) => console.log(html)}
    />
  );
}

Props: toolbar (array), content (initial HTML), onChange, onInput, styleOverrides (CSS string for Shadow DOM), extensions (array).

Via a ref, .editor (Sumaq instance), .getHTML(), .getText() and .setHTML() are available:

const ref = useRef();

<SumaqEditor ref={ref} toolbar={['h2', 'h3', 'p']} />

// Later:
ref.current.getHTML();
ref.current.setHTML('<p>New content</p>');

Vue

A build script generates a self-contained SumaqEditor component for Vue 3: Composition API, <script setup>.

./adapters/build-vue.sh ./path/to/your/project/

The result is a single .vue file:

<script setup>
import SumaqEditor from './SumaqEditor.vue';

function onEditorChange(html) {
  console.log(html);
}
</script>

<template>
  <SumaqEditor
    :toolbar="['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link']"
    content="<p>Initial content</p>"
    @change="onEditorChange"
    @input="(html) => console.log(html)"
  />
</template>

Props: toolbar (array), content (string), style-overrides (CSS string), extensions (array). Events: change, input.

Via a template ref, .editor, .getHTML(), .getText() and .setHTML() are available:

<template>
  <SumaqEditor ref="editorRef" :toolbar="['h2', 'h3', 'p']" />
</template>

<script setup>
import { ref } from 'vue';
const editorRef = ref(null);

// Later:
editorRef.value.getHTML();
editorRef.value.setHTML('<p>New content</p>');
</script>

Svelte

A build script generates a self-contained SumaqEditor component for Svelte.

./adapters/build-svelte.sh ./path/to/your/project/

The result is a single .svelte file:

<script>
  import SumaqEditor from './SumaqEditor.svelte';
</script>

<SumaqEditor
  toolbar={['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link']}
  content="<p>Initial content</p>"
  on:change={(e) => console.log(e.detail)}
  on:input={(e) => console.log(e.detail)}
/>

Props: toolbar (array), content (string), styleOverrides (CSS string), extensions (array). Events: change, input (payload in e.detail).

Via bind:this, .getEditor(), .getHTML(), .getText() and .setHTML() are available:

<script>
  import SumaqEditor from './SumaqEditor.svelte';
  let editorComponent;
</script>

<SumaqEditor bind:this={editorComponent} toolbar={['h2', 'h3', 'p']} />

<button on:click={() => console.log(editorComponent.getHTML())}>
  Get HTML
</button>

Performance

Up to 5,000 paragraphs (~25,000 DOM nodes), every operation stays within the 16ms frame budget. At 10,000 paragraphs, typing remains smooth at 18ms. Formatting operations start to become noticeable. Beyond 15,000 paragraphs, editing latency crosses the perceptible threshold.

getHTML() stays fast at every scale: 50,000 paragraphs read back in 30ms.

Stress test results, measured in Chromium, loading progressively larger documents into a single Sumaq instance. "Typing" is execCommand('insertText'), "Bold" is execCommand('bold') on a selection, "getHTML()" is reading back the full document.

Paragraphs DOM nodes Typing Bold getHTML()
5003,800<1ms<1ms<1ms
1,0007,800<1ms<1ms1ms
2,0009,4003.5ms10ms2ms
3,00015,0008ms10ms3ms
5,00025,00010ms21ms6ms
10,00050,00018ms58ms9ms
20,000100,00043ms108ms11ms
50,000250,000106ms439ms30ms

The bottleneck at high node counts is execCommand and the browser's own DOM mutation cost, not Sumaq. No virtual DOM diffing, no state reconciliation overhead, just the browser doing what browsers do.


Why Shadow DOM

The Problem

Sumaq is designed to be embedded in any page: CMS backends, client websites, admin panels. Every host page brings its own CSS: resets, framework styles, component libraries, custom overrides. Without encapsulation, those styles bleed into the editor and break it.

Examples of what goes wrong without isolation:

  • A global button { background: red; } overrides all toolbar buttons
  • A CSS reset strips list markers from <ul>/<ol> in the content area
  • Framework styles (Bootstrap, Tailwind, etc.) override heading sizes, link colors, table borders
  • * { box-sizing: border-box; } or * { margin: 0; } may or may not conflict depending on the host, unpredictable

The editor can't control or predict what CSS the host page loads.

The Solution

Sumaq attaches a shadow root to a host element and renders the entire editor inside it. All CSS is embedded as a <style> element within the shadow root, resulting in:

  • Complete CSS encapsulation: host styles cannot cross the shadow boundary
  • Host page resets, frameworks, and global rules have zero effect on the editor
  • The editor's own styles have zero effect on the host page
  • Typography baseline for the content area is self-contained and predictable
  • No specificity games, no !important, no fighting the cascade

Implementation

Architecture

container (light DOM)
  └── shadowHost <div>
        └── #shadow-root (open)
              ├── <style>/* all editor CSS */</style>
              └── .sumaq-editor
                    ├── .sumaq-toolbar
                    ├── .sumaq-content [contentEditable]
                    ├── .sumaq-source <textarea>
                    ├── .sumaq-modal
                    └── .sumaq-modal (link)

Key decisions

  • Open shadow root (mode: 'open'): allows external JS to reach into the shadow DOM via shadowRoot if needed (e.g., for testing, programmatic access to the content area)
  • Intermediate host element: a plain <div> sits between the container and the shadow root, so destroy() can cleanly remove it and restore original container content
  • All CSS embedded: the external sumaq.css file is kept as documentation/fallback, but the Shadow DOM version carries all styles internally via static styles
  • Typography baseline: since no host styles reach the content area, the editor must define its own complete typographic foundation: headings, paragraphs, lists (including nested marker progression), blockquotes, inline formatting, tables, code blocks, and links

What still works through Shadow DOM

  • document.execCommand(): operates on the focused contentEditable regardless of shadow boundary
  • window.getSelection(): returns selections inside open shadow roots in all modern browsers
  • aria-pressed, role="separator", SVG <title>: accessibility tree is fully exposed through shadow DOM
  • position: fixed on modals: still positions relative to the viewport
  • selectionchange event on document: still fires for selections inside the shadow root

What needed adapting

  • document.activeElement: returns the shadow host, not the focused element inside. The selectionchange handler checks content.contains(selection.anchorNode) instead
  • destroy(): removes the shadow host element rather than the wrapper directly
  • selectionchange listener cleanup: stored as a named reference (this._onSelectionChange) so it can be properly removed on destroy

Trade-offs

  • File size: all CSS is duplicated as a string inside the JS file (~3KB). Acceptable for a self-contained component
  • Debugging: styles live in the shadow root's <style> element rather than a separate file. Browser DevTools handle this well (inspect shadow root, see computed styles)
  • No external theming: host page CSS cannot style the editor. This is the whole point, but it means customization must happen through the static styles property or a future theming API (CSS custom properties that pierce shadow boundaries)

Browser Support

Shadow DOM v1 is supported in all modern browsers since 2020. No polyfill needed.

Browser Since
Chrome53 (2016)
Firefox63 (2018)
Safari10 (2016)
Edge79 (2020, Chromium)
Chrome Android53 (2016)
Safari iOS10 (2016)
Samsung Internet6.0 (2017)