UI Components Easy
Contenteditable Preview
A small contenteditable surface with sanitized plain-text preview and character count.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
/* Contenteditable Preview */
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: #121a2d;
--inset: #0e1424;
--line: #263555;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
--danger: #ff7b7b;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
padding: clamp(1rem, 4vw, 3rem);
background: radial-gradient(1100px 520px at 15% -15%, #1a2544 0%, var(--bg) 60%);
color: var(--text);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
button { font: inherit; }
.demo { width: min(720px, 100%); margin: auto; }
.editor-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 20px;
padding: clamp(1rem, 3vw, 1.75rem);
box-shadow: 0 24px 70px -30px #000;
}
.editor-head h1 { margin: 0 0 .35rem; font-size: 1.15rem; letter-spacing: -0.01em; }
.hint { margin: 0 0 1.15rem; color: var(--muted); font-size: .85rem; }
.lbl {
display: block;
margin: 1.1rem 0 .4rem;
font-size: .7rem;
text-transform: uppercase;
letter-spacing: .09em;
color: var(--muted);
}
.toolbar {
display: flex;
align-items: center;
gap: .35rem;
padding: .35rem;
background: var(--inset);
border: 1px solid var(--line);
border-radius: 12px;
}
.tb {
min-width: 36px;
height: 32px;
padding: 0 .6rem;
font-size: .85rem;
color: var(--muted);
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: background .15s, color .15s;
}
.tb:hover { background: #1b2540; color: var(--text); }
.tb:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.tb[aria-pressed="true"] {
background: #78a9ff2e;
color: var(--accent);
border-color: #78a9ff66;
}
.sep { width: 1px; height: 18px; background: var(--line); margin: 0 .3rem; }
.rich-input {
min-height: 170px;
max-height: 300px;
overflow-y: auto;
padding: .9rem 1rem;
background: var(--inset);
border: 1px solid var(--line);
border-radius: 14px;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
.rich-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px #78a9ff26; }
.rich-input.over { border-color: var(--danger); box-shadow: 0 0 0 3px #ff7b7b22; }
.rich-input p { margin: 0 0 .5rem; }
.rich-input p:last-child { margin-bottom: 0; }
.rich-input ul { margin: .5rem 0; padding-left: 1.25rem; }
.rich-input:empty::before {
content: "Start typing…";
color: #6c7c9e;
}
.meter { margin-top: .8rem; }
.counter {
margin: 0 0 .4rem;
font-size: .78rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.counter.over { color: var(--danger); }
.dot {
display: inline-block;
width: 3px; height: 3px;
margin: 0 .5rem .2rem;
border-radius: 50%;
background: var(--line);
}
.bar { height: .3rem; border-radius: 99px; background: #1b2540; overflow: hidden; }
.bar > i {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--accent), #b18bff);
transition: width .2s ease;
}
.bar.over > i { background: var(--danger); }
.card {
margin: 0;
min-height: 76px;
padding: .9rem 1rem;
background: #0a0f1e;
border: 1px dashed var(--line);
border-radius: 14px;
color: var(--muted);
font: .82rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre-wrap;
word-break: break-word;
}
.card:empty::before { content: "(empty)"; opacity: .5; }
.card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}/* Contenteditable Preview — sanitized paste, formatting state, live counters. */
(() => {
const MAX = 280;
const editor = document.querySelector("#editor");
const preview = document.querySelector("#preview");
const counter = document.querySelector("#char-count");
const charsEl = counter.querySelector("[data-chars]");
const wordsEl = counter.querySelector("[data-words]");
const maxEl = counter.querySelector("[data-max]");
const bar = document.querySelector(".bar");
const fill = bar.querySelector("[data-fill]");
const buttons = [...document.querySelectorAll(".tb[data-cmd]")];
maxEl.textContent = String(MAX);
/* ---- sanitizer: allow-list of tags, zero attributes ---------------- */
const ALLOWED = new Set(["B", "STRONG", "I", "EM", "U", "P", "BR", "UL", "OL", "LI", "DIV", "SPAN"]);
function sanitizeFragment(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
const doomed = [];
while (walker.nextNode()) doomed.push(walker.currentNode);
for (const el of doomed) {
const tag = el.tagName;
if (tag === "SCRIPT" || tag === "STYLE" || tag === "IFRAME" || tag === "OBJECT") {
el.remove();
continue;
}
// strip every attribute (kills on*, style, href/javascript:, srcset…)
for (const attr of [...el.attributes]) el.removeAttribute(attr.name);
if (!ALLOWED.has(tag)) {
// unwrap: keep the text, drop the element
el.replaceWith(...el.childNodes);
}
}
return root;
}
function sanitizeHtml(html) {
const tpl = document.createElement("template");
tpl.innerHTML = html;
return sanitizeFragment(tpl.content);
}
/* ---- plain-text extraction (block-aware) --------------------------- */
function toPlainText(node) {
let out = "";
node.childNodes.forEach((child) => {
if (child.nodeType === Node.TEXT_NODE) {
out += child.nodeValue.replace(/\s+/g, " ");
return;
}
if (child.nodeType !== Node.ELEMENT_NODE) return;
const tag = child.tagName;
if (tag === "BR") { out += "\n"; return; }
const inner = toPlainText(child);
if (tag === "LI") out += "• " + inner.trim() + "\n";
else if (tag === "P" || tag === "DIV" || tag === "UL" || tag === "OL") out += inner.replace(/\n?$/, "\n");
else out += inner;
});
return out;
}
/* ---- render -------------------------------------------------------- */
function update() {
const text = toPlainText(editor).replace(/\n{3,}/g, "\n\n").trim();
preview.textContent = text;
const chars = [...text].length;
const words = text ? text.split(/\s+/).filter(Boolean).length : 0;
charsEl.textContent = String(chars);
wordsEl.textContent = String(words);
const pct = Math.min(100, (chars / MAX) * 100);
fill.style.width = pct + "%";
const over = chars > MAX;
counter.classList.toggle("over", over);
bar.classList.toggle("over", over);
editor.classList.toggle("over", over);
}
/* ---- toolbar state -------------------------------------------------- */
function syncToolbar() {
for (const btn of buttons) {
let active = false;
try { active = document.queryCommandState(btn.dataset.cmd); } catch { /* unsupported */ }
btn.setAttribute("aria-pressed", String(active));
}
}
function run(cmd) {
editor.focus();
document.execCommand(cmd, false, null);
syncToolbar();
update();
}
buttons.forEach((btn) => {
// mousedown so the editor keeps its selection
btn.addEventListener("mousedown", (e) => { e.preventDefault(); run(btn.dataset.cmd); });
btn.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); run(btn.dataset.cmd); }
});
});
document.querySelector("#clear-editor").addEventListener("click", () => {
editor.innerHTML = "<p><br></p>";
editor.focus();
update();
syncToolbar();
});
/* ---- paste: sanitize before insertion -------------------------------- */
editor.addEventListener("paste", (e) => {
e.preventDefault();
const dt = e.clipboardData;
const html = dt.getData("text/html");
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
range.deleteContents();
let node;
if (html) {
node = sanitizeHtml(html);
} else {
node = document.createDocumentFragment();
const lines = dt.getData("text/plain").split(/\r?\n/);
lines.forEach((line, i) => {
if (i) node.appendChild(document.createElement("br"));
node.appendChild(document.createTextNode(line));
});
}
const last = node.lastChild;
range.insertNode(node);
if (last) { range.setStartAfter(last); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); }
update();
});
/* ---- drop is another injection vector -------------------------------- */
editor.addEventListener("drop", (e) => {
e.preventDefault();
const text = e.dataTransfer.getData("text/plain");
if (text) document.execCommand("insertText", false, text);
update();
});
/* ---- keyboard shortcuts + live sync ---------------------------------- */
editor.addEventListener("keydown", (e) => {
if (!(e.ctrlKey || e.metaKey)) return;
const map = { b: "bold", i: "italic", u: "underline" };
const cmd = map[e.key.toLowerCase()];
if (cmd) { e.preventDefault(); run(cmd); }
});
editor.addEventListener("input", update);
editor.addEventListener("keyup", syncToolbar);
editor.addEventListener("mouseup", syncToolbar);
document.addEventListener("selectionchange", () => {
if (editor.contains(document.getSelection()?.anchorNode || null)) syncToolbar();
});
// sanitize whatever markup shipped in the initial HTML too
const initial = sanitizeHtml(editor.innerHTML);
editor.replaceChildren(initial);
update();
syncToolbar();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contenteditable Preview</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Contenteditable Preview">
<section class="editor-card">
<header class="editor-head">
<h1>Contenteditable preview</h1>
<p class="hint">Type or paste rich text. Pasted HTML is sanitized to an allow-list, and the preview shows the plain-text value you would persist.</p>
</header>
<div class="toolbar" role="toolbar" aria-label="Text formatting" aria-controls="editor">
<button type="button" class="tb" data-cmd="bold" aria-pressed="false" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" class="tb" data-cmd="italic" aria-pressed="false" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" class="tb" data-cmd="underline" aria-pressed="false" title="Underline (Ctrl+U)"><u>U</u></button>
<span class="sep" aria-hidden="true"></span>
<button type="button" class="tb" data-cmd="insertUnorderedList" aria-pressed="false" title="Bullet list">List</button>
<button type="button" class="tb" id="clear-editor" title="Clear editor content">Clear</button>
</div>
<label class="lbl" for="editor">Editor</label>
<div
id="editor"
class="editor rich-input"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-describedby="char-count"
spellcheck="true"
><p>Write a <strong>short note</strong> here.</p></div>
<div class="meter">
<p id="char-count" class="counter" aria-live="polite">
<span data-chars>0</span> / <span data-max>280</span> chars
<span class="dot" aria-hidden="true"></span>
<span data-words>0</span> words
</p>
<div class="bar"><i data-fill></i></div>
</div>
<label class="lbl" for="preview">Sanitized plain-text preview</label>
<pre id="preview" class="card" tabindex="0" aria-live="polite"></pre>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function RichEditorContenteditable() {
const [value, setValue] = useState("");
return (
<section className="demo">
<h2>Contenteditable Preview</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>
);
}Contenteditable Preview
A small contenteditable surface with sanitized plain-text preview and character count.
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.