Skip to content
38

Mention Input

@-mention textarea with caret-anchored autocomplete, highlighted mention chips, async search and multiple trigger characters.

Example

Installation

npx shadcn@latest add https://hirael.com/r/mention-input.json

Usage

import {
  MentionInput,
  MentionInputTextarea,
  MentionInputList,
  MentionInputItem,
  useMentionInput,
} from "@/components/mention-input"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<MentionInput />

+ native element props
PropTypeDefault
valuestringnone
defaultValuestringnone
onValueChange((value: string) => void)none
itemsMentionItem[]NO_ITEMS
onSearch((query: string, trigger: string) => Promise<MentionItem[]>)none
triggerstring | string[]'@'
placeholderstringnone
disabledbooleannone
maxRowsnumbernone
onMention((item: MentionItem) => void)none
emptyMessagestring'No results.'
loadingMessagestring'Searching…'
listLabelstring'Mention suggestions'
namestringnone

<MentionInputTextarea />

+ native element props

No props of its own; forwards everything to the underlying element.

<MentionInputList />

+ native element props

No props of its own; forwards everything to the underlying element.

<MentionInputItem />

+ native element props
PropTypeDefault
item*MentionItemnone
index*numbernone
childrenReact.ReactNodenone

Component source

'use client';

import * as React from 'react';

import { cn } from '@/lib/utils';
import { Spinner } from '@/registry/hirael/bases/radix/ui/spinner';
import { composeRefs } from '@/registry/hirael/lib/compose-refs';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';

export interface MentionItem {
  id: string;
  /** Inserted after the trigger; keep it free of spaces so the token stays one mention. */
  label: string;
  description?: string;
}

interface ActiveMention {
  start: number;
  trigger: string;
  query: string;
}

const MIRROR_PROPS = [
  'box-sizing',
  'padding-top',
  'padding-right',
  'padding-bottom',
  'padding-left',
  'border-top-width',
  'border-right-width',
  'border-bottom-width',
  'border-left-width',
  'font-family',
  'font-size',
  'font-weight',
  'font-style',
  'letter-spacing',
  'line-height',
  'text-transform',
  'word-spacing',
  'text-indent',
  'tab-size',
  'direction',
] as const;

// `.` and `-` may join name parts but never end one, so "Thanks @john." mentions `john`.
const MENTION_NAME = String.raw`[\p{L}\p{N}_]+(?:[.\-][\p{L}\p{N}_]+)*`;

const triggerCharClass = (triggers: string[]) => {
  return triggers.map((ch) => ch.replace(/[\\\]^-]/g, '\\$&')).join('');
};

export const getMentions = (value: string, trigger: string | string[] = '@'): string[] => {
  const triggers = Array.isArray(trigger) ? trigger : [trigger];
  const re = new RegExp(`(?:^|\\s)[${triggerCharClass(triggers)}](${MENTION_NAME})`, 'gu');
  const out: string[] = [];
  let m: RegExpExecArray | null;
  while ((m = re.exec(value)) !== null) out.push(m[1]);

  return out;
};

const getActiveMention = (text: string, caret: number, triggers: string[]): ActiveMention | null => {
  for (let i = caret - 1; i >= 0; i--) {
    const ch = text[i];
    if (/\s/.test(ch)) return null;
    if (triggers.includes(ch)) {
      if (i === 0 || /\s/.test(text[i - 1])) {
        return { start: i, trigger: ch, query: text.slice(i + 1, caret) };
      }

      return null;
    }
  }

  return null;
};

const measureCaret = (textarea: HTMLTextAreaElement, index: number) => {
  const style = window.getComputedStyle(textarea);
  const mirror = document.createElement('div');
  for (const prop of MIRROR_PROPS) {
    mirror.style.setProperty(prop, style.getPropertyValue(prop));
  }
  mirror.style.position = 'absolute';
  mirror.style.top = '0';
  mirror.style.left = '0';
  mirror.style.visibility = 'hidden';
  mirror.style.whiteSpace = 'pre-wrap';
  mirror.style.overflowWrap = 'break-word';
  mirror.style.width = `${textarea.offsetWidth}px`;
  mirror.textContent = textarea.value.slice(0, index);
  const marker = document.createElement('span');
  marker.textContent = '​';
  mirror.appendChild(marker);
  (textarea.parentElement ?? document.body).appendChild(mirror);
  const rect = {
    top: marker.offsetTop,
    left: marker.offsetLeft,
    height: parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2,
  };
  mirror.remove();

  return rect;
};

interface Segment {
  text: string;
  mention: boolean;
}

