Mention

A text field with @-mentions: typing the trigger opens a list of people, picking one inserts @Name as a single block that deletes as a whole, and the component tells you who was mentioned. It is the composer of a Chat thread and the comment box on a task.

Import
Mention, MentionLabel, MentionInput, MentionContent and MentionItem from the main entry, @comitor/ui.
Install
Nothing extra — no optional peer. The headless core, @diceui/mention, is an ordinary dependency of the package, and so is @floating-ui/react, which its shared layer imports.
Runtime
A client component ("use client"). Keep the composer inside a client component of your own — onFilter and the change handlers are functions, and functions cannot cross from a Server Component.
Origin
Added in 2.1.0, ported from Dice UI (MIT, © 2024 Sadman Sakib) onto the package's own primitives and tokens. What changed on the way is below and in the changelog.

Anatomy

What comes back is two separate things: the text, through onInputValueChange, and the list of mentioned item values, through value / onValueChange. Where each name sits in the text is kept inside the component and never handed out. Upstream's separate portal part is built into MentionContent.

anatomy.tsx
import { Mention, MentionContent, MentionInput, MentionItem, MentionLabel } from '@comitor/ui'

<Mention onValueChange={setMentioned}>
  {/* A <label> wired to the field */}
  <MentionLabel>Comment</MentionLabel>

  {/* role="combobox". asChild + a <textarea> for a multi-line composer. */}
  <MentionInput placeholder="Type @ to mention someone…" />

  {/* role="listbox", portalled to <body>, anchored at the caret. The portal is built in. */}
  <MentionContent>
    {people.map((person) => (
      // value is what you get back; label is what gets inserted after the @
      <MentionItem key={person.id} value={person.id} label={person.name}>
        {person.name}
      </MentionItem>
    ))}
  </MentionContent>
</Mention>

Mentioning teammates in a task comment

Type @ and a few letters of a name — accents optional, so tran finds Trần Quang Minh and duc finds Lê Văn Đức. ↓ and ↑ move through the list, Enter or a click inserts, and Backspace against an inserted name removes all of it. Under the field: the raw value, and the de-duplicated list of people to notify.

value
[]
notify
—
  • value holds item values, not names. Give each MentionItem a stable id as value and the display name as label; label is the text inserted after the @.
  • The built-in filter searches the label. It looks for the typed text anywhere in each item's label — its value when there is no label — ignoring accents and case, with the package's own matchesSearch, the rule the Combobox follows. That is why the demo passes no onFilter. Before 3.1.0 it searched the value — an id here — and kept accents, so apps passed an onFilter that mapped ids to names; that workaround can go.
  • The query ends at the first space, so people type part of a name (@tran), never @Trần Quang Minh — matching anywhere in the name, not only at its start, is what makes that work. To narrow past the first word, type the words together: the built-in filter also tries each label without its spaces, so @tranquang finds Trần Quang Minh.
  • The list only opens when the trigger starts the text or follows a space or a line break. An address such as [email protected] typed mid-sentence leaves it closed.
  • value is a log of insertions: mention Anna twice and she is in it twice. Notify from new Set(value).
  • It is multi-line because MentionInput takes asChild and hands its behaviour to your <textarea>. The inserted-name blocks are an overlay that copies the field's font, padding and scroll position.

A different trigger: # for channels

trigger is a single string, so one field listens for one character. This chat composer links channels with # and reports channel ids. A composer that wants both @people and #channels in the same text cannot get it from one Mention — the core tracks exactly one trigger.

value: []

  • Searching more than the label. The built-in filter would find channels by name on its own; this composer passes onFilter to search their topics too — #asia finds sales-apac, #tuesday finds design-review. onFilter is handed each item's value, not its label, so it looks the channel up by id, and matchesSearch keeps the built-in rules for accents and case.
  • The trigger is read once, when the field mounts; changing the prop afterwards does nothing. To switch a live field, remount it: <Mention key={trigger} trigger={trigger}>.
  • A single-line <input> works the same way — the list is anchored at the caret either way, measured from the text in front of it.

In context: a comment thread with Send

A task's thread in a Card. Enter sends, Shift+Enter starts a new line, and Enter still picks a person while the list is open. Mention someone, send, and look at the Notified line under your comment.

TSK-2041 · Retry failed card payments

