EmpireUI
Get Pro
← Blog7 min read#react#mention-input#tailwind

@Mention Input in React: Slack-style User Tagging Component

Build a Slack-style @mention input in React with a dropdown user picker, keyboard navigation, and Tailwind styling — no heavy dependencies required.

Chat interface showing user mention tagging with an @ symbol dropdown on a dark background

Why Building @Mention Inputs Is Harder Than It Looks

Honestly, the moment you sit down to build an @mention input from scratch you realize why libraries like Slack and Linear charge money. What looks like a textarea with a dropdown is actually a mess of cursor-position math, DOM range APIs, regex state machines, and keyboard-focus juggling — all at the same time.

The core tension is that a real mention input isn't a normal <input>. You need rich text behaviour: part of the content stays editable, part of it becomes a chip or highlighted token. Achieving that means either working with contenteditable (painful) or maintaining a parallel data structure that maps raw text positions to user objects.

This article builds a pragmatic, production-ready React @mention component step by step. We'll use a <textarea> for the editable surface, track caret position with selectionStart, and render a floating suggestion list with Tailwind v3.4 utilities. No contenteditable, no 400-line third-party parser.

How the Mention Detection Algorithm Works

The trick is watching every keystroke and extracting a "current query" from the text the user typed after the most recent @ that hasn't been closed yet. Three edge cases matter: the user might type multiple @ symbols, they might delete back through one, and they might paste a block that includes @ characters.

Here's the detection logic — just a function that accepts the full textarea value and the current selectionStart index, then returns either a match object or null:

// useMentionQuery.ts
interface MentionMatch {
  query: string;     // text after @
  startIndex: number; // index of the @ character
}

export function getMentionQuery(
  value: string,
  caretPos: number
): MentionMatch | null {
  // Walk backwards from caret until we hit @ or a space/newline
  const slice = value.slice(0, caretPos);
  const atIndex = slice.lastIndexOf('@');

  if (atIndex === -1) return null;

  const between = slice.slice(atIndex + 1);

  // If there's a space between @ and caret, the mention was abandoned
  if (/\s/.test(between)) return null;

  return { query: between, startIndex: atIndex };
}

That's the whole parser. It's intentionally narrow — no multi-word mention support, no nested @ handling. You can add those later if your product actually needs them. Start simple.

Building the MentionInput Component with Tailwind

Now wire the detection hook into a React component. The textarea tracks its own value and caret position. When getMentionQuery returns a match, we fetch filtered users and show the dropdown. When a user selects a suggestion, we splice the mention token into the string and close the dropdown.

// MentionInput.tsx
import { useState, useRef, useCallback } from 'react';
import { getMentionQuery } from './useMentionQuery';

const USERS = [
  { id: '1', handle: 'alice', name: 'Alice Chen' },
  { id: '2', handle: 'bob', name: 'Bob Smith' },
  { id: '3', handle: 'carol', name: 'Carol Wu' },
];