const segmentValue = (value: string, triggers: string[], known: Set<string>): Segment[] => {
  const re = new RegExp(`(^|\\s)([${triggerCharClass(triggers)}]${MENTION_NAME})`, 'gu');
  const segments: Segment[] = [];
  let last = 0;
  let m: RegExpExecArray | null;
  while ((m = re.exec(value)) !== null) {
    const start = m.index + m[1].length;
    const token = m[2];
    if (!known.has(token.slice(1).toLowerCase())) continue;
    if (start > last) segments.push({ text: value.slice(last, start), mention: false });
    segments.push({ text: token, mention: true });
    last = start + token.length;
  }
  if (last < value.length) segments.push({ text: value.slice(last), mention: false });

  return segments;
};

const metrics = 'min-h-16 w-full rounded-sm border px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap break-words';

interface MentionPosition {
  top: number;
  left: number;
}

interface MentionInputCtx {
  id: string;
  listboxId: string;
  open: boolean;
  loading: boolean;
  filteredItems: MentionItem[];
  activeIndex: number;
  activeTrigger: string | undefined;
  pos: MentionPosition;
  value: string;
  segments: Segment[];
  disabled?: boolean;
  placeholder?: string;
  name?: string;
  emptyMessage: string;
  loadingMessage: string;
  listLabel: string;
  popupRef: React.RefObject<HTMLDivElement | null>;
  textareaRef: React.RefObject<HTMLTextAreaElement | null>;
  backdropRef: React.RefObject<HTMLDivElement | null>;
  select: (item: MentionItem) => void;
  setActiveIndex: (index: number) => void;
  textareaProps: Pick<React.ComponentProps<'textarea'>, 'onChange' | 'onSelect' | 'onScroll' | 'onKeyDown' | 'onBlur'>;
}

const MentionInputContext = React.createContext<MentionInputCtx | null>(null);

export const useMentionInput = () => {
  const ctx = React.useContext(MentionInputContext);
  if (!ctx) {
    throw new Error('MentionInput compound parts must be used inside <MentionInput>');
  }

  return ctx;
};

export interface MentionInputProps extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'onChange'> {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  items?: MentionItem[];
  onSearch?: (query: string, trigger: string) => Promise<MentionItem[]>;
  trigger?: string | string[];
  placeholder?: string;
  disabled?: boolean;
  maxRows?: number;
  onMention?: (item: MentionItem) => void;
  emptyMessage?: string;
  loadingMessage?: string;
  listLabel?: string;
  name?: string;
}

const NO_ITEMS: MentionItem[] = [];