Payments · due Friday

  1. Phạm Thùy Linh

    The customer confirmed the retry window is 24 hours, not 48. Can someone update the spec before Friday?

  • Guard Enter-to-send. Your onKeyDown on MentionInput runs first, and preventDefault() makes the component skip its own handling. Send only while the list is closed — track it with onOpenChange — and while no IME composition is in progress (nativeEvent.isComposing; Vietnamese Telex and VNI compose). Otherwise Enter posts the message instead of picking the highlighted person.
  • Reset by remounting. There is no reset API: the positions of inserted names live inside the component, and the field is uncontrolled, so inputValue cannot empty it. A new key clears the text, the value and those positions together; the demo then puts focus back in the new field.
  • Before notifying, the demo keeps only the ids whose @Name is still in the text — see Known limitations for how value goes stale.

What the port guarantees

Detecting the trigger, running the filter, the keyboard and positioning at the caret are the headless core's; the package's file is a styled wrapper that also supplies the default filter. These are the places where it deliberately does not match upstream.

  • aria-controls points at the list. Upstream's field referenced an id generated in the root that no element carried, so the combobox pointed at nothing. The port generates one id and puts it on both ends.
  • The list has a name. ARIA 1.2 requires one on role="listbox" and upstream set none. It is Suggestions by default, from the mention namespace; aria-label on MentionContent wins.
  • The built-in filter matches names. The core's own filter compares the typed text with each item's value — usually an id — loosely, and keeps accents: @nguyen found no Nguyễn, @tran found nobody. Without onFilter the port matches the label instead, ignoring accents and case, as the Combobox does; onFilter still replaces it. Since 3.1.0.
  • Right-to-left follows your DirectionProvider. @diceui/shared keeps a private direction context that Radix's DirectionProvider — where every other component here gets its direction — cannot reach. The port reads Radix's direction and passes it down as dir.
  • Inserted names use the package's tokens. Upstream painted the block bg-blue-200 text-blue-950 straight from Tailwind's palette. The port uses the info pair — teal, the package's blue — as bg-info/15. The block is drawn by an overlay laid over the field, with transparent text everywhere except the names; the name's text is painted over the field's own copy of it, so the two must be the same colour or every glyph gets a fringe. That is why it is text-foreground, not text-info-ink: 15.69:1 in light, 15.15:1 in dark, 13.44:1 at worst (high-contrast dark). Against the page the block is 1.21:1 — deliberately faint — and it never relies on colour alone: it always starts with the trigger.
  • Surfaces match the rest of the package. MentionInput wears Input's border and focus recipe (upstream: ring-1 ring-ring and a shadow-xs) with Textarea's background — transparent, and --input-fill in dark. MentionContent is the popover surface with shadow-overlay, and it grows out of the caret side (origin-(--dice-transform-origin)) instead of from its centre. MentionItem uses the command-menu row's classes and density padding.

Language

The label, the placeholder and every row in the list are yours, so they follow your own copy. The one string the package owns is the list's accessible name — invisible, so the preview reads it back from the DOM when the list opens. Switch the language, then type @.

Demo language

The open list's accessible name, read from the DOM: — type @ to open it

  • Namespace mention, one key: listLabel — Suggestions in English, Danh sách gợi ý in the built-in Vietnamese table (comitorMessagesVi.mention). It is a plain string — no key here is a function — so it can also be overridden from a Server Component through the provider's messages.
  • Precedence: aria-label on MentionContent → the nearest ComitorLocaleProvider → English. There is no labels prop and no DEFAULT_*_LABELS constant for this component.
  • The list is portalled to the end of <body>, outside any wrapper you set lang on — one more reason the attribute belongs on <html>.
  • Where the provider goes, and how locale is chosen: Get started → Set the language.

Props

Mention — the headless core's props, plus the port's dir bridge. Every other div attribute passes through.

