UI Components Medium
Slash-command Menu
Open a keyboard-aware slash command menu inside a contenteditable editor and insert blocks.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: #121a2d;
--panel-2: #18233b;
--line: #2b3d62;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
padding: clamp(1rem, 4vw, 3rem);
background: radial-gradient(120% 90% at 50% 0%, #141d36 0%, var(--bg) 60%);
color: var(--text);
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.demo {
width: min(760px, 100%);
margin: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0006;
}
.demo__head h1 { margin: 0 0 .4rem; font-size: 1.35rem; letter-spacing: -.02em; }
.hint { margin: 0 0 1.4rem; color: var(--muted); font-size: .88rem; }
kbd {
display: inline-block;
padding: .05em .4em;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: .35rem;
background: var(--panel-2);
font: inherit;
font-size: .82em;
}
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.editor-shell { position: relative; }
.editor {
min-height: 16rem;
padding: 1.3rem 1.4rem;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel-2);
outline: none;
transition: border-color .18s ease, box-shadow .18s ease;
}
.editor:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgb(120 169 255 / .18); }
.editor > * { margin: 0 0 .85rem; }
.editor > *:last-child { margin-bottom: 0; }
.editor h2 { font-size: 1.25rem; letter-spacing: -.02em; }
.editor h3 { font-size: 1.05rem; color: #cfe0ff; }
.editor ul, .editor ol { padding-left: 1.3rem; }
.editor li { margin: .2rem 0; }
.editor blockquote {
padding: .3rem 0 .3rem 1rem;
border-left: 3px solid var(--accent);
color: var(--muted);
font-style: italic;
}
.editor pre {
padding: .9rem 1rem;
border-radius: 10px;
background: #080d18;
border: 1px solid var(--line);
font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
overflow-x: auto;
}
.editor hr { border: 0; border-top: 1px solid var(--line); }
.editor .todo { color: var(--muted); }
.menu {
position: absolute;
z-index: 5;
width: 17rem;
max-height: 15rem;
overflow-y: auto;
padding: .4rem;
border: 1px solid #49618e;
border-radius: 12px;
background: #101a30;
box-shadow: 0 18px 40px #000a;
animation: menu-pop .12s ease-out;
}
@keyframes menu-pop { from { opacity: 0; transform: translateY(-4px); } }
.menu__hint {
margin: .2rem .5rem .35rem;
font-size: .68rem;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
}
.menu__list { margin: 0; padding: 0; list-style: none; }
.menu__empty { margin: .5rem; color: var(--muted); font-size: .85rem; }
.item {
display: flex;
align-items: center;
gap: .65rem;
padding: .45rem .5rem;
border-radius: 9px;
cursor: pointer;
}
.item[aria-selected="true"] { background: rgb(120 169 255 / .17); }
.item__icon {
flex: none;
width: 1.9rem; height: 1.9rem;
display: grid; place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: #0b1223;
font: 600 12px/1 ui-monospace, Menlo, monospace;
color: var(--accent);
}
.item__label { font-size: .9rem; }
.item__desc { display: block; font-size: .74rem; color: var(--muted); }
.status { margin: 1rem 0 0; font-size: .8rem; color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
.menu { animation: none; }
.editor { transition: none; }
}/**
* Slash-command menu for a contenteditable editor.
* - Detects a "/query" token immediately before the caret (text node only).
* - Anchors a listbox at the caret, filters commands, supports full keyboard nav.
* - Inserting replaces the "/query" token with a real block element.
*/
(() => {
const editor = document.getElementById("slash-editor");
const menu = document.getElementById("slash-menu");
const list = document.getElementById("slash-list");
const empty = document.getElementById("slash-empty");
const status = document.getElementById("slash-status");
if (!editor || !menu) return;
const COMMANDS = [
{ id: "h2", icon: "H2", label: "Heading", desc: "Section title", keywords: "heading title h2", build: () => block("h2", "Heading") },
{ id: "h3", icon: "H3", label: "Subheading", desc: "Smaller title", keywords: "subheading h3", build: () => block("h3", "Subheading") },
{ id: "p", icon: "¶", label: "Text", desc: "Plain paragraph", keywords: "text paragraph body", build: () => block("p", "Text") },
{ id: "ul", icon: "•", label: "Bulleted list", desc: "Unordered items", keywords: "bullet list unordered ul", build: () => listBlock("ul") },
{ id: "ol", icon: "1.", label: "Numbered list", desc: "Ordered items", keywords: "numbered ordered list ol", build: () => listBlock("ol") },
{ id: "todo", icon: "☑", label: "To-do", desc: "Checkbox item", keywords: "todo task checkbox", build: todoBlock },
{ id: "quote", icon: "❝", label: "Quote", desc: "Blockquote", keywords: "quote blockquote citation", build: () => block("blockquote", "Quote") },
{ id: "code", icon: "{}", label: "Code block", desc: "Monospaced snippet", keywords: "code pre snippet", build: codeBlock },
{ id: "hr", icon: "—", label: "Divider", desc: "Horizontal rule", keywords: "divider rule separator hr", build: () => document.createElement("hr") },
];
function block(tag, text) {
const el = document.createElement(tag);
el.textContent = text;
return el;
}
function listBlock(tag) {
const el = document.createElement(tag);
const li = document.createElement("li");
li.textContent = "List item";
el.appendChild(li);
return el;
}
function todoBlock() {
const p = document.createElement("p");
p.className = "todo";
p.textContent = "☐ To-do item";
return p;
}
function codeBlock() {
const pre = document.createElement("pre");
const code = document.createElement("code");
code.textContent = "const editor = document.querySelector('#slash-editor');";
pre.appendChild(code);
return pre;
}
let open = false;
let matches = [];
let active = 0;
let token = null; // { node, start, end, query }
/** Read the "/query" token immediately before the caret, or null. */
function readToken() {
const sel = window.getSelection();
if (!sel || !sel.isCollapsed || sel.rangeCount === 0) return null;
const node = sel.anchorNode;
if (!node || node.nodeType !== Node.TEXT_NODE || !editor.contains(node)) return null;
const offset = sel.anchorOffset;
const before = node.data.slice(0, offset);
const slash = before.lastIndexOf("/");
if (slash === -1) return null;
// The slash must start a word and the query must not contain whitespace.
const prev = slash === 0 ? " " : before[slash - 1];
if (!/\s| /.test(prev) && slash !== 0) return null;
const query = before.slice(slash + 1);
if (/[\s ]/.test(query)) return null;
return { node, start: slash, end: offset, query };
}
function filter(query) {
const q = query.trim().toLowerCase();
if (!q) return COMMANDS.slice();
return COMMANDS.filter((c) => (c.label + " " + c.keywords).toLowerCase().includes(q));
}
function render() {
list.textContent = "";
matches.forEach((cmd, i) => {
const li = document.createElement("li");
li.className = "item";
li.id = "slash-opt-" + cmd.id;
li.setAttribute("role", "option");
li.setAttribute("aria-selected", String(i === active));
li.innerHTML =
'<span class="item__icon" aria-hidden="true"></span>' +
'<span><span class="item__label"></span><span class="item__desc"></span></span>';
li.querySelector(".item__icon").textContent = cmd.icon;
li.querySelector(".item__label").textContent = cmd.label;
li.querySelector(".item__desc").textContent = cmd.desc;
li.addEventListener("mousemove", () => {
if (active === i) return;
active = i;
render();
});
li.addEventListener("mousedown", (e) => {
e.preventDefault(); // keep the caret
insert(cmd);
});
list.appendChild(li);
});
empty.hidden = matches.length > 0;
const current = matches[active];
editor.setAttribute("aria-activedescendant", current ? "slash-opt-" + current.id : "");
const el = list.children[active];
if (el) el.scrollIntoView({ block: "nearest" });
}
function position() {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0).cloneRange();
range.collapse(true);
let rect = range.getBoundingClientRect();
if (!rect || (!rect.width && !rect.height)) {
// Collapsed range in an empty spot: measure with a temporary marker.
const marker = document.createElement("span");
marker.textContent = "";
range.insertNode(marker);
rect = marker.getBoundingClientRect();
marker.remove();
}
const host = editor.parentElement.getBoundingClientRect();
const width = 272;
let left = rect.left - host.left;
left = Math.max(0, Math.min(left, host.width - width));
let top = rect.bottom - host.top + 8;
// Flip above if there is not enough room below the viewport.
if (rect.bottom + 250 > window.innerHeight) top = rect.top - host.top - 250;
menu.style.left = left + "px";
menu.style.top = Math.max(0, top) + "px";
}
function show() {
if (!open) {
open = true;
menu.hidden = false;
editor.setAttribute("aria-expanded", "true");
}
render();
position();
status.textContent = matches.length
? matches.length + " block" + (matches.length === 1 ? "" : "s") + " available."
: "No matching block.";
}
function close(reason) {
if (!open) return;
open = false;
menu.hidden = true;
editor.setAttribute("aria-expanded", "false");
editor.setAttribute("aria-activedescendant", "");
token = null;
status.textContent = reason || "Menu closed.";
}
function insert(cmd) {
if (!token) return close();
const { node, start, end } = token;
// Remove the "/query" text.
node.deleteData(start, end - start);
const el = cmd.build();
const host = node.parentElement.closest("#slash-editor > *") || node.parentElement;
if (host && host.parentElement === editor) {
host.insertAdjacentElement("afterend", el);
if (!host.textContent.trim()) host.remove();
} else {
editor.appendChild(el);
}
// Put the caret at the end of the inserted block's text.
const target = el.querySelector("li, code") || el;
const range = document.createRange();
if (target.firstChild) range.setStart(target.firstChild, target.firstChild.length ?? 0);
else range.selectNodeContents(target);
range.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
editor.focus();
close("Inserted " + cmd.label + ".");
}
function sync() {
token = readToken();
if (!token) return close();
const next = filter(token.query);
const prevId = matches[active] && matches[active].id;
matches = next;
if (!matches.length) {
active = 0;
show();
return;
}
const keep = matches.findIndex((c) => c.id === prevId);
active = keep >= 0 ? keep : 0;
show();
}
editor.addEventListener("input", sync);
editor.addEventListener("click", () => { if (open) sync(); });
editor.addEventListener("blur", () => close());
editor.addEventListener("keydown", (e) => {
if (!open) return;
if (e.key === "Escape") { e.preventDefault(); close("Dismissed."); return; }
if (!matches.length) return;
if (e.key === "ArrowDown") {
e.preventDefault();
active = (active + 1) % matches.length;
render();
} else if (e.key === "ArrowUp") {
e.preventDefault();
active = (active - 1 + matches.length) % matches.length;
render();
} else if (e.key === "Home") {
e.preventDefault(); active = 0; render();
} else if (e.key === "End") {
e.preventDefault(); active = matches.length - 1; render();
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
insert(matches[active]);
}
});
// Keyboard-only affordance: arrow keys / typing update the token after selection moves.
document.addEventListener("selectionchange", () => {
if (open && document.activeElement === editor) {
const t = readToken();
if (!t) close();
}
});
window.addEventListener("resize", () => { if (open) position(); });
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Slash-command Menu</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Slash-command Menu">
<header class="demo__head">
<h1>Slash commands</h1>
<p class="hint">
Type <kbd>/</kbd> in the editor to open the block menu. Keep typing to filter,
move with <kbd>↑</kbd> <kbd>↓</kbd>, insert with <kbd>Enter</kbd> or
<kbd>Tab</kbd>, dismiss with <kbd>Esc</kbd>.
</p>
</header>
<section class="editor-shell">
<span id="editor-label" class="sr-only">Document body</span>
<div
id="slash-editor"
class="editor rich-input"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-labelledby="editor-label"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="slash-menu"
aria-activedescendant=""
spellcheck="false"
><p>Welcome to the block editor.</p><p>Put your cursor here and press / to summon commands.</p></div>
<div id="slash-menu" class="menu" role="listbox" aria-label="Insert block" hidden>
<p class="menu__hint">Blocks</p>
<ul id="slash-list" class="menu__list"></ul>
<p id="slash-empty" class="menu__empty" hidden>No block matches that filter.</p>
</div>
</section>
<p id="slash-status" class="status" role="status" aria-live="polite">Menu closed.</p>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function RichEditorSlashMenu() {
const [value, setValue] = useState("");
return (
<section className="demo">
<h2>Slash-command Menu</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>
);
}Slash-command Menu
Open a keyboard-aware slash command menu inside a contenteditable editor and insert blocks.
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.