How to Build a Robust @Mention Feature in Frontend Rich Text Editors

This article explores the implementation of @mention functionality in modern front‑end rich‑text editors, covering background, industry examples from Weibo and Twitter, core concepts like Range and Selection APIs, cursor handling, search pop‑ups, key interception, and techniques for inserting custom @tags with code snippets.

ELab Team
ELab Team
ELab Team
How to Build a Robust @Mention Feature in Frontend Rich Text Editors

Background

First use of @mention was about ten years ago via Weibo. The feature now appears in many social and office applications. The author investigated front‑end implementations of @mention.

Industry Implementations

Weibo

Weibo uses a simple regex on a textarea, relying on unique usernames to map IDs.

Twitter

Twitter also starts with @ and ends with a space, using contenteditable for rich‑text and mapping nicknames to IDs.

Basic Idea

Listen to user input and match text that starts with @.

Show a search popup with matched users.

Handle ArrowUp, ArrowDown, Enter, and Escape to control the list.

Replace the @text with an HTML tag that stores user metadata.

Because names may not be unique, contenteditable is preferred over textarea for inserting custom tags.

Key Steps

Obtain cursor position

To replace the typed string you first need the cursor. This requires understanding Selection and Range .

Range

Range represents a pair of boundary points: start and end. Each point is a DOM node plus an offset. Example: let range = new Range(); Set boundaries with range.setStart(node, offset) and range.setEnd(node, offset) . Given an HTML fragment:

<p id="p">Example: <i>italic</i> and <b>bold</b></p>

Selecting "Example: <i>italic</i>" corresponds to the first two child nodes of the &lt;p&gt; element.

Setting start and end:

range.setStart(p, 0); // start at first child (text node "Example: ")
range.setEnd(p, 2);   // end before the <i> element

The Range object has properties such as startContainer , startOffset , endContainer , endOffset , collapsed , and commonAncestorContainer .

Selection

The Selection API represents the user's selection and can be obtained via window.getSelection() or document.getSelection() . According to the Selection API spec, a selection may contain multiple ranges, but most browsers support only one range except Firefox.

Selection properties include anchorNode , anchorOffset , focusNode , focusOffset , isCollapsed , and rangeCount .

Get the @ user

Using the cursor offset and the node's textContent , a simple regex extracts the @username.

const getCursorIndex = () => {
  const selection = window.getSelection();
  return selection?.focusOffset;
};

const getRangeNode = () => {
  const selection = window.getSelection();
  return selection?.focusNode;
};

const getAtUser = () => {
  const content = getRangeNode()?.textContent || "";
  const regx = /@([^@\s]*)$/;
  const match = regx.exec(content.slice(0, getCursorIndex()));
  if (match && match.length === 2) {
    return match[1];
  }
  return undefined;
};

The popup visibility logic uses the same regex.

const showAt = () => {
  const node = getRangeNode();
  if (!node || node.nodeType !== Node.TEXT_NODE) return false;
  const content = node.textContent || "";
  const regx = /@([^@\s]*)$/;
  const match = regx.exec(content.slice(0, getCursorIndex()));
  return match && match.length === 2;
};

Popup position can be obtained from the range's client rects:

const getRangeRect = () => {
  const selection = window.getSelection();
  const range = selection?.getRangeAt(0);
  const rect = range.getClientRects()[0];
  const LINE_HEIGHT = 30;
  return { x: rect.x, y: rect.y + LINE_HEIGHT };
};

Keydown handling intercepts ArrowUp, ArrowDown, and Enter when the popup is visible.

const handleKeyDown = (e) => {
  if (showDialog) {
    if (e.code === "ArrowUp" || e.code === "ArrowDown" || e.code === "Enter") {
      e.preventDefault();
    }
  }
};

Inside the popup, ArrowDown/Up changes the highlighted index, Enter selects a user, and Escape hides the popup.

const keyDownHandler = (e) => {
  if (visibleRef.current) {
    if (e.code === "Escape") {
      props.onHide();
      return;
    }
    if (e.code === "ArrowDown") {
      setIndex(old => Math.min(old + 1, (usersRef.current?.length || 0) - 1));
      return;
    }
    if (e.code === "ArrowUp") {
      setIndex(old => Math.max(0, old - 1));
      return;
    }
    if (e.code === "Enter") {
      if (indexRef.current !== undefined && usersRef.current?.[indexRef.current]) {
        props.onPickUser(usersRef.current?.[indexRef.current]);
        setIndex(-1);
      }
      return;
    }
  }
};

Replace @text with custom tag

Split the original TextNode around the @username, create an at-button element, and insert the three parts back into the DOM.

parentNode.removeChild(oldTextNode);
if (nextNode) {
  parentNode.insertBefore(previousTextNode, nextNode);
  parentNode.insertBefore(atButton, nextNode);
  parentNode.insertBefore(nextTextNode, nextNode);
} else {
  parentNode.appendChild(previousTextNode);
  parentNode.appendChild(atButton);
  parentNode.appendChild(nextTextNode);
}

After insertion, reset the cursor to the start of the following text node.

const range = new Range();
range.setStart(nextTextNode, 0);
range.setEnd(nextTextNode, 0);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);

To avoid the cursor appearing inside the button, a zero‑width space (\u200b) is added before the following text node.

const nextTextNode = new Text("\u200b" + restSlice);
range.setStart(nextTextNode, 1);
range.setEnd(nextTextNode, 1);

An alternative wrapper adds invisible spaces on both sides of the button.

const btn = document.createElement("span");
btn.textContent = `@${user.name}`;
const wrapper = document.createElement("span");
const spaceElem = document.createElement("span");
spaceElem.style.whiteSpace = "pre";
spaceElem.textContent = "\u200b";
wrapper.appendChild(spaceElem);
wrapper.appendChild(btn);
wrapper.appendChild(spaceElem.cloneNode(true));
return wrapper;

Conclusion

Implementing @mention in a front‑end rich‑text editor involves many subtle details, but the core ideas revolve around cursor management with Range/Selection, regex‑based user lookup, popup UI, and replacing the typed text with a custom, non‑editable element.

The provided playground demonstrates a working prototype.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaScript@mentionRange APISelection API
ELab Team
Written by

ELab Team

Sharing fresh technical insights

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.