PropTypeDefaultDescription
valuestring[]—The values of the items mentioned so far, in the order they were inserted — repeats included. Controlled.
defaultValuestring[][]Starting value when uncontrolled.
onValueChange(value: string[]) => void—Called after an insert, and after Backspace, Delete or Cut removes an inserted name.
onInputValueChange(value: string) => void—Called with the full text on every change. This is how you read what was typed.
inputValuestring—Not a way to set the text: the field is uncontrolled and this value never reaches it. The component diffs against it to keep inserted names in place, so leave it unset.
open / defaultOpen / onOpenChangeboolean / boolean / (open: boolean) => voidfalseWhether the list is open. onOpenChange is what a composer watches so that Enter never sends while a person is highlighted.
triggerstring'@'The character that opens the list. One per field, read once when the field mounts — later changes are ignored, so remount with a key to switch.
onFilter(options: string[], term: string) => string[]—Replaces the built-in filter, which looks for the term anywhere in each item's label, ignoring accents and case. Called once per item, with [value] — not the label — and the text typed after the trigger; return the values to keep. An item whose value equals or starts with the term is kept before onFilter is asked.
exactMatchbooleanfalseNo effect since 3.1.0: the core reads it only when no filter is passed, and the port always passes one. The built-in filter matches substrings — what exactMatch used to ask for — never loose matches such as dsgn for design-review.
loopbooleanfalse↓ and ↑ wrap around the list.
modalbooleanfalseWhile the list is open: page scroll is locked, pointer events outside are blocked, and Tab inserts the highlighted item instead of closing the list.
disabledbooleanfalseDisables the field; the list never opens.
readonlybooleanfalseLower-case, as the headless core spells it — not readOnly. The field becomes read-only and the list never opens.
requiredbooleanfalseSet on the hidden form input only, which browsers never validate. Nothing blocks an empty submit.
namestring—Inside a <form>, a hidden input posts the mentioned values as a JSON array — the ids, not the text. Name the field itself to post the text.
dir'ltr' | 'rtl'DirectionProvider, else 'ltr'Reading direction. Unset, the port reads Radix's DirectionProvider; under 'rtl' the list's alignment is mirrored.
classNamestring—Merged onto the root div, which also carries the styling of inserted names (the **:data-tag classes) — keep it on the root.

MentionInput — every input attribute, and:

PropTypeDefaultDescription
asChildbooleanfalsePut the field's behaviour on your own child — a <textarea> for a composer. Pass extra classes to MentionInput rather than the child, so they merge with tailwind-merge instead of simply concatenating.
placeholderstring—No default. Say that the trigger exists: “Type @ to mention someone…”.
aria-labelstring—Names the field when there is no MentionLabel. The field still points aria-labelledby at the missing label's id, so a visually hidden MentionLabel (className='sr-only') is the cleaner fix.
namestring—Posts the text itself with a form — the root's name posts only the ids.
onKeyDown / onChange / onPaste…event handlers—Yours run before the component's. Calling event.preventDefault() makes the component skip its own handling — which is exactly what an unguarded Enter-to-send does to Enter-to-pick.

MentionContent — positioning is anchored to the caret, not to an element:

PropTypeDefaultDescription
aria-labelstring'Suggestions'The listbox's name. Unset, it comes from the mention namespace (Danh sách gợi ý in Vietnamese).
side'top' | 'right' | 'bottom' | 'left''bottom'Preferred side of the caret. It flips when there is no room (avoidCollisions).
sideOffsetnumber4Gap in pixels between the caret line and the list.
align'start' | 'center' | 'end''start'Alignment against the caret; mirrored under dir='rtl'.
alignOffsetnumber0Shift along the aligned edge.
avoidCollisions / collisionPaddingboolean / numbertrue / 0Keep the list inside the viewport (or collisionBoundary), with padding.
fitViewportbooleanfalseCap the list at the space available in the viewport.
forceMountbooleanfalseKeep the list in the DOM while closed (hidden). Items only register — and so only take part in filtering — while mounted.
onEscapeKeyDown / onPointerDownOutside(event) => void—Call event.preventDefault() to keep the list open.
classNamestring—The list has no height limit. For a long directory add max-h-64 overflow-auto; the highlighted item scrolls into view.

MentionItem — any content inside; the row is a role="option" div:

PropTypeDefaultDescription
valuerequiredstring—A stable id — what value reports, and what onFilter is handed. An empty string throws when the item mounts.
labelstringvalueThe text inserted after the trigger — @Anna Nguyen — and the text the built-in filter searches. The visible row is your children.
disabledbooleanfalseShown dimmed, skipped by the arrow keys, and ignores clicks.

MentionLabel is a <label> already wired to the field.

Known limitations

