UI Components Easy
Editor Toolbar
A minimal semantic toolbar for bold, italic, code, and undo with an execCommand fallback.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b0d12;
--panel: #12151d;
--panel-2: #171b25;
--line: #262c3a;
--text: #e6e9f0;
--muted: #8b93a7;
--accent: #7c9cff;
--accent-soft: rgba(124, 156, 255, 0.16);
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(900px 480px at 12% -10%, rgba(124, 156, 255, 0.14), transparent 60%),
var(--bg);
color: var(--text);
font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
button { font: inherit; }
.demo {
display: grid;
place-items: center;
padding: clamp(1rem, 4vw, 3rem);
}
.sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* editor shell */
.editor {
width: min(720px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 24px 60px -30px rgba(0, 0, 0, 0.9);
}
/* toolbar */
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 8px 10px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border-bottom: 1px solid var(--line);
}
.group { display: flex; gap: 4px; }
.sep {
width: 1px;
align-self: stretch;
margin: 4px 2px;
background: var(--line);
}
.tbtn {
display: grid;
place-items: center;
min-width: 34px;
height: 32px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: background 140ms ease, color 140ms ease, border-color 140ms ease;
}
.tbtn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.05); color: var(--text); }
.tbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tbtn[aria-pressed="true"] {
background: var(--accent-soft);
border-color: rgba(124, 156, 255, 0.45);
color: #cdd8ff;
}
.tbtn:disabled { opacity: 0.32; cursor: not-allowed; }
.glyph { font-size: 14px; line-height: 1; pointer-events: none; }
.glyph.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.hint {
margin: 0 0 0 auto;
max-width: 22ch;
font-size: 11.5px;
line-height: 1.35;
color: var(--muted);
}
/* writing surface */
.surface {
min-height: 240px;
max-height: 420px;
overflow-y: auto;
padding: 22px 24px;
font-size: 16px;
line-height: 1.75;
outline: none;
}
.surface:focus-visible { box-shadow: inset 0 0 0 2px rgba(124, 156, 255, 0.32); }
.surface p { margin: 0 0 1em; }
.surface p:last-child { margin-bottom: 0; }
.surface strong { color: #fff; }
.surface em { color: #dfe5f6; }
.surface code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.88em;
padding: 2px 6px;
border-radius: 6px;
background: rgba(124, 156, 255, 0.14);
border: 1px solid rgba(124, 156, 255, 0.24);
color: #c9d6ff;
}
.surface ::selection { background: rgba(124, 156, 255, 0.35); }
/* status bar */
.statusbar {
display: flex;
justify-content: flex-end;
padding: 8px 14px;
border-top: 1px solid var(--line);
background: var(--panel-2);
font-size: 12px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
@media (max-width: 600px) {
.hint { display: none; }
}/**
* Editor Toolbar — dependency-free contenteditable formatting toolbar.
*
* - One dispatcher drives buttons + keyboard shortcuts.
* - Inline state (bold/italic/underline/code) is derived from the caret's
* ancestor chain, so button pressed-state is always truthful.
* - History is a real snapshot stack (HTML + caret offset), not execCommand's
* opaque stack; execCommand is only used as the fast path for the three
* styles browsers still implement well, with a manual Range-based fallback.
* - Toolbar is a roving-tabindex composite widget (single tab stop, arrows move).
*/
(() => {
const surface = document.getElementById("surface");
const toolbar = document.querySelector(".toolbar");
const countEl = document.getElementById("count");
const liveEl = document.getElementById("live");
if (!surface || !toolbar) return;
const buttons = Array.from(toolbar.querySelectorAll(".tbtn"));
const byCmd = (c) => buttons.find((b) => b.dataset.cmd === c);
const TAGS = { bold: "STRONG", italic: "EM", underline: "U", code: "CODE" };
/* ---------------- history ---------------- */
const undoStack = [];
const redoStack = [];
let composing = false;
const caretOffset = () => {
const sel = document.getSelection();
if (!sel || !sel.rangeCount || !surface.contains(sel.anchorNode)) return null;
const r = sel.getRangeAt(0).cloneRange();
r.selectNodeContents(surface);
r.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset);
return r.toString().length;
};
const restoreCaret = (offset) => {
if (offset == null) return;
const walker = document.createTreeWalker(surface, NodeFilter.SHOW_TEXT);
let seen = 0;
let node;
while ((node = walker.nextNode())) {
const len = node.nodeValue.length;
if (seen + len >= offset) {
const range = document.createRange();
range.setStart(node, offset - seen);
range.collapse(true);
const sel = document.getSelection();
sel.removeAllRanges();
sel.addRange(range);
return;
}
seen += len;
}
};
const snapshot = () => ({ html: surface.innerHTML, caret: caretOffset() });
function pushHistory() {
const last = undoStack[undoStack.length - 1];
if (last && last.html === surface.innerHTML) return;
undoStack.push(snapshot());
if (undoStack.length > 120) undoStack.shift();
redoStack.length = 0;
syncHistoryButtons();
}
function travel(from, to) {
if (!from.length) return;
to.push(snapshot());
const state = from.pop();
surface.innerHTML = state.html;
restoreCaret(state.caret);
surface.focus();
syncHistoryButtons();
refresh();
}
function syncHistoryButtons() {
byCmd("undo").disabled = undoStack.length === 0;
byCmd("redo").disabled = redoStack.length === 0;
}
/* ---------------- inline formatting ---------------- */
function activeTags() {
const sel = document.getSelection();
if (!sel || !sel.rangeCount) return new Set();
let node = sel.anchorNode;
if (!node || !surface.contains(node)) return new Set();
const tags = new Set();
while (node && node !== surface) {
if (node.nodeType === 1) tags.add(node.tagName);
node = node.parentNode;
}
return tags;
}
const isActive = (cmd) => activeTags().has(TAGS[cmd]);
/** Wrap the current non-collapsed range in `tag`, or unwrap if already inside. */
function toggleWrap(tag) {
const sel = document.getSelection();
if (!sel || !sel.rangeCount || !surface.contains(sel.anchorNode)) return;
const range = sel.getRangeAt(0);
// unwrap: find the nearest matching ancestor and lift its children out
let host = sel.anchorNode;
while (host && host !== surface && !(host.nodeType === 1 && host.tagName === tag)) {
host = host.parentNode;
}
if (host && host !== surface) {
const parent = host.parentNode;
const frag = document.createDocumentFragment();
while (host.firstChild) frag.appendChild(host.firstChild);
const first = frag.firstChild;
const last = frag.lastChild;
parent.replaceChild(frag, host);
if (first && last) {
const r = document.createRange();
r.setStartBefore(first);
r.setEndAfter(last);
sel.removeAllRanges();
sel.addRange(r);
}
return;
}
if (range.collapsed) return;
const el = document.createElement(tag);
try {
el.appendChild(range.extractContents());
range.insertNode(el);
} catch {
return;
}
const r = document.createRange();
r.selectNodeContents(el);
sel.removeAllRanges();
sel.addRange(r);
}
function exec(cmd) {
if (cmd === "undo") return travel(undoStack, redoStack);
if (cmd === "redo") return travel(redoStack, undoStack);
surface.focus();
pushHistory();
if (cmd === "code") {
toggleWrap("CODE");
} else {
// fast path; fall back to manual wrapping where execCommand is missing
let ok = false;
try {
ok = document.execCommand(cmd, false, null);
} catch {
ok = false;
}
if (!ok) toggleWrap(TAGS[cmd]);
}
announce(`${cmd} ${isActive(cmd) ? "on" : "off"}`);
refresh();
}
/* ---------------- ui sync ---------------- */
function refresh() {
const tags = activeTags();
for (const btn of buttons) {
const cmd = btn.dataset.cmd;
if (!TAGS[cmd]) continue;
btn.setAttribute("aria-pressed", String(tags.has(TAGS[cmd])));
}
const words = surface.textContent.trim().split(/\s+/).filter(Boolean).length;
countEl.textContent = `${words} word${words === 1 ? "" : "s"}`;
}
let announceTimer;
function announce(msg) {
clearTimeout(announceTimer);
announceTimer = setTimeout(() => { liveEl.textContent = msg; }, 60);
}
/* ---------------- events ---------------- */
toolbar.addEventListener("mousedown", (e) => {
// keep the selection alive when a button is pressed
if (e.target.closest(".tbtn")) e.preventDefault();
});
toolbar.addEventListener("click", (e) => {
const btn = e.target.closest(".tbtn");
if (btn && !btn.disabled) exec(btn.dataset.cmd);
});
// roving tabindex
buttons.forEach((b, i) => b.setAttribute("tabindex", i === 0 ? "0" : "-1"));
toolbar.addEventListener("keydown", (e) => {
const idx = buttons.indexOf(document.activeElement);
if (idx === -1) return;
const map = { ArrowRight: 1, ArrowLeft: -1 };
let next = null;
if (e.key in map) next = (idx + map[e.key] + buttons.length) % buttons.length;
if (e.key === "Home") next = 0;
if (e.key === "End") next = buttons.length - 1;
if (next === null) return;
e.preventDefault();
buttons[idx].setAttribute("tabindex", "-1");
buttons[next].setAttribute("tabindex", "0");
buttons[next].focus();
});
surface.addEventListener("keydown", (e) => {
const mod = e.metaKey || e.ctrlKey;
if (!mod) return;
const k = e.key.toLowerCase();
const shortcuts = { b: "bold", i: "italic", u: "underline", e: "code" };
if (k === "z") {
e.preventDefault();
exec(e.shiftKey ? "redo" : "undo");
} else if (k === "y") {
e.preventDefault();
exec("redo");
} else if (shortcuts[k]) {
e.preventDefault();
exec(shortcuts[k]);
}
});
surface.addEventListener("compositionstart", () => { composing = true; });
surface.addEventListener("compositionend", () => { composing = false; pushHistory(); });
let typingTimer;
surface.addEventListener("input", () => {
if (composing) return;
clearTimeout(typingTimer);
typingTimer = setTimeout(pushHistory, 400);
refresh();
});
// paste as plain text — contenteditable HTML is untrusted
surface.addEventListener("paste", (e) => {
e.preventDefault();
pushHistory();
const text = (e.clipboardData || window.clipboardData).getData("text/plain");
const sel = document.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
refresh();
});
document.addEventListener("selectionchange", () => {
if (surface.contains(document.getSelection()?.anchorNode)) refresh();
});
syncHistoryButtons();
refresh();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Editor Toolbar</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Editor Toolbar">
<section class="editor" aria-label="Rich text editor">
<div class="toolbar" role="toolbar" aria-label="Formatting" aria-controls="surface">
<div class="group" role="group" aria-label="Inline style">
<button type="button" class="tbtn" data-cmd="bold" aria-pressed="false" title="Bold (Ctrl+B)">
<span class="glyph" style="font-weight:800">B</span><span class="sr">Bold</span>
</button>
<button type="button" class="tbtn" data-cmd="italic" aria-pressed="false" title="Italic (Ctrl+I)">
<span class="glyph" style="font-style:italic;font-family:Georgia,serif">I</span><span class="sr">Italic</span>
</button>
<button type="button" class="tbtn" data-cmd="underline" aria-pressed="false" title="Underline (Ctrl+U)">
<span class="glyph" style="text-decoration:underline">U</span><span class="sr">Underline</span>
</button>
<button type="button" class="tbtn" data-cmd="code" aria-pressed="false" title="Inline code (Ctrl+E)">
<span class="glyph mono"></></span><span class="sr">Inline code</span>
</button>
</div>
<div class="sep" role="separator" aria-orientation="vertical"></div>
<div class="group" role="group" aria-label="History">
<button type="button" class="tbtn" data-cmd="undo" title="Undo (Ctrl+Z)" disabled>
<span class="glyph">↶</span><span class="sr">Undo</span>
</button>
<button type="button" class="tbtn" data-cmd="redo" title="Redo (Ctrl+Shift+Z)" disabled>
<span class="glyph">↷</span><span class="sr">Redo</span>
</button>
</div>
<p class="hint" id="hint">Select text, then use the toolbar or Ctrl/Cmd shortcuts. Arrow keys move between buttons.</p>
</div>
<div
id="surface"
class="surface"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-label="Document body"
aria-describedby="hint"
spellcheck="false"
>
<p>Draft your release note here. Try making a phrase <strong>bold</strong>, adding <em>emphasis</em>, or marking an API name as <code>renderToolbar()</code>.</p>
<p>Every command runs through one dispatcher, so buttons and keyboard shortcuts stay in sync.</p>
</div>
<footer class="statusbar">
<span class="count" id="count">0 words</span>
<span class="sr" role="status" aria-live="polite" id="live"></span>
</footer>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function RichEditorToolbar() {
const [value, setValue] = useState("");
return (
<section className="demo">
<h2>Editor Toolbar</h2>
<div
contentEditable
suppressContentEditableWarning
onInput={(event) => setValue(event.currentTarget.textContent ?? "")}
style={{ minHeight: 140, outline: "1px solid #39527d", padding: 16 }}
/>{" "}
<p>{value.length} characters</p>
</section>
);
}Editor Toolbar
A minimal semantic toolbar for bold, italic, code, and undo with an execCommand fallback.
Support notes
These editors stay intentionally dependency-free. Treat contenteditable value as untrusted, sanitize before persistence, and replace execCommand with a model-driven editor when requirements grow.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.