6 min read

Building a Feishu-Style `@` Mention Input Box with Plain JavaScript

Table of Contents

Cover Image

A Feishu-style OKR input box looks simple from the outside: type @, choose a person, insert a clean mention token, hover it for details, and press Enter to submit.

Under the hood, however, this kind of editor has several tricky parts:

  • matching people while the user types
  • inserting mentions into a contenteditable area
  • making mentions behave like whole tokens
  • showing hover popovers reliably
  • preventing rich HTML from polluting the editor
  • overriding Enter without breaking selection behavior

This article walks through a practical implementation inspired by Zhang Xinxu’s Gitee project:
https://gitee.com/zhangxinxu/okr-at-mention

The Core Idea

The input is built around a contenteditable element. When the user types @, JavaScript detects the current mention query, filters a list of people, and displays a dropdown menu.

When the user selects a person, the editor inserts a styled token rather than plain text. This allows the mention to behave more like a structured object than ordinary characters.

1. Initializing the Editable Mention Input

At the HTML level, the editor can be a simple editable div.

<div
  id="okrInput"
  class="mention-editor"
  contenteditable="true"
  data-placeholder="Type @, pick a person, then press Enter to submit."
></div>

The JavaScript wires the editor to a people list and defines what should happen when Enter is pressed.

const people = [
  { id: "u01", name: "Ada Lovelace", role: "Engineering" },
  { id: "u02", name: "Grace Hopper", role: "Platform" },
  { id: "u03", name: "Lin Chen", role: "Design" },
  { id: "u04", name: "Maya Patel", role: "Product" }
];

createAtMentionInput(document.getElementById("okrInput"), people, {
  pressEnter(editor) {
    document.getElementById("submitOutput").textContent =
      "Submit: " + serializeEditor(editor);
  }
});

This gives the editor three responsibilities: detect @ queries, show matches, and serialize the final content when submitted.

2. Using <hr> as a Whole Mention Token

A common way to make part of a contenteditable editor uneditable is to use contenteditable="false". But that can create awkward cursor behavior, especially when placing the caret before or after the protected element.

A clever alternative is to represent the mention as an empty single tag, such as <hr>, and render its visual label with CSS pseudo-elements.

.at-token {
  display: inline-block;
  margin: 0 3px;
  border: 0;
  vertical-align: -0.28em;
  background: transparent;
  cursor: pointer;
  user-select: none;
}

.at-token::before {
  content: attr(data-label);
  display: inline-flex;
  min-height: 1.62em;
  padding: 0 0.48em;
  border: 1px solid rgba(15, 139, 141, 0.28);
  border-radius: 999px;
  background: #e8f8f7;
  color: #08696b;
  font-weight: 700;
}

Then JavaScript inserts the mention as an hr element with structured metadata.

function insertMentionToken(editor, person) {
  const token = document.createElement("hr");

  token.className = "at-token";
  token.dataset.id = person.id;
  token.dataset.label = "@" + person.name;
  token.dataset.role = person.role;

  editor.append(token, document.createTextNode(" "));
}

The actual element has no text content, but the user sees a polished @Ada Lovelace style token. Because it is a single element, deletion naturally behaves more like removing a whole mention.

3. Showing a Popover with Event Delegation

Mention tokens may be inserted dynamically, so binding hover events directly to every token is fragile. A better pattern is event delegation: bind one listener to the editor and detect whether the hovered target is a mention token.

const popover = document.getElementById("personPopover");

editor.addEventListener("mouseover", (event) => {
  const token = event.target.closest("hr.at-token");
  if (!token || !editor.contains(token)) return;

  popover.innerHTML =
    "<strong>" + token.dataset.label + "</strong>" +
    "<span>" + token.dataset.role + "</span>";

  positionPopover(popover, token);
  popover.hidden = false;
});

editor.addEventListener("mouseout", (event) => {
  if (event.target.closest("hr.at-token")) {
    popover.hidden = true;
  }
});

This approach works well in dynamic editors and also maps cleanly to framework environments such as Vue or React, where editor content may be recreated or updated frequently.

4. Keeping Pasted and Dropped Content Plain

A contenteditable area can accidentally receive rich HTML through paste or drag-and-drop. That is dangerous if your editor is supposed to remain clean and predictable.

The fix is to intercept paste and drop events, read both the HTML and plain text versions, prevent the default behavior, and insert only plain text.

const doStripHtml = function (event) {
  const dataInput = event.clipboardData || event.dataTransfer;
  const htmlOrigin = dataInput.getData("text/html");
  const textOrigin =
    dataInput.getData("text/plain") || dataInput.getData("text");

  if (htmlOrigin) {
    event.preventDefault();
    insertPlainTextAtCursor(textOrigin);
  }
};

editor.addEventListener("paste", doStripHtml);
editor.addEventListener("drop", doStripHtml);

The helper inserts text at the current selection range:

function insertPlainTextAtCursor(text) {
  const selection = window.getSelection();
  if (!selection.rangeCount) return;

  const range = selection.getRangeAt(0);
  range.deleteContents();

  const textNode = document.createTextNode(text);
  range.insertNode(textNode);

  range.setStartAfter(textNode);
  range.collapse(true);

  selection.removeAllRanges();
  selection.addRange(range);
}

This keeps the editor’s internal structure clean, even when users paste formatted text from documents, websites, or chat tools.

5. Intercepting Enter for Custom Submit Behavior

In many OKR or comment-style inputs, Enter should submit rather than insert a new line. Since this is a contenteditable element, the browser’s default behavior must be stopped manually.

editor.addEventListener("keydown", (event) => {
  if (event.key === "Enter") {
    event.preventDefault();

    output.textContent = "Saved: " + serializeEditor(editor);
  }
});

The important detail is serialization. Since the @ mentions are represented as hr elements, editor.textContent alone will not preserve them. The serializer must explicitly convert mention tokens back into readable text.

function serializeEditor(editor) {
  const parts = [];

  editor.childNodes.forEach((node) => {
    if (node.nodeType === Node.TEXT_NODE) {
      parts.push(node.textContent);
    } else if (
      node.nodeType === Node.ELEMENT_NODE &&
      node.matches("hr.at-token")
    ) {
      parts.push(node.dataset.label);
    } else {
      parts.push(node.textContent);
    }
  });

  return parts.join("").replace(/\s+/g, " ").trim();
}

Tradeoffs of the <hr> Technique

Using <hr> as a mention token is practical, but it is not perfect.

The biggest limitation is that pseudo-element text cannot be selected and copied like normal text. Also, because the visible mention label is stored in attributes rather than real text content, submission requires custom serialization.

Still, for many business input scenarios, the benefits are compelling:

  • cleaner deletion behavior
  • no need for contenteditable="false"
  • simple token metadata through dataset
  • predictable rendering through CSS
  • easy hover behavior through delegation

Conclusion

A Feishu-style @ mention editor is a small component with surprisingly deep browser behavior behind it. The key is to avoid treating the editor as plain text. Mentions are structured objects, paste events need sanitizing, and submission needs explicit serialization.

The <hr> token technique is especially interesting because it turns a native single tag into a controlled inline mention component. Combined with event delegation and plain-text paste handling, it creates a lightweight editor pattern that works without a heavy rich text framework.


Try It Yourself

Want to see these concepts in action? I’ve created an interactive demo where you can experiment with the code and see real-time results.

View the Live Demo

Explore more demos from my previous articles in the Demo Gallery.

Happy coding!