const MentionInput = ({
  value: valueProp,
  defaultValue,
  onValueChange,
  items = NO_ITEMS,
  onSearch,
  trigger = '@',
  placeholder,
  disabled,
  maxRows,
  onMention,
  emptyMessage = 'No results.',
  loadingMessage = 'Searching…',
  listLabel = 'Mention suggestions',
  name,
  className,
  children,
  ref,
  ...props
}: MentionInputProps) => {
  const id = React.useId();
  const listboxId = `${id}-listbox`;

  const triggers = React.useMemo(() => (Array.isArray(trigger) ? trigger : [trigger]), [trigger]);

  const [value, setValue] = useControllableState({
    prop: valueProp,
    defaultProp: defaultValue ?? '',
    onChange: onValueChange,
  });

  const [mention, setMention] = React.useState<ActiveMention | null>(null);
  const [activeIndex, setActiveIndex] = React.useState(0);
  const [asyncItems, setAsyncItems] = React.useState<MentionItem[]>([]);
  const [loading, setLoading] = React.useState(false);
  const [selectedLabels, setSelectedLabels] = React.useState<string[]>([]);
  const [pos, setPos] = React.useState<MentionPosition>({ top: 0, left: 0 });

  const wrapperRef = React.useRef<HTMLDivElement | null>(null);
  const composedWrapperRef = React.useMemo(() => composeRefs(wrapperRef, ref), [ref]);
  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
  const backdropRef = React.useRef<HTMLDivElement | null>(null);
  const popupRef = React.useRef<HTMLDivElement | null>(null);
  const dismissedRef = React.useRef(false);
  const focusFrameRef = React.useRef<number | null>(null);

  React.useEffect(
    () => () => {
      if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current);
    },
    [],
  );

  const open = mention !== null && !disabled;

  const filtered = React.useMemo(() => {
    if (onSearch) return asyncItems;
    if (!mention) return [];
    const q = mention.query.toLowerCase();

    return items.filter((it) => it.label.toLowerCase().includes(q) || it.description?.toLowerCase().includes(q));
  }, [onSearch, asyncItems, items, mention]);

  const active = Math.min(activeIndex, Math.max(filtered.length - 1, 0));

  const known = React.useMemo(() => {
    const set = new Set<string>();
    for (const it of items) set.add(it.label.toLowerCase());
    for (const label of selectedLabels) set.add(label.toLowerCase());

    return set;
  }, [items, selectedLabels]);

  const segments = React.useMemo(() => segmentValue(value, triggers, known), [value, triggers, known]);

  const activeQuery = mention?.query;
  const activeTrigger = mention?.trigger;

  const onSearchRef = React.useRef(onSearch);
  React.useEffect(() => {
    onSearchRef.current = onSearch;
  });

  const queryKey = `${activeTrigger ?? ''}\u0000${activeQuery ?? ''}`;
  const [lastQueryKey, setLastQueryKey] = React.useState(queryKey);
  if (lastQueryKey !== queryKey) {
    setLastQueryKey(queryKey);
    setActiveIndex(0);
  }

  React.useEffect(() => {
    if (!onSearchRef.current || activeQuery === undefined || activeTrigger === undefined) {
      return;
    }
    let cancelled = false;
    setLoading(true);
    const t = setTimeout(() => {
      onSearchRef.current!(activeQuery, activeTrigger)
        .catch(() => [])
        .then((res) => {
          if (cancelled) return;
          setAsyncItems(res);
          setLoading(false);
        });
    }, 200);

    return () => {
      cancelled = true;
      clearTimeout(t);
    };
  }, [activeQuery, activeTrigger]);

  React.useLayoutEffect(() => {
    const ta = textareaRef.current;
    if (!ta) return;
    ta.style.height = 'auto';
    const style = window.getComputedStyle(ta);
    const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
    const borders = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
    const max = maxRows
      ? lineHeight * maxRows + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) + borders
      : Infinity;
    ta.style.height = `${Math.min(ta.scrollHeight + borders, max)}px`;
    ta.style.overflowY = ta.scrollHeight + borders > max ? 'auto' : 'hidden';
    const backdrop = backdropRef.current;
    if (backdrop) {
      backdrop.scrollTop = ta.scrollTop;
      backdrop.scrollLeft = ta.scrollLeft;
    }
  }, [value, maxRows]);

  React.useLayoutEffect(() => {
    if (!mention) return;
    const ta = textareaRef.current;
    const wrap = wrapperRef.current;
    const pop = popupRef.current;
    if (!ta || !wrap || !pop) return;
    const caret = measureCaret(ta, mention.start);
    const caretTop = caret.top - ta.scrollTop;
    const caretLeft = caret.left - ta.scrollLeft;
    const popRect = pop.getBoundingClientRect();
    const wrapRect = wrap.getBoundingClientRect();
    let top = caretTop + caret.height + 4;
    const spaceBelow = window.innerHeight - (wrapRect.top + top);
    if (popRect.height > spaceBelow && wrapRect.top + caretTop - popRect.height - 4 > 0) {
      top = caretTop - popRect.height - 4;
    }
    setPos({ top, left: Math.max(0, Math.min(caretLeft, wrap.clientWidth - popRect.width)) });
  }, [mention, filtered.length, loading]);

  React.useEffect(() => {
    if (!open) return;
    const close = () => setMention(null);
    window.addEventListener('resize', close);

    return () => window.removeEventListener('resize', close);
  }, [open]);

  React.useEffect(() => {
    if (!open) return;
    const el = document.getElementById(`${id}-option-${active}`);
    el?.scrollIntoView({ block: 'nearest' });
  }, [open, active, id]);

  const [lastText, setLastText] = React.useState(value);
  if (value !== lastText) {
    setLastText(value);
    setSelectedLabels((prev) => {
      if (prev.length === 0) return prev;
      const present = new Set(getMentions(value, triggers).map((m) => m.toLowerCase()));
      const next = prev.filter((label) => present.has(label.toLowerCase()));

      return next.length === prev.length ? prev : next;
    });
  }

  const detect = React.useCallback(
    (text: string, caret: number) => {
      const next = dismissedRef.current ? null : getActiveMention(text, caret, triggers);
      setMention((prev) => {
        if (prev === null && next === null) return prev;
        if (prev && next && prev.start === next.start && prev.trigger === next.trigger && prev.query === next.query) {
          return prev;
        }

        return next;
      });
    },
    [triggers],
  );

  const select = React.useCallback(
    (item: MentionItem) => {
      const ta = textareaRef.current;
      if (!ta || !mention) return;
      const caret = ta.selectionStart;
      const inserted = `${mention.trigger}${item.label} `;
      const next = value.slice(0, mention.start) + inserted + value.slice(caret);
      setValue(next);
      setSelectedLabels((prev) => (prev.includes(item.label) ? prev : [...prev, item.label]));
      onMention?.(item);
      setMention(null);
      const position = mention.start + inserted.length;
      if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current);
      focusFrameRef.current = requestAnimationFrame(() => {
        focusFrameRef.current = null;
        ta.focus();
        ta.setSelectionRange(position, position);
      });
    },
    [mention, value, setValue, onMention],
  );

  const handleKeyDown = React.useCallback(
    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
      if (!open) return;
      // Enter confirming an IME composition must not pick a suggestion.
      if (e.nativeEvent.isComposing || e.keyCode === 229) return;
      if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
        e.preventDefault();
        const n = filtered.length;
        setActiveIndex(n ? (active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n : 0);
      } else if (e.key === 'Enter' || e.key === 'Tab') {
        const item = filtered[active];
        if (item) {
          e.preventDefault();
          select(item);
        }
      } else if (e.key === 'Escape') {
        e.preventDefault();
        dismissedRef.current = true;
        setMention(null);
      }
    },
    [open, filtered, active, select],
  );

  const handleChange = React.useCallback(
    (e: React.ChangeEvent<HTMLTextAreaElement>) => {
      dismissedRef.current = false;
      setValue(e.target.value);
      detect(e.target.value, e.target.selectionStart);
    },
    [setValue, detect],
  );

  const handleSelect = React.useCallback(
    (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
      detect(e.currentTarget.value, e.currentTarget.selectionStart);
    },
    [detect],
  );

  const handleScroll = React.useCallback(
    (e: React.UIEvent<HTMLTextAreaElement>) => {
      const backdrop = backdropRef.current;
      if (backdrop) {
        backdrop.scrollTop = e.currentTarget.scrollTop;
        backdrop.scrollLeft = e.currentTarget.scrollLeft;
      }
      if (open) setMention(null);
    },
    [open],
  );

  const handleBlur = React.useCallback(() => setMention(null), []);

  const textareaProps = React.useMemo<MentionInputCtx['textareaProps']>(
    () => ({
      onChange: handleChange,
      onSelect: handleSelect,
      onScroll: handleScroll,
      onKeyDown: handleKeyDown,
      onBlur: handleBlur,
    }),
    [handleChange, handleSelect, handleScroll, handleKeyDown, handleBlur],
  );

  const ctx = React.useMemo<MentionInputCtx>(
    () => ({
      id,
      listboxId,
      open,
      loading,
      filteredItems: filtered,
      activeIndex: active,
      activeTrigger,
      pos,
      value,
      segments,
      disabled,
      placeholder,
      name,
      emptyMessage,
      loadingMessage,
      listLabel,
      popupRef,
      textareaRef,
      backdropRef,
      select,
      setActiveIndex,
      textareaProps,
    }),
    [
      id,
      listboxId,
      open,
      loading,
      filtered,
      active,
      activeTrigger,
      pos,
      value,
      segments,
      disabled,
      placeholder,
      name,
      emptyMessage,
      loadingMessage,
      listLabel,
      select,
      textareaProps,
    ],
  );

  return (
    <MentionInputContext.Provider value={ctx}>
      <div
        ref={composedWrapperRef}
        data-slot="mention-input"
        data-disabled={disabled || undefined}
        className={cn('relative w-full', className)}
        {...props}
      >
        {children ?? (
          <>
            <MentionInputTextarea />
            <MentionInputList />
          </>
        )}
      </div>
    </MentionInputContext.Provider>
  );
};

