UI Components Medium
Mention Autocomplete
Suggest local teammates after @ input, navigable with the keyboard and Enter.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0a0b0f;
--panel: #14161d;
--panel-2: #1b1e27;
--line: #272b36;
--text: #e7e9ee;
--muted: #9aa1b1;
--accent: #7c9cff;
--accent-soft: rgba(124, 156, 255, 0.16);
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: clamp(1rem, 4vw, 2.5rem);
background:
radial-gradient(900px 500px at 20% -10%, rgba(124, 156, 255, 0.14), transparent 60%),
var(--bg);
color: var(--text);
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.demo { width: min(620px, 100%); }
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.composer {
background: linear-gradient(180deg, var(--panel), var(--panel-2));
border: 1px solid var(--line);
border-radius: 16px;
padding: 22px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
}
.composer__title { margin: 0 0 6px; font-size: 1.05rem; letter-spacing: -0.01em; }
.composer__hint { margin: 0 0 18px; color: var(--muted); font-size: 0.83rem; }
kbd {
display: inline-block;
padding: 1px 6px;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 6px;
background: #0f1117;
font: inherit;
font-size: 0.76rem;
}
.field { position: relative; }
.field__label {
display: block;
margin-bottom: 8px;
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--muted);
}
.editor {
min-height: 132px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 12px;
background: #0e1015;
outline: none;
overflow-wrap: anywhere;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.editor:focus { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
.editor:empty::before { content: attr(data-placeholder); color: #5c6377; }
.field__help { margin: 8px 0 0; font-size: 0.76rem; color: var(--muted); }
.mention {
display: inline-block;
padding: 1px 7px;
margin: 0 1px;
border-radius: 999px;
background: var(--accent-soft);
border: 1px solid rgba(124, 156, 255, 0.35);
color: #c3d1ff;
font-weight: 600;
white-space: nowrap;
}
.menu {
position: absolute;
z-index: 20;
margin: 6px 0 0;
padding: 6px;
list-style: none;
width: min(300px, 100%);
max-height: 232px;
overflow-y: auto;
background: #10131a;
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 20px 44px rgba(0, 0, 0, 0.55);
animation: pop 0.12s ease-out;
}
.menu[hidden] { display: none; }
@keyframes pop {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: none; }
}
.menu__item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 9px;
border-radius: 9px;
cursor: pointer;
}
.menu__item[aria-selected="true"] { background: var(--accent-soft); }
.menu__avatar {
flex: none;
width: 28px; height: 28px;
display: grid;
place-items: center;
border-radius: 50%;
background: #22283a;
color: #cbd4ea;
font-size: 0.72rem;
font-weight: 700;
}
.menu__name { display: block; font-size: 0.87rem; }
.menu__name mark { background: none; color: var(--accent); font-weight: 700; }
.menu__role { display: block; font-size: 0.72rem; color: var(--muted); }
.menu__empty { padding: 10px; color: var(--muted); font-size: 0.83rem; }
.statusbar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--line);
font-size: 0.8rem;
color: var(--muted);
}
.statusbar__names { margin-left: auto; color: var(--text); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}// Mention autocomplete on a contenteditable surface.
// Detects an "@query" token immediately before the caret, filters a local
// directory, renders a roving-selection listbox, and replaces the token with a
// non-editable chip on commit. Vanilla only.
const PEOPLE = [
{ id: "ada", name: "Ada Lovelace", handle: "ada", role: "Engineering" },
{ id: "grace", name: "Grace Hopper", handle: "grace", role: "Compilers" },
{ id: "linus", name: "Linus Berg", handle: "linus", role: "Infrastructure" },
{ id: "maya", name: "Maya Okafor", handle: "maya", role: "Design" },
{ id: "raj", name: "Raj Patel", handle: "raj", role: "Product" },
{ id: "sofia", name: "Sofia Marin", handle: "sofia", role: "Data" },
{ id: "yuki", name: "Yuki Tanaka", handle: "yuki", role: "Support" },
{ id: "noor", name: "Noor Haddad", handle: "noor", role: "Security" },
];
const editor = document.getElementById("editor");
const menu = document.getElementById("mention-list");
const status = document.getElementById("mention-status");
const countEl = document.getElementById("mention-count");
const namesEl = document.getElementById("mention-names");
let matches = [];
let active = 0;
let open = false;
let token = null; // { node, start, end, query }
const escapeHtml = (s) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
const initials = (name) =>
name.split(/\s+/).slice(0, 2).map((p) => p[0]).join("").toUpperCase();
/** Find an "@query" token ending exactly at the caret, if any. */
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 end = sel.anchorOffset;
const text = node.data.slice(0, end);
const m = /(^|[\s (])@([\w.\-]{0,20})$/.exec(text);
if (!m) return null;
return { node, start: end - m[2].length - 1, end, query: m[2] };
}
function filter(query) {
const q = query.toLowerCase();
return PEOPLE.filter(
(p) => p.handle.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
).slice(0, 6);
}
function highlight(name, query) {
const safe = escapeHtml(name);
if (!query) return safe;
const i = name.toLowerCase().indexOf(query.toLowerCase());
if (i < 0) return safe;
return (
escapeHtml(name.slice(0, i)) +
"<mark>" +
escapeHtml(name.slice(i, i + query.length)) +
"</mark>" +
escapeHtml(name.slice(i + query.length))
);
}
function positionMenu() {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const rect = sel.getRangeAt(0).getBoundingClientRect();
const host = editor.getBoundingClientRect();
const parent = menu.offsetParent.getBoundingClientRect();
const top = (rect.bottom || host.bottom) - parent.top + 6;
const left = Math.min(
Math.max((rect.left || host.left) - parent.left, 0),
Math.max(host.width - menu.offsetWidth, 0)
);
menu.style.top = top + "px";
menu.style.left = left + "px";
}
function render() {
if (!matches.length) {
menu.innerHTML = '<li class="menu__empty">No teammate found</li>';
} else {
menu.innerHTML = matches
.map(
(p, i) =>
`<li class="menu__item" role="option" id="mention-opt-${p.id}" data-index="${i}"` +
` aria-selected="${i === active}">` +
`<span class="menu__avatar" aria-hidden="true">${initials(p.name)}</span>` +
`<span class="menu__text"><span class="menu__name">${highlight(p.name, token ? token.query : "")}</span>` +
`<span class="menu__role">@${p.handle} · ${escapeHtml(p.role)}</span></span></li>`
)
.join("");
}
const opt = menu.querySelector('[aria-selected="true"]');
editor.setAttribute("aria-activedescendant", opt ? opt.id : "");
if (opt) opt.scrollIntoView({ block: "nearest" });
}
function openMenu() {
open = true;
menu.hidden = false;
editor.setAttribute("aria-expanded", "true");
render();
positionMenu();
status.textContent = matches.length
? `${matches.length} teammates available. ${matches[active].name} selected.`
: "No teammate found";
}
function closeMenu() {
open = false;
token = null;
menu.hidden = true;
menu.innerHTML = "";
editor.setAttribute("aria-expanded", "false");
editor.removeAttribute("aria-activedescendant");
}
function move(delta) {
if (!matches.length) return;
active = (active + delta + matches.length) % matches.length;
render();
status.textContent = matches[active].name;
}
function commit(person) {
if (!token || !person) return;
const { node, start, end } = token;
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
range.deleteContents();
const chip = document.createElement("span");
chip.className = "mention";
chip.contentEditable = "false";
chip.dataset.userId = person.id;
chip.textContent = "@" + person.handle;
const tail = document.createTextNode(" ");
range.insertNode(tail);
range.insertNode(chip);
const sel = window.getSelection();
const after = document.createRange();
after.setStart(tail, 1);
after.collapse(true);
sel.removeAllRanges();
sel.addRange(after);
closeMenu();
editor.focus();
syncSummary();
status.textContent = `${person.name} mentioned`;
}
function syncSummary() {
const chips = [...editor.querySelectorAll(".mention")];
countEl.textContent = String(chips.length);
namesEl.textContent = chips.length ? chips.map((c) => c.textContent).join(", ") : "none";
}
function refresh() {
const next = readToken();
if (!next) {
if (open) closeMenu();
return;
}
const sameQuery = token && token.query === next.query;
token = next;
matches = filter(next.query);
if (!sameQuery) active = 0;
if (active >= matches.length) active = 0;
openMenu();
}
editor.addEventListener("input", () => {
syncSummary();
refresh();
});
editor.addEventListener("keydown", (event) => {
if (!open) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
move(1);
break;
case "ArrowUp":
event.preventDefault();
move(-1);
break;
case "Home":
event.preventDefault();
active = 0;
render();
break;
case "End":
event.preventDefault();
active = Math.max(matches.length - 1, 0);
render();
break;
case "Enter":
case "Tab":
if (matches.length) {
event.preventDefault();
commit(matches[active]);
}
break;
case "Escape":
event.preventDefault();
closeMenu();
break;
default:
break;
}
});
// Caret moves without an input event (arrow keys, clicks) also re-evaluate.
document.addEventListener("selectionchange", () => {
if (document.activeElement !== editor) return;
if (open) refresh();
});
editor.addEventListener("blur", () => {
// Allow the pointerdown handler on the menu to run first.
setTimeout(() => {
if (document.activeElement !== editor) closeMenu();
}, 0);
});
menu.addEventListener("pointerdown", (event) => {
const item = event.target.closest(".menu__item");
if (!item) return;
event.preventDefault(); // keep caret/selection intact
commit(matches[Number(item.dataset.index)]);
});
menu.addEventListener("pointermove", (event) => {
const item = event.target.closest(".menu__item");
if (!item) return;
const i = Number(item.dataset.index);
if (i !== active) {
active = i;
render();
}
});
// Paste as plain text so the editor value stays predictable.
editor.addEventListener("paste", (event) => {
event.preventDefault();
const text = (event.clipboardData || window.clipboardData).getData("text/plain");
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) 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);
syncSummary();
refresh();
});
window.addEventListener("resize", () => {
if (open) positionMenu();
});
syncSummary();<link rel="stylesheet" href="style.css">
<main class="demo" data-demo="Mention Autocomplete">
<section class="composer">
<h1 class="composer__title">New comment</h1>
<p class="composer__hint">Type <kbd>@</kbd> to mention a teammate. <kbd>↑</kbd><kbd>↓</kbd> to move, <kbd>Enter</kbd> or <kbd>Tab</kbd> to insert, <kbd>Esc</kbd> to dismiss.</p>
<div class="field">
<label class="field__label" for="editor">Comment</label>
<div
id="editor"
class="editor"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="mention-list"
aria-describedby="editor-help"
data-placeholder="Write something and mention someone with @…"
></div>
<p id="editor-help" class="field__help">Mentions are inserted as non-editable chips.</p>
<ul id="mention-list" class="menu" role="listbox" aria-label="Teammates" hidden></ul>
</div>
<div class="statusbar">
<span id="mention-status" class="sr-only" role="status" aria-live="polite"></span>
<span><strong id="mention-count">0</strong> mentions</span>
<output id="mention-names" class="statusbar__names">none</output>
</div>
</section>
</main>
<script src="script.js"></script>import { useState } from "react";
export function RichEditorMentions() {
const [value, setValue] = useState("");
return (
<section className="demo">
<h2>Mention Autocomplete</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>
);
}Mention Autocomplete
Suggest local teammates after @ input, navigable with the keyboard and Enter.
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.