TAEditor
A zero-dependency, CDN-droppable WYSIWYG HTML editor. Inspired by CKEditor / TinyMCE, but small enough to read in one sitting.
- ~29 KB minified JS, ~4 KB minified CSS.
- No runtime dependencies. KaTeX is loaded by you, only if you use the math button.
- Works with Bootstrap, Tailwind, jQuery, Alpine, Vue, and HTMX out of the box — see Framework compatibility.
- Image upload: base64 by default, configurable endpoint or async handler.
- Math equations via KaTeX, fully round-trippable (the LaTeX source is stored on the element).
- Code-editor-style Tab indent/outdent inside
<pre>blocks. - XSS-safe by default — built-in sanitizer strips
<script>, inlineon*handlers, andjavascript:URLs on paste and on load.
30-second start
Two <script> tags and one line of JavaScript. That's it.
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="https://taeditor.trogon.info/dist/taeditor.min.css">
</head>
<body>
<textarea id="editor" name="content"></textarea>
<script src="https://taeditor.trogon.info/dist/taeditor.min.js"></script>
<script>TAEditor.create('#editor');</script>
</body>
</html>
Wrap the <textarea> in a <form> and the editor auto-syncs its HTML back into the textarea on submit — your server-side form handler doesn't need to change.
Recipes
Minimal toolbar (comment box)
Strip the toolbar down for short-form input like comments or chat replies.
<textarea id="comment" name="comment"></textarea>
<script>
TAEditor.create('#comment', {
toolbar: ['bold', 'italic', 'link', '|', 'bulletList', 'orderedList'],
height: 120,
placeholder: 'Write a comment…'
});
</script>
Full editor with math
For long-form content like docs, articles, or wikis. Add KaTeX's CDN tags whenever you enable the math button.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js"></script>
<link rel="stylesheet" href="https://taeditor.trogon.info/dist/taeditor.min.css">
<script src="https://taeditor.trogon.info/dist/taeditor.min.js"></script>
<textarea id="doc" name="doc"></textarea>
<script>
TAEditor.create('#doc', {
height: 500,
math: { katex: window.katex }
});
</script>
Click the fx button, type \int_0^\infty e^{-x^2}\,dx, hit Insert. Clicking the rendered equation re-opens the dialog with the original LaTeX — no source loss.
Multiple forms on one page
Each <textarea> becomes its own independent editor and auto-syncs to whichever <form> wraps it — so several forms with rich-text bodies on one page all just work. Two ways to wire them up:
Option A — one call per textarea (by ID). Useful when each editor has different options.
<!-- Form 1: long-form post -->
<form action="/posts" method="POST">
<input name="title" placeholder="Title" required>
<textarea id="postBody" name="body"></textarea>
<button type="submit">Publish post</button>
</form>
<!-- Form 2: a quick comment, somewhere else on the same page -->
<form action="/comments" method="POST">
<textarea id="commentBody" name="body"></textarea>
<button type="submit">Send comment</button>
</form>
<script>
TAEditor.create('#postBody', {
height: 400,
toolbar: ['bold','italic','underline','|','h2','h3','|',
'blockquote','pre','|','bulletList','orderedList','|',
'link','image','table']
});
TAEditor.create('#commentBody', {
height: 120,
toolbar: ['bold','italic','link','|','bulletList','orderedList'],
placeholder: 'Write a comment…'
});
</script>
Option B — one call by class selector. Useful when every editor uses the same config. Returns an array of instances.
<!-- Add the same class to every textarea you want to upgrade. -->
<form action="/posts" method="POST">
<textarea class="rich" name="body"></textarea>
<button type="submit">Publish</button>
</form>
<form action="/notes" method="POST">
<textarea class="rich" name="body"></textarea>
<button type="submit">Save note</button>
</form>
<form action="/replies" method="POST">
<textarea class="rich" name="body"></textarea>
<button type="submit">Reply</button>
</form>
<script>
const editors = TAEditor.create('.rich', { height: 220 });
console.log(editors.length); // 3
</script>
Option C — upgrade every <textarea> on the page. Shortest possible setup; works when you want all textareas to be rich-text.
<form action="/posts" method="POST">
<textarea name="body"></textarea>
<button type="submit">Publish</button>
</form>
<form action="/notes" method="POST">
<textarea name="body"></textarea>
<button type="submit">Save note</button>
</form>
<script>
TAEditor.create('textarea'); // every <textarea> in the document
</script>
In all three options:
- The textarea inside each form gets synced on its own form's
submit— there's no cross-form interference. - Server-side, each form POSTs the same field name (
body) it always did; the editor never changes the request shape. - You can mix and match on the same page — a class-selector batch init for the "common" editors plus a one-off
create('#special', { … })for one that needs a custom toolbar.
Image upload to your server
Point at any endpoint that accepts multipart/form-data and replies with { "url": "…" }.
TAEditor.create('#editor', {
image: {
uploadUrl: '/api/upload',
maxSize: 5 * 1024 * 1024 // 5 MB cap
}
});
See Server-side upload endpoint for a minimal Node/Express example.
Image upload via async handler
For S3 signed URLs, custom auth headers, image processing, or any non-standard upload flow. Return the public URL string.
TAEditor.create('#editor', {
image: {
uploadHandler: async (file) => {
// 1. Ask your backend for a signed upload URL.
const { signedUrl, publicUrl } = await fetch('/api/sign-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: file.name, type: file.type })
}).then(r => r.json());
// 2. PUT the file directly to S3 (or whatever).
await fetch(signedUrl, { method: 'PUT', body: file });
// 3. Return the URL the editor should put in <img src>.
return publicUrl;
}
}
});
If you set neither uploadUrl nor uploadHandler, images become data: URIs embedded in the HTML — fine for prototypes, bad for large or many images.
Loading and saving content
const editor = TAEditor.create('#editor');
// Set the initial HTML programmatically (in addition to or instead of textarea value).
editor.setData('<h2>Welcome</h2><p>Edit me.</p>');
// Read it back any time.
const html = editor.getData();
// Save to your API.
await fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editor.getData() })
});
setData() sanitizes the input — <script> tags, inline event handlers, and javascript: URLs are stripped before they reach the DOM.
Listening for changes (autosave)
const editor = TAEditor.create('#editor');
let timer;
editor.on('change', (html) => {
clearTimeout(timer);
timer = setTimeout(() => {
fetch('/api/autosave', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: html })
});
}, 800);
});
editor.on('ready', () => console.log('Editor mounted'));
editor.on('blur', () => console.log('Editor lost focus'));
Inside a Bootstrap form
The editor's CSS is namespaced under .ta-editor so it doesn't fight with Bootstrap's utilities.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://taeditor.trogon.info/dist/taeditor.min.css">
<script src="https://taeditor.trogon.info/dist/taeditor.min.js"></script>
<div class="container py-4" style="max-width: 720px;">
<form>
<div class="mb-3">
<label class="form-label">Title</label>
<input type="text" class="form-control" name="title">
</div>
<div class="mb-3">
<label class="form-label">Body</label>
<textarea id="body" name="body" class="form-control"></textarea>
</div>
<button type="submit" class="btn btn-primary">Save draft</button>
</form>
</div>
<script>TAEditor.create('#body', { height: 320 });</script>
Inside a Tailwind layout
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://taeditor.trogon.info/dist/taeditor.min.css">
<script src="https://taeditor.trogon.info/dist/taeditor.min.js"></script>
<div class="max-w-2xl mx-auto py-8 px-4">
<h1 class="text-xl font-semibold mb-3">New post</h1>
<textarea id="post" name="post"></textarea>
</div>
<script>TAEditor.create('#post', { height: 360 });</script>
Mount on a div (no textarea)
You don't have to use a textarea. Any element works — TAEditor takes over its inner HTML as the initial content.
<div id="editor"><p>Pre-loaded content.</p></div>
<script>
const ed = TAEditor.create('#editor');
document.querySelector('#save').addEventListener('click', () => {
console.log(ed.getData());
});
</script>
Note that in this mode there is no form auto-sync — you read getData() yourself.
Framework compatibility
TAEditor is vanilla JS with no global side-effects beyond window.TAEditor, all CSS is namespaced under .ta-*, and the editor opts itself out of common reactive scanners. It coexists cleanly with the major libraries:
| Library | Status | Notes |
|---|---|---|
| jQuery 3 | ✓ | The textarea is synced on every keystroke, so $('#form').serialize() and $.ajax({ data: $(form).serialize() }) return up-to-date content from any click — you don't need to wait for the submit event. |
| Bootstrap 5 | ✓ | TAEditor dialogs use z-index: 9999, sitting above Bootstrap modals (1055). You can put a TAEditor inside a Bootstrap modal and open the link/image dialog on top. |
| Tailwind 3 | ✓ | Editor CSS overrides Tailwind preflight where it would damage editor content (list bullets, inline images). |
| Alpine.js 3 | ✓ | Editor root carries x-ignore, so Alpine skips the contenteditable subtree even if users type x-data-like strings. |
| Vue 3 | ✓ | Editor root carries v-pre, so Vue's template compiler skips the subtree. Mount with v-once (or :key="...") so Vue doesn't re-render the host element. |
| HTMX | ✓ | When hx-swap removes a region containing an editor, the editor auto-detects the disconnect via MutationObserver and tears down its document listeners. No need for htmx:beforeSwap cleanup hooks. |
| React | ⚠ | Mount once via useEffect + useRef and never render children inside the host element — React's virtual DOM will fight contenteditable. Call editor.destroy() in the effect's cleanup function. |
See examples/compat-test.html for a single page that loads all of the above together.
Init API
TAEditor.create(target, options?) accepts any CSS selector string — anything document.querySelectorAll() understands — or a raw element. The shape of the return depends on how many elements matched:
| Target | What it does | Returns |
|---|---|---|
'#postBody' (ID selector) |
upgrades that one element | single instance |
'.editor' (class selector) |
upgrades every matching element | array of instances |
'textarea' (tag selector) |
upgrades every <textarea> on the page |
array of instances |
'form.rich textarea' (any complex selector) |
whatever querySelectorAll returns |
array of instances (or single if 1) |
document.getElementById('x') (raw element) |
upgrades that element | single instance |
A NodeList / Element[] |
upgrades each | array of instances |
// All of these are valid, all use the same one entry point.
TAEditor.create('#postBody'); // by ID
TAEditor.create('.editor'); // by class — every match
TAEditor.create('textarea'); // every textarea on the page
TAEditor.create('form.post textarea[name=body]'); // any CSS selector
TAEditor.create(document.querySelector('#x')); // a raw element
TAEditor.create(document.querySelectorAll('.x')); // a NodeList
If the target is a <textarea>, the textarea is hidden, the editor mounts next to it, and the value is synced back on every change and on the surrounding <form>'s submit. Otherwise the editor mounts inside the target and replaces its inner HTML.
All options
TAEditor.create('#editor', {
toolbar: [
'bold', 'italic', 'underline', '|',
'paragraph', 'h1', 'h2', 'h3', '|',
'blockquote', 'pre', 'hr', '|',
'bulletList', 'orderedList', '|',
'link', 'image', 'table', 'math'
],
height: 400, // min-height in px
placeholder: 'Start typing…',
sanitize: true, // strip <script>, on* attrs, etc.
image: {
uploadUrl: '/api/upload', // POST multipart, expects { url }
uploadHandler: async (file) => '…', // overrides uploadUrl when provided
maxSize: 5 * 1024 * 1024,
accept: 'image/*'
},
math: { katex: window.katex } // peer dep, pass it in
});
Instance API
const editor = TAEditor.create('#editor');
editor.getData(); // → HTML string (sanitized)
editor.setData(html); // also sanitizes
editor.destroy(); // detach; the original <textarea> is restored
editor.on('change', html => {});
editor.on('focus', () => {});
editor.on('blur', () => {});
editor.on('ready', () => {});
editor.off('change', fn);
Toolbar buttons
Pass an array of these names to toolbar, with '|' as a separator. Reorder, remove, or duplicate freely.
| Name | What it does |
|---|---|
bold, italic, underline |
Inline formatting |
paragraph |
Convert current block to <p> |
h1, h2, h3, h4, h5, h6 |
Convert current block to heading |
blockquote |
Convert current block to <blockquote> |
pre |
Convert current block to <pre><code> (code-editor-style Tab) |
hr |
Insert a horizontal rule |
bulletList, orderedList |
Toggle list |
link |
Insert or edit a link |
image |
Insert an image (file upload or URL) |
table |
Insert an N×M table; cells get a context menu for add/remove row/col |
math |
Insert / edit a LaTeX equation (requires KaTeX) |
Keyboard shortcuts
| Key | Effect |
|---|---|
| Ctrl/Cmd+B | Bold |
| Ctrl/Cmd+I | Italic |
| Ctrl/Cmd+U | Underline |
| Tab (in a list) | Nest the current item |
| Shift+Tab (in a list) | Outdent the current item |
| Tab (in a table cell) | Move to next cell (creates a new row at the end) |
| Shift+Tab (in a table cell) | Move to previous cell |
Tab (in <pre>) |
Insert \t, or indent every selected line |
Shift+Tab (in <pre>) |
Outdent current line, or every selected line |
| Tab (elsewhere) | Insert four non-breaking spaces |
| Enter (on empty nested list item) | Outdent one level |
| Enter (on empty root list item) | Exit list to paragraph |
Enter twice (on blank line at end of <pre>) |
Exit code block to paragraph below |
Server-side upload endpoint
The shape image.uploadUrl expects is intentionally tiny — POST multipart/form-data with one field named file, return JSON { "url": "…" }. Here's a 20-line Node + Express example:
const express = require('express');
const multer = require('multer');
const path = require('path');
const upload = multer({ dest: 'public/uploads/' });
const app = express();
app.use(express.static('public'));
app.post('/api/upload', upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no file' });
res.json({ url: '/uploads/' + path.basename(req.file.path) });
});
app.listen(3000);
Any framework works the same way — Flask, Rails, Laravel, ASP.NET, Go, etc. Read the file field, store it, return its URL.
Browser support
Modern evergreen browsers (Chrome, Firefox, Safari, Edge). Uses native Selection/Range APIs and ES2020 syntax. No IE11 support.