type MentionInputTextareaProps = Omit<
  React.ComponentProps<'textarea'>,
  'value' | 'defaultValue' | 'name' | 'placeholder' | 'disabled'
>;

/** The consumer's handler runs first; the part's own is skipped once the event is default-prevented. */
const chainHandlers =
  <E extends React.SyntheticEvent>(theirs: ((event: E) => void) | undefined, ours: ((event: E) => void) | undefined) =>
  (event: E) => {
    theirs?.(event);
    if (!event.defaultPrevented) ours?.(event);
  };

const MentionInputTextarea = ({
  className,
  ref,
  onChange,
  onSelect,
  onScroll,
  onKeyDown,
  onBlur,
  ...props
}: MentionInputTextareaProps) => {
  const {
    id,
    listboxId,
    open,
    filteredItems,
    activeIndex,
    value,
    segments,
    disabled,
    placeholder,
    name,
    backdropRef,
    textareaRef,
    textareaProps,
  } = useMentionInput();
  const composedRef = React.useMemo(() => composeRefs(textareaRef, ref), [textareaRef, ref]);

  return (
    <>
      <div
        ref={backdropRef}
        aria-hidden
        data-slot="mention-input-backdrop"
        className={cn(
          metrics,
          className,
          'pointer-events-none absolute inset-0 overflow-hidden border-transparent text-transparent',
        )}
      >
        {segments.map((seg, i) => (
          <span
            key={i}
            data-slot={seg.mention ? 'mention-input-mention' : undefined}
            className={seg.mention ? 'rounded-[3px] bg-primary/15 box-decoration-clone' : undefined}
          >
            {seg.text}
          </span>
        ))}
        {'​'}
      </div>
      <textarea
        ref={composedRef}
        rows={1}
        name={name}
        value={value}
        placeholder={placeholder}
        disabled={disabled}
        role="combobox"
        aria-expanded={open}
        aria-haspopup="listbox"
        aria-autocomplete="list"
        aria-controls={listboxId}
        aria-activedescendant={open && filteredItems[activeIndex] ? `${id}-option-${activeIndex}` : undefined}
        data-slot="mention-input-textarea"
        {...props}
        onChange={chainHandlers(onChange, textareaProps.onChange)}
        onSelect={chainHandlers(onSelect, textareaProps.onSelect)}
        onScroll={chainHandlers(onScroll, textareaProps.onScroll)}
        onKeyDown={chainHandlers(onKeyDown, textareaProps.onKeyDown)}
        onBlur={chainHandlers(onBlur, textareaProps.onBlur)}
        className={cn(
          metrics,
          'relative resize-none border-input bg-transparent transition-colors outline-none',
          'placeholder:text-muted-foreground',
          'focus-visible:border-ring',
          'disabled:cursor-not-allowed disabled:opacity-50',
          className,
        )}
      />
    </>
  );
};

