Mention Autocomplete (@mention) — HTML CSS JS Snippet

Mention Autocomplete · Forms · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

@ trigger detection scanning the text node before the caret
Real-time user filtering on every character typed
Non-editable mention chip (contentEditable: false) with highlight style
Non-breaking space cursor repositioning after chip insertion
Arrow key navigation (up/down) with Enter to select
Escape key dismissal and space-in-query auto-dismiss
Comment feed rendering with preserved mention chips
Works with mouse click and keyboard — fully dual-input

About this UI Snippet

Mention Autocomplete — @mention Popup, Keyboard Navigation & Mention Chip

Screenshot of the Mention Autocomplete snippet rendered live

An @mention autocomplete is a fundamental feature of any collaborative tool — used in project management (Linear, Jira), chat (Slack, Discord), comments (GitHub, Notion), and document editors. It extends the autocomplete input into a contenteditable. It is a frequently-searched UI pattern because contenteditable-based autocomplete involves several non-obvious browser API interactions: reading the caret position, finding the @-trigger in the text before it, inserting a non-editable chip, and moving the cursor past it. This snippet provides a complete, working implementation.

The trigger detection

onInput() runs on every keystroke in the contenteditable. It calls getCaretRange() to get the current selection range, then getQueryBeforeCaret() to scan backward from the caret in the current text node for an @ sign. If an @ is found with no whitespace between it and the caret, it extracts the characters after the @ as the search query and opens the popup. Any whitespace between @ and the caret, or no @, closes the popup — so typing a space after a partial mention dismisses the dropdown.

Filtering the user list

The popup filters the users array by name using a case-insensitive includes check. Results render immediately as the user types, updating on every character. The selectedIdx tracks which item is highlighted, and both the data-source (window.__filteredUsers) and the rendered list stay in sync.

Keyboard navigation

onKeyDown intercepts ArrowUp, ArrowDown, Enter, and Escape while the popup is open. Arrow keys move selectedIdx and re-render the popup list with the new highlight. Enter calls insertMention() on the selected user. Escape hides the popup and clears the mention range. All other keys fall through to the browser's normal editing behaviour.

Inserting the mention chip

insertMention() uses the saved mentionRange to find the @ character, selects from the @ to the caret (the partial text the user typed), deletes it, and inserts a non-editable span with class "mention". A non-breaking space after the chip moves the caret out of the span so the next character typed starts normal text rather than extending the mention. This is the standard mention-chip insertion technique: replace the trigger + typed text with a chip, then position the cursor after it.

The comment feed

sendComment() reads the editor's innerHTML (which contains both plain text and mention spans), wraps it in a comment thread card with a timestamp, and prepends it to the feed. The mention chips are preserved in the submitted HTML so they remain highlighted in the posted comment.

Accessibility and production considerations

In a production implementation you would replace contenteditable with a proper rich text editor or a library (ProseMirror, Tiptap, Slate) for full accessibility and cross-browser reliability. The snippet demonstrates the core browser APIs and the mention-insertion technique that any implementation builds on.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to reason through the Selection and Range API calls alone. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to walk through exactly how getQueryBeforeCaret scans backward from the caret offset to find the last unbroken at-sign, or why the mention chip needs contentEditable set to false plus a trailing non-breaking space to behave as an atomic unit. The same assistant can help optimize it — checking whether showPopup should debounce its full re-render on every keystroke once the user list grows past a few hundred entries, or whether filtering with startsWith first and includes as a fallback avoids noisy partial matches. It's equally useful for extending the behavior: ask it to support mentioning by typing a hashtag for tags, fetch users from a real API instead of the hardcoded array, or restrict mentions to only channel members. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build an "at-mention autocomplete" for a contenteditable comment box in plain HTML, CSS, and JavaScript using only the Selection and Range APIs — no libraries, no textarea.

