Implementing an @User Mention Input Component with Highlight and Deletion Handling in React
This article explains how to build a custom React @mention input component that highlights mentioned users, treats the whole @username as a single deletable unit, tracks cursor position for a floating selector, and returns both the plain text and the list of mentioned users to the parent component.
When a product needs to notify users by mentioning them (e.g., "@user") in posts or videos, existing open‑source libraries such as Ant Design's Mentions component fall short because they do not provide highlighted mentions and treat deletions character‑by‑character, causing unnecessary performance overhead.
The solution is to implement a custom @ mention input using a contentEditable div, a floating selector, and a set of helper functions that handle highlighting, whole‑entity deletion, cursor tracking, and data extraction.
Key Implementation Points
Highlight Effect
Delete/Select User as a Whole
Track @ Position and Align Selector
Expose Text Content and Mentioned Users to Parent
Because standard input or textarea cannot satisfy the first two requirements, a rich‑text editor is created by setting contentEditable on a div .
<AtInput
height={150}
onRequest={async (searchStr) => {
const { data } = await UserFindAll({ nickname: searchStr });
return data?.list?.map(v => ({
id: v.uid,
name: v.nickname,
wechatAvatarUrl: v.wechatAvatarUrl,
}));
}}
onChange={(content, selected) => {
setAtUsers(selected);
}}
/>The component maintains state for content, visibility of the selector, fetched user options, the index of the current "@", focus node, search keyword, cursor coordinates, and the list of selected users.
let timer: NodeJS.Timeout | null = null;
const AtInput = (props: AtInputProps) => {
const { height = 300, onRequest, onChange, value, onBlur } = props;
const [content, setContent] = useState
('');
const [visible, setVisible] = useState
(false);
const [options, setOptions] = useState
([]);
const [currentAtIdx, setCurrentAtIdx] = useState
();
const [focusNode, setFocusNode] = useState
();
const [searchStr, setSearchStr] = useState
('');
const [cursorPosition, setCursorPosition] = useState
({ x: 0, y: 0 });
const [selected, setSelected] = useState
([]);
const atRef = useRef
();
// ... (logic omitted for brevity)
};The onObserveInput function observes every input or click event, extracts the substring before the cursor, finds the last "@" character, and if a keyword exists, fetches matching users and shows the selector at the correct position.
const onObserveInput = () => {
let cursorBeforeStr = '';
const selection: any = window.getSelection();
if (selection?.focusNode?.data) {
cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
}
setFocusNode(selection.focusNode);
const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
setCurrentAtIdx(lastAtIndex);
if (lastAtIndex !== -1) {
getCursorPosition();
const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
setSearchStr(searchStr);
fetchOptions(searchStr);
setVisible(true);
} else {
setVisible(false);
setSearchStr('');
}
} else {
setVisible(false);
}
};When a user is selected from the dropdown, the component replaces the original "@keyword" with an immutable span element that displays the user's name, updates the internal list of selected users, refreshes the editor content, hides the selector, and refocuses the editor.
const onSelect = (item: Options) => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0) as Range;
range.setStart(focusNode as Node, currentAtIdx!);
range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
range.deleteContents();
const atEle = createAtSpanTag(item.id, item.name);
range.insertNode(atEle);
range.collapse();
setSelected([...selected, item]);
setContent(document.getElementById('atInput')?.innerText as string);
setVisible(false);
atRef.current.focus();
};The helper createAtSpanTag builds the non‑editable span with a unique ID and optional color, while getAttrIds extracts the IDs of all rendered .at-span elements to keep the selected user list in sync.
const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
const ele = document.createElement('span');
ele.className = 'at-span';
ele.style.color = color;
ele.id = id.toString();
ele.contentEditable = 'false';
ele.innerText = `@${name}`;
return ele;
};
const getAttrIds = () => {
const spans = document.querySelectorAll('.at-span');
const ids = new Set();
spans.forEach(span => ids.add(span.id));
return selected.filter(s => ids.has(s.id));
};Finally, a useEffect hook watches changes to selected and content , calling the parent onChange callback with the current plain text and the array of mentioned users.
useEffect(() => {
const selectUsers = getAttrIds();
onChange(content, selectUsers);
}, [selected, content]);The article concludes that the current implementation works but is rigid; future enhancements could include customizable highlight styles, keyboard shortcuts, and other UI refinements to improve user experience.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.