All of these are in the headless core; the workaround is what an app can do today.

  • One trigger per field, fixed at mount. @ and # cannot share a field. Remount with a key to switch.
  • The query ends at the first space. No prop changes it, so a name of several words is typed together — @tranquang — which the built-in filter matches against the label without its spaces. An onFilter of your own has to allow for that itself.
  • An item whose value starts with the query is always kept — the core keeps it before any filter, built-in or yours, is asked. With ids like u-anna, @u lists everyone. Harmless as long as people do not type the start of your ids.
  • A pasted @Name stays text unless an item's value is that name: the core matches pasted mentions against values, not labels. With ids as values, a mention copied from another message is neither a block nor in value.
  • No empty state. When nothing matches, the list simply closes, and it re-opens on the next keystroke that matches something. There is nowhere to say “Nobody by that name”.
  • No asynchronous search. Filtering runs on each keystroke over the items rendered at that moment, so results that arrive after the user stops typing stay hidden until the text or the caret moves again. Load the candidates up front — the project's members, the channel's members — rather than per keystroke.
  • No height limit. The list is as tall as its matches. For a large team give MentionContent max-h-64 overflow-auto; the highlighted row scrolls into view.
  • value can drift from the text. It lists repeats; deleting one of two mentions of the same person removes that person from value entirely, and the other mention stops behaving as a block; typing over a selection that contains a name leaves its id behind. Before notifying, de-duplicate and keep only the ids whose @Name is still in the text — the thread demo does.
  • No reset. inputValue does not set the text; remount with a key after sending.
  • Form fields are half the story. The root's name posts only the ids, required is not enforced, and the read-only flag is spelled readonly. Name the field itself for the text and validate before you send.

Related

  • Combobox — choosing an assignee in a form field, from a fixed list, with a trigger button rather than inline text.
  • Tags Input — a list of recipients as tags rather than names inside a message.
  • Patterns where it belongs: Chat (the composer), Tasks (comments on a task) and Social (the post composer).

Accessibility

Focus stays in the field the whole time; the list is navigated through it.

KeyWhenWhat happens
@At the start of the text, or after a space or line breakOpens the list, filtered by what you type next. Inside a word — [email protected] — it does nothing.
↓↑List openNext or previous suggestion; the first or last when nothing is highlighted.
HomeEndList openFirst or last suggestion.
EnterList openInserts the highlighted suggestion and a space after it; closes the list if nothing is highlighted.
TabList openCloses the list and moves on — or inserts, with modal.
EscList openCloses the list.
SpaceTyping a queryEnds the query and closes the list.
BackspaceAgainst or inside an inserted nameDeletes the whole name, and the space after it.
←→Next to an inserted nameJumps over it; with Ctrl or ⌘, to its edges.
BackspaceDeleteA selection that touches an inserted nameDeletes the selection, and those people leave value. Cut does the same.
  • The field is a role="combobox" with aria-autocomplete="list", aria-expanded, aria-controls pointing at the list (the port's fix) and aria-activedescendant following the highlight — so the highlighted person is announced while typing carries on.
  • The list is a named role="listbox" of role="option" rows. aria-selected marks people already mentioned in this text, not the highlighted row.
  • An option is named by its whole content. LetterAvatar carries a hidden copy of the name for screen readers, so next to a visible name it is read twice — the demos mark it aria-hidden="true". Keep a secondary line such as the role short; it is read with every highlight.
  • The list is portalled to the end of <body> and never takes focus; clicking a suggestion inserts it and puts focus back in the field.
  • MentionLabel labels the field through htmlFor and aria-labelledby — and the field carries that aria-labelledby whether or not the label is rendered. Where a design has no visible label, render it with className="sr-only", as the thread demo does. An aria-label alone still names the field, but leaves the reference dangling for an audit to flag.
  • In a <form>, the root's name posts the mentioned values as JSON from a hidden input; the text needs a name of its own. required is not enforced.
  • An inserted name never relies on colour: it always starts with the trigger character, and its text holds 13.44:1 or better in every palette.
  • Still yours: a label (visible, or sr-only), a placeholder or hint that says the trigger exists, guarding Enter-to-send, checking value against the text before notifying anyone, and the lang attribute on <html>.
  • MentionInput is 16px below md and 14px from md up, like every field since 3.13.4 — so a MentionInput asChild over a <textarea> needs no text class to match a Textarea. More on Input.