const MentionInputList = ({ className, style, children, ref, ...props }: React.ComponentProps<'div'>) => {
  const ctx = useMentionInput();
  const composedRef = React.useMemo(() => composeRefs(ctx.popupRef, ref), [ctx.popupRef, ref]);

  if (!ctx.open) return null;

  return (
    <div
      ref={composedRef}
      id={ctx.listboxId}
      role="listbox"
      aria-label={ctx.listLabel}
      data-slot="mention-input-list"
      style={{ ...style, top: ctx.pos.top, left: ctx.pos.left }}
      className={cn(
        'absolute z-50 max-h-60 w-64 overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
        className,
      )}
      {...props}
    >
      {children ??
        (ctx.loading ? (
          <div
            data-slot="mention-input-loading"
            className="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground"
          >
            <Spinner />
            {ctx.loadingMessage}
          </div>
        ) : ctx.filteredItems.length === 0 ? (
          <div data-slot="mention-input-empty" className="px-2 py-2 text-xs text-muted-foreground">
            {ctx.emptyMessage}
          </div>
        ) : (
          ctx.filteredItems.map((item, i) => <MentionInputItem key={item.id} item={item} index={i} />)
        ))}
    </div>
  );
};

interface MentionInputItemProps extends Omit<React.ComponentProps<'div'>, 'children'> {
  item: MentionItem;
  index: number;
  children?: React.ReactNode;
}

const MentionInputItem = ({
  item,
  index,
  className,
  children,
  onMouseDown,
  onClick,
  onMouseMove,
  ...props
}: MentionInputItemProps) => {
  const ctx = useMentionInput();
  const active = index === ctx.activeIndex;

  return (
    <div
      id={`${ctx.id}-option-${index}`}
      role="option"
      aria-selected={active}
      data-slot="mention-input-item"
      data-active={active || undefined}
      onMouseDown={(e) => {
        onMouseDown?.(e);
        if (e.defaultPrevented) return;
        e.preventDefault();
      }}
      onClick={(e) => {
        onClick?.(e);
        if (e.defaultPrevented) return;
        ctx.select(item);
      }}
      onMouseMove={(e) => {
        onMouseMove?.(e);
        if (e.defaultPrevented) return;
        ctx.setActiveIndex(index);
      }}
      className={cn(
        'flex cursor-default flex-col gap-0.5 rounded-sm px-2 py-1.5',
        active && 'bg-accent text-accent-foreground',
        className,
      )}
      {...props}
    >
      {children ?? (
        <>
          <bdi className="text-sm leading-none">
            {ctx.activeTrigger}
            {item.label}
          </bdi>
          {item.description && <span className="truncate text-xs text-muted-foreground">{item.description}</span>}
        </>
      )}
    </div>
  );
};

export { MentionInput, MentionInputTextarea, MentionInputList, MentionInputItem };

Dependencies

shadcn registry

spinnercompose-refsuse-controllable-state