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() |
|---|---|---|---|---|
| 500 | 3,800 | <1ms | <1ms | <1ms |
| 1,000 | 7,800 | <1ms | <1ms | 1ms |
| 2,000 | 9,400 | 3.5ms | 10ms | 2ms |
| 3,000 | 15,000 | 8ms | 10ms | 3ms |
| 5,000 | 25,000 | 10ms | 21ms | 6ms |
| 10,000 | 50,000 | 18ms | 58ms | 9ms |
| 20,000 | 100,000 | 43ms | 108ms | 11ms |
| 50,000 | 250,000 | 106ms | 439ms | 30ms |
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 viashadowRootif 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, sodestroy()can cleanly remove it and restore original container content - All CSS embedded: the external
sumaq.cssfile is kept as documentation/fallback, but the Shadow DOM version carries all styles internally viastatic 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 focusedcontentEditableregardless of shadow boundarywindow.getSelection(): returns selections inside open shadow roots in all modern browsersaria-pressed,role="separator", SVG<title>: accessibility tree is fully exposed through shadow DOMposition: fixedon modals: still positions relative to the viewportselectionchangeevent ondocument: still fires for selections inside the shadow root
What needed adapting
document.activeElement: returns the shadow host, not the focused element inside. Theselectionchangehandler checkscontent.contains(selection.anchorNode)insteaddestroy(): removes the shadow host element rather than the wrapper directlyselectionchangelistener 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 stylesproperty 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 |
|---|---|
| Chrome | 53 (2016) |
| Firefox | 63 (2018) |
| Safari | 10 (2016) |
| Edge | 79 (2020, Chromium) |
| Chrome Android | 53 (2016) |
| Safari iOS | 10 (2016) |
| Samsung Internet | 6.0 (2017) |