# Mention Input

> A React mention input where @ mentions people, # adds tags and any trigger you define inserts chips, stored as markup that survives renames.

- Type: React component (Inputs)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add mention-input`
- Docs: https://wingo-ui.com/components/mention-input

## Interaction

Type @ at the start of a word and a list of people opens at the caret; keep typing (spaces allowed, so “Daniel Re” still filters, accents and case ignored) and the matching letters turn bold. Up and Down move through the list and wrap, PageUp and PageDown jump by five, Home and End go to the ends while the list is open, Enter or Tab inserts, a click or a tap inserts without the field losing focus. The mention lands as a tinted chip followed by a space, and the caret goes after it. Type # for tags: when nothing matches exactly the last row offers “Create #…”. Escape closes the list first; a second Escape reaches the dialog or sheet around the field. A mention is one piece: Backspace right after it or Delete right before it removes all of it, Left and Right step over it in one press, a click, a tap or Up / Down that would land inside it puts the caret at its nearer edge, a double-click or a drag that starts inside it selects all of it, and typing over a selection that cuts it removes it. Undo brings a removed chip back. Copying text with chips and pasting it into another mention field keeps the chips. ⌘ Enter (Ctrl Enter elsewhere) submits; the playground’s Save note adds the note below, rendered with MentionText. With maxLength a counter sits in the box and shakes when an insert would not fit. On a phone the text is 16px, rows are at least 44px and the list attaches to the edge of the box, flipping above it when the keyboard would cover it, so the keyboard never closes. Screen readers hear the plain text (“@Daniel Reed”), the highlighted suggestion as it moves, “No matches” when nothing fits, and a short note when a mention is added or removed.

## Installation

```bash
npx wingo-ui@latest add mention-input
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/mention-input` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/mention-input.tsx`

npm dependencies: `npm i motion`

Wingo UI dependencies (installed automatically):

- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) ([Markdown](https://wingo-ui.com/components/use-keyboard-inset.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { MentionInput, MentionText, getMentions, type MentionItem } from "@/components/ui/mention-input"

const team: MentionItem[] = [
  { id: "u-daniel", label: "Daniel Reed", avatar: "/demo/mihai-ionescu.jpg", description: "Sales manager" },
  { id: "u-sophia", label: "Sophia Lee", avatar: "/demo/ioana-dumitrescu.jpg", description: "Accounting" },
]
const tags: MentionItem[] = [
  { id: "t-follow-up", label: "follow-up" },
  { id: "t-quote", label: "quote" },
]

export function ClientNote() {
  const [note, setNote] = useState("Call @[Daniel Reed](u-daniel) tomorrow #[follow-up](t-follow-up)")
  const [saved, setSaved] = useState<string[]>([])
  return (
    <div className="flex flex-col gap-3">
      <MentionInput
        label="New note"
        value={note}
        onValueChange={setNote}
        people={team}
        tags={tags}
        placeholder="Write a note. @ for teammates, # for tags"
        onSubmit={(value) => {
          console.log("notify", getMentions(value).map((m) => m.id))
          setSaved((prev) => [value, ...prev])
          setNote("")
        }}
      />
      {saved.map((value, i) => (
        <MentionText key={i} value={value} className="text-sm" />
      ))}
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue?` | `string` | `""` | The starting markup while uncontrolled. Mentions are trigger + [label](id): @[Daniel Reed](u-daniel), #[follow-up](t-follow-up); brackets and backslashes inside are escaped with \. The playground falls back to a sample note when it is empty. |
| `value?` | `string` |  | The markup, to control it from outside; pair it with onValueChange. A change from outside while typing keeps the caret at the same plain-text offset. |
| `onValueChange?` | `(value: string, context: MentionChangeContext) => void` |  | Called with the new markup on every change, plus a context { text; mentions: MentionRef[] }: the plain text people see and the mentions with their offsets (for notifications). |
| `onChange?` | `(event: ChangeEvent<HTMLTextAreaElement \| HTMLInputElement>) => void` |  | The native change event of the control, untouched. Its value is the plain text, not the markup. |
| `people?` | `MentionItem[]` |  | Shorthand for the @ trigger: { id; label; avatar?; description?; icon?; keywords?; disabled? }. Rows show the avatar (initials without a photo) and the description under the name; spaces are allowed in the query. |
| `tags?` | `MentionItem[]` |  | Shorthand for the # trigger. Rows show a # tile unless an item has its own icon. |
| `triggers?` | `MentionTrigger[]` |  | Full control: { char; items?; onSearch?(query) => Promise<MentionItem[]>; allowCreate?; onCreate?(label); tone?; color?; title?; minChars?; maxResults? (8); allowSpaces? }. char is one symbol or punctuation character. Entries win over people / tags for the same char, field by field, so { char: "@", tone: "info" } keeps the people list. onSearch is debounced 200ms and drops stale answers. |
| `allowCreateTags?` | `boolean` | `true` | The # list ends with “Create #query” when nothing matches exactly. The created tag (onCreate's result, by default { id: label, label }) is inserted and offered again later. |
| `multiline?` | `boolean` | `true` | A textarea that grows with the text from minRows to maxRows; false renders a single-line input for task titles, where the text scrolls sideways and the chips follow. |
| `minRows?` | `number` | `3` | Height of the empty textarea in rows. |
| `maxRows?` | `number` | `8` | The textarea grows up to this many rows, then scrolls inside; the chips scroll with the text. |
| `placeholder?` | `string` | `"Write a note. Type @ to mention someone, # to add a tag"` | Shown while the field is empty; it is the best place to teach the triggers. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | The field sizes: text 13 / 14 / 15px (16px on phones, so iOS never zooms) and one row as tall as an input of the same size. |
| `variant?` | `"soft" \| "outline" \| "ghost"` | `"soft"` | soft is a filled well that works on every surface, outline a bordered box, ghost invisible until hovered (inline notes). |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Corner shape, scaled per size like the button's (md is 8 / 10 / 12px). full is a pill on one line and half a row height on a taller box. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The chip tint of every trigger without its own tone or color. Neutral is a quiet gray in both themes. |
| `color?` | `string` | `""` | Any CSS color for the chips; replaces the tone. The tint is mixed at 14% so the text on it stays readable. |
| `label?` | `ReactNode` | `""` | Renders the field anatomy with a label linked to the control. Leave it empty for a bare box. |
| `description?` | `ReactNode` | `""` | Helper text under the box, linked with aria-describedby. The error takes its place while it shows. |
| `error?` | `ReactNode` | `""` | Replaces the description, slides in and marks the box invalid. |
| `required?` | `boolean` | `false` | The required mark after the label and required on the control. |
| `maxLength?` | `number` | `0` | A limit on the plain text (a chip counts as its visible text). A counter sits in the box, turns amber at 90% and red at the limit, and shakes when a mention would not fit (the insert is refused). |
| `suggestionsPlacement?` | `"auto" \| "caret" \| "below" \| "above"` | `"auto"` | auto opens the list at the caret with a mouse and attaches it to the box edge on touch, above the box when the keyboard would cover the room below. With a mouse the list is portaled above everything, so no card or dialog body ever cuts it. On touch it renders in flow against the box, so it rides every scroll with the field while the keyboard is up; when the parents that clip it (an overflow-hidden card, a sheet or dialog body) leave it too little room, it is portaled instead. A parent that isolates without clipping needs z-dropdown so the in-flow list paints over what follows it. |
| `onSubmit?` | `(value: string, context: MentionChangeContext) => (void \| Promise<unknown>)` |  | Runs on submitShortcut with the markup and { text, mentions }. A promise locks the field (read-only, dimmed, aria-busy) until it settles. Without it the shortcut submits the surrounding form. |
| `submitShortcut?` | `string` | `"mod+enter"` | A combo in mod+k notation, bound on the field only while it has focus. "enter" makes it chat style (a bare Enter still makes new lines on touch keyboards); "" turns it off. |
| `disabled?` | `boolean` | `false` | Dims the field and blocks typing. |
| `readOnly?` | `boolean` | `false` | The text and its chips stay visible and selectable; no suggestions open. |
| `invalid?` | `boolean` | `false` | The invalid look and aria-invalid without a message. |
| `autoFocus?` | `boolean` | `false` | Focuses the field on mount with the caret at the end of any starting text. |
| `name?` | `string` | `""` | Adds a hidden input with the markup, so a native form posts the mentions, not just the text. |
| `labels?` | `Partial<MentionInputLabels>` |  | suggestions “Suggestions”, people “People”, tags “Tags”, create (label, trigger) => “Create “#label””, empty “No matches”, loading “Searching…”, mentioned (label) => “label mentioned”, removed (label) => “label removed”, counter (count, max) => “count of max”, limitReached “Character limit reached”. Exported as DEFAULT_MENTION_INPUT_LABELS. |
| `classNames?` | `Partial<Record<"field" \| "label" \| "description" \| "error" \| "box" \| "control" \| "highlighter" \| "chip" \| "suggestions" \| "option" \| "counter", string>>` |  | Extra classes for the parts: field, label, description and error style the field anatomy, the rest the box and the list. The highlighter and the control must keep the same font, padding and line height, so style both or neither. |
| `className?` | `string` |  | Extra classes for the field root (the box when there is no label, description or error). |
| `ref?` | `Ref<HTMLTextAreaElement \| HTMLInputElement> (a useRef object or an (el) => void callback)` |  | The native control. Every other prop (id, aria-*, onKeyDown, onFocus, enterKeyHint) is spread onto it too. |

## Parts

### MentionText (component)

Renders a stored value read-only with the same chips, for comments, audit rows and approval history. Mentions of highlightIds (the current user) take highlightTone; renderMention can wrap a chip in a link. Props: { value: string; triggers?; tone?; color?; highlightIds?: string[]; highlightTone? ("brand"); renderMention?(mention, chip) }.

### parseMentionMarkup (function)

Splits markup into { type: "text"; text } and { type: "mention"; trigger; id; label } segments. Unknown ids still parse; empty labels are dropped. Signature: (value: string) => MentionSegment[].

### getMentions (function)

Every mention with { trigger, id, label, start, end } in plain-text offsets, e.g. to notify the people mentioned. Signature: (value: string) => MentionRef[].

### mentionsToText (function)

The plain text people read: “Call @Daniel Reed tomorrow”. For previews, search indexes and emails. Signature: (value: string) => string.

### createMentionMarkup (function)

The markup of one mention, escaped, to build a value on the server or prefill a reply. Signature: (trigger: string, item: { id: string; label: string }) => string.

### serializeMentionSegments (function)

The inverse of parseMentionMarkup: segments back to markup, with text that looks like markup escaped. Signature: (segments: MentionSegment[]) => string.

### DEFAULT_MENTION_INPUT_LABELS (constant)

The English strings of the suggestion list, the create row, the counter and the screen reader announcements. Spread it and override what you translate.

## Examples

- **Single line**: multiline={false} for a task title; Enter adds the task to the list.
- **Async search**: onSearch over 40 colleagues with a 400ms request: a spinner while it loads, “No results” when nothing matches.
- **Custom trigger**: $ links invoices, quotes and contracts, with its own icons and the brand tone.
- **Tags with create**: Tags only, in the info tone; an unknown tag offers “Create “#…”” and is inserted.
- **Limit and error**: maxLength={280} with the counter in the box, and an error until someone is mentioned.
- **MentionText**: Three stored notes rendered read-only; mentions of you (Emma Carter) stand out in the brand tone.

## See also

- [Textarea](https://wingo-ui.com/components/textarea) ([Markdown](https://wingo-ui.com/components/textarea.md))
- [Comment Thread](https://wingo-ui.com/components/comment-thread) ([Markdown](https://wingo-ui.com/components/comment-thread.md))
- [Tag Input](https://wingo-ui.com/components/tag-input) ([Markdown](https://wingo-ui.com/components/tag-input.md))
- [Combobox](https://wingo-ui.com/components/combobox) ([Markdown](https://wingo-ui.com/components/combobox.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))

---

Canonical: https://wingo-ui.com/components/mention-input
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
