YurbaEditor
A full-featured lightweight WYSIWYG editor in a single dependency-free file - rich text in, sanitized HTML out.
<link rel="stylesheet" href="/dist/yurba-editor.min.css">
<script src="/dist/yurba-editor.min.js"></script>
This is the standalone build. On a page that loads YurbaUI use the .ui build instead, see Builds.
Point it at a <textarea> - the library hides it and keeps its value in sync, so the form submits HTML normally.
<form>
<textarea name="body"><p>Hello</p></textarea>
</form>
YurbaEditor.create({ field: 'textarea[name=body]' })
Or mount it standalone - it is a custom element, so you can also write <yurba-editor></yurba-editor> directly and any inner markup becomes the initial value.
const editor = YurbaEditor.create({ mount: '#editor', value: '<p>Start here</p>' })
console.log(editor.getHTML())
Builds
Two builds of the same editor with the same API. They differ in who draws the menus and dialogs: the editor itself, or YurbaUI.
| Build | Files | Menus and dialogs |
|---|---|---|
yurba-editor |
dist/yurba-editor.min.jsdist/yurba-editor.min.css |
Its own popups, no dependencies. Pick it for a page without YurbaUI. |
yurba-editor.ui |
dist/yurba-editor.ui.min.jsdist/yurba-editor.ui.min.css |
Toolbar menus are YurbaUI.Dropdown, the right-click and link / image / table menus are YurbaUI.ContextMenu, the link / image / video prompts and Find & replace open in a YurbaUI.Modal. Pick it for a page that already loads YurbaUI. |
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0,0">
<link rel="stylesheet" href="/dist/yurba-editor.min.css">
<script src="/dist/yurba-editor.min.js"></script>
Its CSS styles the .y-dropdown classes of its right-click menu at zero specificity, so if YurbaUI's CSS is on the page too, YurbaUI's look wins.
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0,0">
<link rel="stylesheet" href="/dist/yurba-ui.min.css">
<link rel="stylesheet" href="/dist/yurba-editor.ui.min.css">
<script src="/dist/yurba-ui.min.js"></script>
<script src="/dist/yurba-editor.ui.min.js"></script>
Load YurbaUI first. The build calls the YurbaUI global when an editor with toolbar menus is created and whenever a menu or dialog opens, and has no fallback to its own popups.
Icons are Material Symbols Rounded, so the page loads that font (any built-in icon can be replaced with the icons option, see Icons). Colors and radii follow the Yurba theme variables (--yurba-main-color, --yurba-brand-color and others) when the page defines them, see Theming. Each build also has non-minified .js and .css in dist/.
Live demo
Everything you type is sanitized and mirrored below as it would be stored.
A drop-in for a contenteditable input: toolbar: false, footer: false, inline: true - Enter is a line break, no block paragraphs. Formatting via right-click menu and shortcuts (Ctrl/⌘+B/I/U).
Creating an editor
YurbaEditor is a custom element (<yurba-editor>). Create one with the factory and it returns the element. Pass field to sync a form control, or mount to place it in a container.
const editor = YurbaEditor.create(options?)
| Option | Type | Default | Description |
|---|---|---|---|
field optional |
string | Element | - |
A <textarea>/<input> to hide and keep in sync. The editor is inserted right after it. |
mount optional |
string | Element | - |
Container to append the editor into. Omit both to get a detached element you place yourself. |
toolbar optional |
string[] | false | full set |
Toolbar tokens (see reference); '|' is a separator. false hides the toolbar entirely. |
footer optional |
boolean | true |
false hides the footer (brand link + word/char count). |
inline optional |
boolean | false |
Inline mode: Enter inserts <br>, blocks (headings/lists/quotes/tables) are flattened, images are dropped (except glyphClass ones), and markdown/block shortcuts are off - the value stays one flow of text. |
value optional |
string | target value | Initial HTML. Falls back to the textarea value / element innerHTML. |
placeholder optional |
string | 'Start writing…' |
Empty-state text. |
minHeight optional |
number | 160 |
Minimum editing height, in pixels. |
height optional |
number | - | Max height (px) before the surface scrolls. |
embedHosts optional |
string[] | YouTube / Vimeo | Allowed <iframe> hosts for video embeds. |
allowData optional |
boolean | false |
Keep data-* attributes through sanitization (off by default - they are stripped). |
allowClasses optional |
boolean | false |
Keep all class attributes (off by default - classes are filtered to the internal allowlist). |
glyphClass optional |
string | - | Images with this class act as text glyphs (e.g. emoji from a picker): the class survives sanitization, they stay in inline mode, and they can't be selected, resized or opened in the image menu. Their src may also be a data:image/ URL, such as an animated emoji's first frame drawn in the page. |
contextMenu optional |
boolean | array | built-in | Right-click / long-press text menu. false disables it (native menu shows); an array replaces its items (see Context menu). |
onChange optional |
function | - | Called with the sanitized HTML on every change. |
onCount optional |
function | - | Called with { words, chars } whenever the count changes. |
maxChars optional |
number | - | Hard character cap; typing past it is blocked and the footer shows n / max. |
uploadUrl optional |
string | - | Endpoint the editor POSTs an image to (multipart), expecting { url } back. Enables the upload UI. |
onImageUpload optional |
function | - | file => Promise<url> custom uploader; takes precedence over uploadUrl. |
uploadField optional |
string | 'file' |
Multipart field name for uploadUrl. |
uploadHeaders optional |
object | {} |
Extra request headers for uploadUrl (e.g. CSRF). |
maxImageKb optional |
number | - | Client-side size limit before upload. |
icons optional |
object | - | Override built-in icons, e.g. { bold: '<svg…>' }. Each value is an HTML string inserted as given; keys left out keep the default. Keys are listed in Icons. |
| Method | Returns | Description |
|---|---|---|
getHTML() | string | Sanitized HTML; empty string when blank. |
setHTML(html) | this | Replace the content. |
getText() | string | Plain-text content. |
getCount() | { words, chars } | Word / character count; works even with the footer hidden. |
focus() | this | Focus the editing surface. |
on(event, cb) | this | Subscribe to 'change' (html) or 'count' ({ words, chars }). |
destroy() | void | Remove the editor and restore the target. |
Two events fire: change (the sanitized HTML) on every edit, and count ({ words, chars }) when the word/char count changes. Subscribe three ways - the onChange/onCount options, the chainable on() method, or native DOM events on the element (yurba-editor.<name>, payload in event.detail).
const ed = YurbaEditor.create({ field: '#body' })
ed.on('change', html => save(html))
ed.on('count', ({ words, chars }) => label.textContent = `${words} words · ${chars} chars`)
ed.addEventListener('yurba-editor.count', e => console.log(e.detail))
Members on the YurbaEditor global (the custom-element class). They need no instance.
| Member | Description |
|---|---|
YurbaEditor.create(options?) | The factory that builds a <yurba-editor> and returns it (see Creating an editor). |
YurbaEditor.sanitize(html, opts?) | Run the same allowlist sanitizer on a string - e.g. to clean stored HTML before rendering it. opts takes embedHosts, allowData, allowClasses, and strict (false for the looser mode the editor uses by default). |
YurbaEditor.DEFAULT_TOOLBAR | The default array of toolbar tokens - read it to build a custom toolbar from the default set. |
YurbaEditor.DEFAULT_CONTEXT_MENU | The default right-click menu items - read it to build a custom menu from the default set. |
YurbaEditor.EMBED_HOSTS | The default <iframe> embed-host allowlist (YouTube / Vimeo). |
// sanitize stored HTML before printing it - no editor needed
el.innerHTML = YurbaEditor.sanitize(post.body)
// start from the default toolbar, minus the media buttons
const toolbar = YurbaEditor.DEFAULT_TOOLBAR.filter(t => !['image', 'video', 'table'].includes(t))
YurbaEditor.create({ field: '#body', toolbar })
Toolbar tokens
Pass any subset as the toolbar option to control which controls appear and in what order.
undoredoheadingbolditalicunderlinestrikelowercasecapitalizeuppercasesupsubforecolorbackcoloralignleftaligncenteralignrightalignjustifyuloloutdentindentlinkimagevideotablehrblockquotecodecodeblockclearfindsourcefullscreen
'|' renders a separator between groups.
YurbaEditor.create({
field: '#body',
toolbar: ['heading', '|', 'bold', 'italic', 'forecolor', '|', 'ul', 'ol', 'link', 'clear']
})
Icons
Every built-in icon goes through the icons option: an object of key to HTML string. Values are inserted as HTML as given, keys left out keep the default. Both builds use the same keys.
| Keys | Where | Default |
|---|---|---|
undo redo bold italic underline strike sup sub lowercase capitalize uppercase alignleft aligncenter alignright alignjustify ul ol outdent indent link image video table hr blockquote code codeblock clear find source fullscreen | Toolbar buttons, by token | Material Symbols icon |
forecolor backcolor | Text color and highlight buttons | A |
caret | Arrow of the paragraph style button | ▾ |
color-none | Remove color swatch in the color menus | ✕ |
edit open remove | Link menu | Material Symbols icon |
align-left align-center align-right align-none alt img-del | Image menu | Material Symbols icon |
row-above row-below col-left col-right cell-left cell-center cell-right merge-right merge-down split row-del col-del header grid del | Table menu | Material Symbols icon |
find-prev find-next find-close | Find & replace. The .ui build hides the close button, its dialog has its own | Material Symbols icon, ✕ for close |
select-all copy paste clear-all formatting case | Context menu items without a toolbar twin (clear-all is Clear, which empties the editor) | The item's icon |
clear bold italic underline strike code link lowercase capitalize uppercase find | Context menu items, shared with the toolbar key (clear is Clear formatting) | The item's icon |
submenu | Submenu arrow of the context menu. Standalone build only, in the .ui build YurbaUI draws it | › |
A context menu item, built-in or custom, takes its key from key, else from action: selectAll uses select-all, clear uses clear-all, clearFormat uses clear, lower and upper use lowercase and uppercase, any other action is its own key. With no entry in icons it shows its icon.
YurbaEditor.create({
field: '#body',
icons: {
bold: '<svg class="ye-ico" viewBox="0 0 24 24">…</svg>',
copy: '<i class="fa fa-copy"></i>',
caret: '<i class="fa fa-caret-down"></i>'
}
})
Image upload
By default the image button only inserts by URL. Provide uploadUrl (or onImageUpload) and it becomes an Upload image… / By URL… menu, and the editor also accepts drag-and-drop and paste of image files.
YurbaEditor.create({
field: '#body',
uploadUrl: '/admin/_editor/upload',
uploadHeaders: { 'X-CSRF-TOKEN': token },
maxImageKb: 4096
})
The endpoint receives a multipart POST (field name file, override with uploadField) and must respond { "url": "/path/to/image.jpg" }. The URL is re-validated before insertion. For custom flows (e.g. S3 presigned) use onImageUpload(file) => Promise<url> instead. Validate on the server and reject SVG.
Theming
Every color, the corner radius, the shadow and the font are --ye-* CSS variables on .ye. Override any of them on the editor element or an ancestor to reskin - no build step.
| Variable | Default (light) | Role |
|---|---|---|
--ye-surface | #ffffff | Editor background |
--ye-surface-2 | #f9fafb | Toolbar, code, cell fills |
--ye-border | #e5e7eb | Dividers, light borders |
--ye-border-strong | #d1d5db | Editor frame, inputs, table cells |
--ye-text | #1f2937 | Body text |
--ye-text-soft | #374151 | Toolbar icons, secondary text |
--ye-muted | #6b7280 | Placeholder, counts, hints |
--ye-accent | #2563eb | Links, focus, active state |
--ye-accent-ink | #1d4ed8 | Text/icon on accent fills |
--ye-accent-soft | #dbeafe | Active-button / highlight fill |
--ye-on-accent | #ffffff | Text on a solid accent fill |
--ye-hover | #0000000f | Hover fill of buttons and menu items |
--ye-fill | #00000021 | Hover fill of neutral buttons |
--ye-danger | #dc2626 | Destructive actions |
--ye-danger-soft | #fef2f2 | Destructive-action fill |
--ye-radius | 14px | Corner radius |
--ye-shadow | elevation | Popup / menu shadow |
--ye-font | page font | Editor font family (unset: inherited) |
.ye {
--ye-accent: #16a34a;
--ye-radius: 8px;
}
Dark mode remaps the color variables only - radius, shadow and font stay. It applies automatically under prefers-color-scheme: dark; opt a single editor out with the ye--light class, or force dark anywhere with ye--dark. Toggle it live with the switch in the header above.
Security
The value is HTML - render it as such, and re-sanitize on the server. The browser is never the only gate.
A tag allowlist, a per-tag attribute allowlist, style filtered to a safe property set, javascript:/data: URLs rejected, and <iframe> restricted to the configured embed hosts. YurbaEditor.sanitize() applies the exact same rules.