A standalone rich text editor built on Tiptap v2.
Ships as a single IIFE bundle — no framework required.
Load via <script src="editor.js"></script> and call TiptapEditor.createEditor().
Load the script, drop a container element in your HTML, then call TiptapEditor.createEditor().
<script src="https://artworx-tiptap-editor.pages.dev/editor.js"></script> <div id="editor"></div>
const editor = TiptapEditor.createEditor( document.getElementById('editor'), { content: '<p>Hello world!</p>', placeholder: 'Start writing…', onChange: (html) => console.log(html), } ) // Instance methods editor.getHTML() // → HTML string editor.setContent(html) // replace content editor.setEditable(false) // toggle read-only editor.destroy() // cleanup
Live result:
Default setup — all buttons enabled. Fires onChange on every keystroke.
HTML output
Using toolbar: ['bold', 'italic', 'underline', 'bulletList', 'orderedList', 'link'] — only the specified buttons, in that order.
Using simpleToolbar: true — hides headings, blockquote, code block, and horizontal rule. Good for comments or short-form content.
Using editable: false — toolbar is hidden, content is not editable.
Using wrapperClass: 'dark-theme' and CSS variable overrides to restyle the editor per-instance.
Override just a few --te-* CSS variables via wrapperClass to rebrand the editor. Only 6 variables changed here:
.purple-theme.tiptap-editor-wrapper { --te-accent: #605CA8; --te-accent-hover: #4e4a8f; --te-ring: #9b97d3; --te-ring-shadow: rgba(96, 92, 168, 0.15); --te-btn-active-bg: #ededf7; --te-btn-active-hover-bg: #dddcf0; }
Live result:
Pass { custom: true, name, icon, title, command } objects inside the toolbar array to inject your own buttons anywhere.
Buttons with no command render as non-interactive <span> display widgets.
When isActive returns a string, the element's content is updated live on every transaction.
const imgIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>' TiptapEditor.createEditor(el, { toolbar: [ 'bold', 'italic', 'link', { custom: true, name: 'insert-image', icon: imgIcon, title: 'Insert Image', group: 'extra', command: (editor) => { const url = window.prompt('Image URL') if (url) editor.chain().focus().insertContent( `<img src="${url}" alt="" style="max-width:100%">` ).run() }, }, { custom: true, name: 'word-count', icon: 'Words: 0', title: '', group: 'extra', className: 'tiptap-wordcount', // no command → renders as <span>, not <button> isActive: (editor) => { const text = editor.getText().trim() const count = text === '' ? 0 : text.split(/\s+/).length return `Words: ${count}` // string → updates innerHTML }, }, ], })
Live result:
The editor supports images via @tiptap/extension-image. Since every project handles uploads differently,
image insertion is a custom toolbar button. The example below supports both
file upload → base64 and URL input.
Once inserted, click any image to open the edit bubble — set alt text, title, width and height with any CSS unit (px, %, em, rem, vw…).
const imgIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>' TiptapEditor.createEditor(el, { toolbar: [ 'bold', 'italic', 'link', { custom: true, name: 'insert-image', icon: imgIcon, title: 'Insert Image', group: 'extra', command: (editor, buttonEl) => { // toggle a small popup below the button let popup = buttonEl.parentNode.querySelector('.img-popup') if (popup) { popup.remove(); return } popup = document.createElement('div') popup.className = 'img-popup' popup.innerHTML = ` <label>Upload from device</label> <input type="file" accept="image/*" /> <label>Or paste URL</label> <div style="display:flex;gap:4px"> <input type="text" placeholder="https://…" /> <button type="button">Insert</button> </div> ` // file → base64 popup.querySelector('input[type=file]').addEventListener('change', (e) => { const file = e.target.files[0] if (!file) return const reader = new FileReader() reader.onload = (ev) => { editor.chain().focus().setImage({ src: ev.target.result }).run() popup.remove() } reader.readAsDataURL(file) }) // URL insert popup.querySelector('button').addEventListener('click', () => { const url = popup.querySelector('input[type=text]').value.trim() if (url) editor.chain().focus().setImage({ src: url }).run() popup.remove() }) buttonEl.parentNode.appendChild(popup) document.addEventListener('mousedown', (ev) => { if (!popup.contains(ev.target) && ev.target !== buttonEl) popup.remove() }, { once: true, capture: true }) }, }, ], })
Live result — click the image icon to insert from file or URL:
Using fixedToolbar: true — the toolbar sticks to the top of the viewport as you scroll down the page.