Requirements:
- A contenteditable div that shows a placeholder via the CSS colon-empty colon-colon-before content trick, plus an absolutely positioned popup list anchored below it.
- On every input event, get the current caret with window.getSelection().getRangeAt(0), and only look inside the active text node (bail out if the range's start container isn't a text node). Slice that node's text up to the caret offset and search backward for the most recent at-sign with lastIndexOf. If there is any whitespace between that at-sign and the caret, treat it as no active mention and close the popup.
- When an at-sign with no trailing whitespace is found, treat everything after it as a live search query, filter a list of users by that query (case-insensitive), and render the filtered results in the popup with an avatar, name, and role per row, highlighting one row as the keyboard-selected item.
- Support ArrowUp and ArrowDown to move the selected index and re-render the list, Enter to insert the currently selected user, and Escape to dismiss the popup, all intercepted in a keydown handler that only acts while the popup is open.
- On selection, delete the typed "at-sign plus query" text using a saved Range, insert a new inline span element for the mention that has contentEditable set to false and a class marking it visually distinct (e.g. background tint, bold), then insert a literal non-breaking space text node immediately after it and move the caret to just after that space, so the user's next keystroke starts plain text rather than editing inside the chip.
- Persist mention data as a data attribute (e.g. the user's id) on the chip span so the mentioned users can be extracted later from the editor's innerHTML when the comment is submitted.

Step by step

How to Use

  1. 1
    Type @ to trigger the popupClick the editor and type @ followed by any letters. The popup appears instantly, filtered to matching team members.
  2. 2
    Navigate with keyboardUse arrow keys to move the highlight up and down. Press Enter to insert the selected mention. Press Escape to dismiss.
  3. 3
    Click to selectClick any user in the popup to insert their mention chip at the caret position.
  4. 4
    Post the commentClick Comment to add the message (with mention chips) to the feed below the editor.
  5. 5
    Customise the user listReplace the users array with your own team members: { id, name, role, initials, bg }. In production, fetch from your users API filtered by the query string.
  6. 6
    Export for your frameworkClick "React" for a component using useRef for the contenteditable and useState for popup state. Click "Vue" for a Vue 3 SFC with a reactive mention state.

Real-world uses

Common Use Cases

Team collaboration and project management comments
Add @mention to the comment system of a project management tool, issue tracker, or task manager. When a user is mentioned, send them a notification (email, push, in-app) linking to the specific comment. The mention chip stores the userId as a data attribute, making it easy to extract mentions from the submitted content for notification routing.
Chat interface with user mentions
Integrate @mention into a real-time chat interface. In the message submission handler, extract all mention chips from the innerHTML, collect the user IDs, and include them in the WebSocket message payload so the backend can fan out targeted notifications to mentioned users alongside the broadcast message.
Document editor with collaborative mentions
Extend the snippet for a collaborative document editor. Use an operational-transform or CRDT approach (ProseMirror, Yjs) to represent mention nodes as structured data rather than raw HTML, so concurrent edits cannot corrupt the mention chip position or content.
Social feed comments and posts
Use the @mention pattern in a social feed where posts and comments support user tagging. The mention chip's visual style (purple background, bold text) matches the convention users know from Twitter, Instagram, and LinkedIn — immediately familiar without documentation.
Study contenteditable and the Selection/Range API
The snippet is a practical guide to the browser's Selection and Range APIs: getSelection(), getRangeAt(), setStart/End, insertNode(), and cursor repositioning after programmatic DOM manipulation. These APIs are the foundation for all rich-text editing in the browser and are non-trivial to use correctly without a working example.
Internal admin note and annotation systems
Use mentions in an internal note system where support agents can loop in colleagues, sales reps can tag a deal owner, or ops staff can assign action items inline in a shared note. The comment feed preserves mentions as visual chips so it is always clear who was notified.

Got questions?

Frequently Asked Questions

getQueryBeforeCaret() gets the text of the current text node and slices it to the caret offset, giving only the text typed before the cursor on the current node. It calls lastIndexOf("@") on that slice to find the most recent @ sign. If a whitespace character exists between the @ and the caret, it returns null (no active mention query). Otherwise, it returns the characters between the @ and the caret as the search query. This handles cases like "@Al" mid-word correctly.

Setting contentEditable="false" on the mention span makes it behave as an atomic unit in the editor — the user cannot position the caret inside it, partially select it, or type inside the mention text. The cursor jumps over it as a whole, which is the expected UX for mention chips in every major editor. A non-breaking space (\u00A0) is inserted after the chip to give the cursor a landing position immediately after the mention without being inside it.

Query the editor for all mention spans after submission: editor.querySelectorAll(".mention[data-uid]"). Map over them to extract parseInt(span.dataset.uid) for each. This gives you the array of mentioned user IDs to pass to your notification API alongside the comment content. To store the comment in a database, save the innerHTML (which preserves the chips) or convert to a structured format where mention nodes carry the user ID as a property.

Use a contenteditable div with a ref. In an onChange handler (the oninput equivalent, using onInput in JSX), call getSelection() to find the caret and detect the @-query. Keep popupOpen (boolean), query (string), users (filtered array), and selectedIdx (number) in useState. The popup renders as an absolutely-positioned div. insertMention() manipulates the DOM via the ref, inserts the chip, and repositions the cursor. The main challenge is that React's synthetic events and virtual DOM can conflict with direct contenteditable DOM manipulation — always work through the ref, not via JSX rendering of the editor content. For the Tailwind version, click "Tailwind" to get the same markup with utility classes instead of a scoped stylesheet.