export function MentionInput() {
  const [value, setValue] = useState('');
  const [match, setMatch] = useState<{ query: string; startIndex: number } | null>(null);
  const [activeIndex, setActiveIndex] = useState(0);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const suggestions = match
    ? USERS.filter(u =>
        u.handle.toLowerCase().startsWith(match.query.toLowerCase()) ||
        u.name.toLowerCase().includes(match.query.toLowerCase())
      )
    : [];

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLTextAreaElement>) => {
      const next = e.target.value;
      setValue(next);
      const caret = e.target.selectionStart ?? next.length;
      setMatch(getMentionQuery(next, caret));
      setActiveIndex(0);
    },
    []
  );

  const confirmMention = (user: typeof USERS[number]) => {
    if (!match) return;
    const before = value.slice(0, match.startIndex);
    const after = value.slice(match.startIndex + 1 + match.query.length);
    const inserted = `@${user.handle} `;
    setValue(before + inserted + after);
    setMatch(null);
    // Restore focus after React re-render
    requestAnimationFrame(() => {
      const el = textareaRef.current;
      if (!el) return;
      const pos = before.length + inserted.length;
      el.focus();
      el.setSelectionRange(pos, pos);
    });
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (!suggestions.length) return;
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setActiveIndex(i => Math.min(i + 1, suggestions.length - 1));
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setActiveIndex(i => Math.max(i - 1, 0));
    } else if (e.key === 'Enter' || e.key === 'Tab') {
      e.preventDefault();
      confirmMention(suggestions[activeIndex]);
    } else if (e.key === 'Escape') {
      setMatch(null);
    }
  };

  return (
    <div className="relative w-full">
      <textarea
        ref={textareaRef}
        value={value}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
        rows={4}
        placeholder="Type @ to mention someone…"
        className="w-full resize-none rounded-xl border border-white/10 bg-neutral-900
                   px-4 py-3 text-sm text-neutral-100 placeholder:text-neutral-500
                   focus:outline-none focus:ring-2 focus:ring-indigo-500"
      />
      {suggestions.length > 0 && (
        <ul
          role="listbox"
          className="absolute left-0 top-full z-50 mt-1 w-64 overflow-hidden
                     rounded-xl border border-white/10 bg-neutral-800 shadow-xl"
        >
          {suggestions.map((user, i) => (
            <li
              key={user.id}
              role="option"
              aria-selected={i === activeIndex}
              onMouseDown={() => confirmMention(user)}
              className={`flex cursor-pointer items-center gap-3 px-4 py-2.5 text-sm
                          transition-colors ${
                            i === activeIndex
                              ? 'bg-indigo-600 text-white'
                              : 'text-neutral-200 hover:bg-white/5'
                          }`}
            >
              <span className="grid h-7 w-7 place-items-center rounded-full
                               bg-indigo-500/30 text-xs font-semibold text-indigo-300">
                {user.name[0]}
              </span>
              <div>
                <p className="font-medium">{user.name}</p>
                <p className="text-xs text-neutral-400">@{user.handle}</p>
              </div>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

A few decisions worth calling out: onMouseDown instead of onClick on each list item avoids the blur-before-click timing issue that makes the dropdown disappear before selection registers. The requestAnimationFrame trick after confirmMention ensures focus and caret placement happen after React finishes the re-render cycle.

Dropdown Positioning: Caret-Relative vs. Fixed

The component above positions the suggestion list below the textarea with top-full. That works fine for comment boxes. But chat inputs — think Slack's message bar — need the dropdown to float *above* the input, aligned to the actual caret position within the text. That's a harder problem.

Getting pixel-accurate caret coordinates in a <textarea> requires a "mirror div" technique: you clone the textarea's font/padding CSS into a hidden <div>, insert the text up to the caret as text nodes, then append a zero-width <span> sentinel and call getBoundingClientRect() on it. Libraries like textarea-caret-position (1.2kB minified) do exactly that, and it's worth the extra byte budget if you need true caret-relative placement.

For most SaaS comment threads the top-full approach is perfectly fine. Save the mirror-div complexity for dedicated chat UIs. And if you're already building an animated UI shell, check out animated-tabs-react for pairing a tabbed interface with per-tab mention contexts — a common pattern in project management tools.

Storing Mentioned Users in Form State

Raw text with @alice embedded is fine for display, but when you submit the form you need the resolved user IDs. The server can't guess that @alice maps to user u_001. You need to track mentions separately.

The cleanest pattern is a parallel mentionedUsers Set or Map that gets populated in confirmMention and cleared when the user manually deletes an @handle from the textarea. For deletion you re-run getMentionQuery on every change and diff the current mentions against the mentionedUsers state — any handle that no longer appears in the value string gets pruned.

// Extend the component state
const [mentionedIds, setMentionedIds] = useState<Set<string>>(new Set());

// In confirmMention, after setValue:
setMentionedIds(prev => new Set([...prev, user.id]));

// On submit
const handleSubmit = () => {
  // Regex scan the final value to confirm which @handles are still present
  const presentHandles = new Set(
    [...value.matchAll(/@(\w+)/g)].map(m => m[1])
  );
  const resolvedIds = USERS
    .filter(u => presentHandles.has(u.handle))
    .map(u => u.id);

  console.log({ body: value, mentionedUserIds: resolvedIds });
};

This approach is resilient to paste-and-delete editing. The regex re-scan on submit is the source of truth; the mentionedIds Set is just a cache for real-time notification previews (like showing "Alice will be notified" above the send button).

Fetching Users Asynchronously with Debounce

Static USERS arrays work in demos. Real apps need to hit an API because the user base is too large to load upfront. The query from getMentionQuery becomes the search term, debounced so you're not firing a request on every keystroke.

A 180ms debounce feels right — fast enough to feel responsive, slow enough to avoid hammering the API on quick typists. Wire it with a simple useEffect cleanup or drop in a useDebouncedValue hook from your utility belt. Keep the API endpoint simple: GET /api/users/search?q=alice&limit=5 returning [{ id, handle, name, avatarUrl }].

If you're using React Query or SWR, the fetching logic is a one-liner. The key thing is to reset the suggestions list to empty while the request is in-flight, then populate it when the promise resolves — and ignore stale responses if the query changed before they came back (cancel the request or check that the returned query still matches the current match state).

While you're thinking about interactive UI patterns, animated-button-react shows how to add micro-animations to the "Send" trigger that makes the whole comment form feel polished. Small details compound.

Accessibility and Keyboard Navigation

Screen reader support on custom dropdowns is notoriously under-specified by developers. For mention inputs you need at minimum: role="listbox" on the suggestion list, role="option" and aria-selected on each item, and aria-expanded on the textarea or its wrapper. Link the textarea to the listbox with aria-controls pointing to the list's id.

What about announcing new suggestions? Add aria-live="polite" to a visually hidden status element and update its text content when the suggestion count changes: "3 users found. Use up and down arrows to navigate.". Screen reader users get the count without having to tab into the list.

Does that cover every assistive technology edge case? Probably not, but it covers VoiceOver on macOS, NVDA on Windows, and TalkBack on Android — which together account for the vast majority of screen reader users. Test with those three before shipping.

One more thing: don't forget aria-autocomplete="list" on the textarea. This signals to assistive technologies that the field has completion behavior, and some readers will announce it differently from a plain text field.

Styling Mention Tokens and Dark Mode

Plain textarea content is just a string — you can't style individual @mentions differently without switching to contenteditable or overlaying a mirrored display layer on top of the textarea. That overlay trick works: render a <div> with identical font/padding/size as the textarea, parse the value to wrap @handle tokens in <mark> elements, and position the div exactly over the textarea with pointer-events: none.

The overlay <mark> gets something like background: rgba(99,102,241,0.25) and color: #818cf8 — a soft indigo wash that shows up on both dark and light backgrounds. In Tailwind that's bg-indigo-500/25 text-indigo-400 rounded px-0.5. The textarea itself stays transparent so users interact with it normally; they just *see* the styled overlay.

For dark mode, make sure your token highlight color has enough contrast against both bg-neutral-900 and bg-white. The indigo wash at 25% opacity clears WCAG AA on dark surfaces easily, but on white it's borderline — you may need to bump it to 35% or switch to a darker text token color. If your app also has a theme-toggle-react implementation, test the mention component in both themes explicitly.

The overlay approach does add DOM complexity. Profile it before committing — on long documents with many mentions, re-rendering the parse on every keystroke can cause jank. A 16ms RAF debounce on the overlay update usually keeps it smooth.

FAQ

Can I use a plain `<input>` instead of `<textarea>` for the mention component?

Yes, the getMentionQuery function works identically with <input type="text"> since both elements expose selectionStart. The only difference is <input> doesn't support multi-line entry. For single-line search boxes or inline comment fields that's fine; for full message composers use <textarea>.

How do I prevent the suggestion dropdown from closing when the user clicks a list item?

Use onMouseDown instead of onClick on each list item. When a user clicks, the browser fires mousedown before blur on the textarea. If you use onClick, the textarea blurs first, your code sees an empty match state, and the dropdown disappears before the click event fires. onMouseDown with e.preventDefault() stops the blur from happening at all.

How do I support mentioning channels or topics (like #channel) alongside @users?

Extend getMentionQuery to detect both @ and # trigger characters and return a type field along with the query. Then maintain separate suggestion lists and API endpoints per type. The keyboard and selection logic stays identical — just route the confirmMention call to insert either @handle or #channel based on the match type.

What's the best way to test mention input logic?

Unit-test getMentionQuery in isolation — it's a pure function and covers the tricky cursor-position edge cases cleanly without needing a DOM. For integration tests, use @testing-library/user-event to simulate typing @al then pressing ArrowDown + Enter and assert the final textarea value contains @alice. Avoid snapshot testing here; test behavior, not markup.

Does this component work with React Hook Form or Formik?

Yes. Register the textarea value through a controlled pattern and call the form library's setValue (React Hook Form) or setFieldValue (Formik) inside handleChange and confirmMention instead of the local setValue. The caret-position logic operates on the DOM element directly via the ref, so it doesn't interfere with form state management.

My TypeScript build complains about `selectionStart` being possibly null. How do I handle that?

The DOM spec allows selectionStart to be null on elements where selection doesn't apply, like <input type="number">. On <textarea> and <input type="text"> it's always a number in practice, but TypeScript doesn't know that. Use e.target.selectionStart ?? e.target.value.length as a fallback — if we can't get the caret position, treating it as end-of-string is the least surprising default.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Side Sheet Drawer in React: Slide-In Panels with AnimationTimeline Component in React: Vertical, Horizontal, Alternating10 Tailwind Component Patterns Every Developer Should KnowWhat Is Glassmorphism? A Free React + Tailwind Guide