ESC

Type to search the knowledge base.

Typeahead Mentions

Machine-coding brief for @mentions typeahead — caret detection, portal list, insert token, keyboard, and a11y.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • autocomplete

Problem statement

Build @mentions typeahead inside a text input or textarea: typing @ then a query shows user suggestions; choosing one inserts a mention token. Interviewers score caret/query extraction, list positioning, and keyboard selection — harder than a standalone autocomplete box.

Requirements

Must have

  • Detect active @query at caret (not every @ in the string blindly)
  • Show suggestion list when query active
  • Filter users by name/username
  • Keyboard: ↑/↓, Enter select, Escape close
  • Insert mention and close list; caret after inserted text
  • Click suggestion inserts

Should have

  • Debounced async search
  • Portal list near caret (or under control)
  • Store rich value (@[Name](id)) vs plain @Name — clarify

Nice to have

  • Highlight tokens in a mirrored highlighter layer
  • Multi-trigger (@ users, # tags)
  • contentEditable chip mentions

Planning (5 minutes out loud)

  1. Extract mention query from value + selectionStart
  2. Plain textarea first — chips are a sequel
  3. Insert replaces @query span
  4. MVP — sync filter list + insert; then async + caret coords
  5. IME — ignore updates while composing

Architecture

MentionTextarea
├── textarea
├── SuggestionList (listbox)
└── utils: getMentionState, insertMention

Types

type User = { id: string; name: string; username: string };

type MentionTextareaProps = {
  value: string;
  onChange: (value: string) => void;
  users?: User[]; // sync mock
  searchUsers?: (q: string, signal: AbortSignal) => Promise<User[]>;
};

Implementation sketch

Detect active mention

type MentionState = {
  query: string;
  start: number; // index of @
  end: number; // caret
} | null;

export function getMentionState(
  value: string,
  caret: number
): MentionState {
  const upto = value.slice(0, caret);
  // last @ not preceded by word char, no whitespace between @ and caret
  const m = /(?:^|[\s([{])@([\w.]*)$/.exec(upto);
  if (!m) return null;
  const query = m[1];
  const start = caret - query.length - 1;
  return { query, start, end: caret };
}

Insert

export function insertMention(
  value: string,
  state: NonNullable<MentionState>,
  user: User
) {
  const token = `@${user.username} `;
  const next =
    value.slice(0, state.start) + token + value.slice(state.end);
  const caret = state.start + token.length;
  return { next, caret };
}

Component core

function MentionTextarea({
  value,
  onChange,
  users = [],
  searchUsers,
}: MentionTextareaProps) {
  const ref = useRef<HTMLTextAreaElement>(null);
  const [mention, setMention] = useState<MentionState>(null);
  const [items, setItems] = useState<User[]>([]);
  const [active, setActive] = useState(0);

  function syncMention(nextValue: string, caret: number) {
    const state = getMentionState(nextValue, caret);
    setMention(state);
    if (!state) {
      setItems([]);
      return;
    }
    if (searchUsers) {
      // debounce + abort in a hook — sketch:
      searchUsers(state.query, new AbortController().signal).then(setItems);
    } else {
      const q = state.query.toLowerCase();
      setItems(
        users.filter(
          (u) =>
            u.username.toLowerCase().includes(q) ||
            u.name.toLowerCase().includes(q)
        )
      );
    }
    setActive(0);
  }

  function applyUser(user: User) {
    if (!mention || !ref.current) return;
    const { next, caret } = insertMention(value, mention, user);
    onChange(next);
    setMention(null);
    setItems([]);
    requestAnimationFrame(() => {
      ref.current!.selectionStart = ref.current!.selectionEnd = caret;
      ref.current!.focus();
    });
  }

  return (
    <div className="mention">
      <textarea
        ref={ref}
        value={value}
        aria-label="Message"
        aria-autocomplete="list"
        aria-expanded={!!mention && items.length > 0}
        onChange={(e) => {
          onChange(e.target.value);
          syncMention(e.target.value, e.target.selectionStart);
        }}
        onKeyUp={(e) =>
          syncMention(e.currentTarget.value, e.currentTarget.selectionStart)
        }
        onKeyDown={(e) => {
          if (!mention || items.length === 0) return;
          if (e.key === "ArrowDown") {
            e.preventDefault();
            setActive((i) => Math.min(items.length - 1, i + 1));
          }
          if (e.key === "ArrowUp") {
            e.preventDefault();
            setActive((i) => Math.max(0, i - 1));
          }
          if (e.key === "Enter" || e.key === "Tab") {
            e.preventDefault();
            applyUser(items[active]);
          }
          if (e.key === "Escape") {
            e.preventDefault();
            setMention(null);
            setItems([]);
          }
        }}
      />
      {mention && items.length > 0 && (
        <ul role="listbox" aria-label="Mention suggestions">
          {items.map((u, i) => (
            <li
              key={u.id}
              role="option"
              aria-selected={i === active}
              onMouseDown={(e) => {
                e.preventDefault();
                applyUser(u);
              }}
            >
              {u.name} (@{u.username})
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Use onMouseDown preventDefault so textarea doesn’t blur before click.

Caret coordinates (portal position)

// Approximate: mirror div technique or textarea-caret-position libraries.
// Interview verbal: clone styles into a hidden mirror pre, measure span offset.

Accessibility essentials

  • Textarea remains the primary control
  • Listbox options named; active option highlighted
  • Escape closes without submitting parent forms
  • Don’t trap focus in the list — keep focus in textarea and use aria-activedescendant ideally

Performance notes

  • Debounce async search 150–300ms; AbortController
  • Cap suggestions (8–20)
  • Mirror highlighter can be expensive — throttle

Footguns

  1. Matching @ inside emails — require boundary before @
  2. Insert at wrong index after controlled re-render
  3. Blur before click loses selection
  4. Enter submits outer form while selecting mention
  5. Not handling composition events for CJK IME

Interview out-loud answer

Mentions parse the text before the caret for an @query token with a boundary rule. While active, a listbox filters users; keyboard moves active index and Enter inserts @username replacing the query span. Focus stays in the textarea. Async search is debounced and aborted. Rich chips require a separate overlay model.

Further reading