Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.
Composites
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,MentionContentandMentionItemfrom 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 —onFilterand 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.
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
- —
valueholds item values, not names. Give eachMentionItema stable id asvalueand the display name aslabel;labelis the text inserted after the@.- The built-in filter searches the label. It looks for the typed text anywhere in each item's
label— itsvaluewhen 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 noonFilter. Before 3.1.0 it searched the value — an id here — and kept accents, so apps passed anonFilterthat 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@tranquangfinds 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. valueis a log of insertions: mention Anna twice and she is in it twice. Notify fromnew Set(value).- It is multi-line because
MentionInputtakesasChildand 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
onFilterto search their topics too —#asiafinds sales-apac,#tuesdayfinds design-review.onFilteris handed each item's value, not its label, so it looks the channel up by id, andmatchesSearchkeeps 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
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
onKeyDownonMentionInputruns first, andpreventDefault()makes the component skip its own handling. Send only while the list is closed — track it withonOpenChange— 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
inputValuecannot empty it. A newkeyclears 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
valuegoes 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-controlspoints 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 thementionnamespace;aria-labelonMentionContentwins. - 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:@nguyenfound no Nguyễn,@tranfound nobody. WithoutonFilterthe port matches the label instead, ignoring accents and case, as the Combobox does;onFilterstill replaces it. Since 3.1.0. - Right-to-left follows your DirectionProvider.
@diceui/sharedkeeps a private direction context that Radix'sDirectionProvider— where every other component here gets its direction — cannot reach. The port reads Radix's direction and passes it down asdir. - Inserted names use the package's tokens. Upstream painted the block
bg-blue-200 text-blue-950straight from Tailwind's palette. The port uses theinfopair — teal, the package's blue — asbg-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 istext-foreground, nottext-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.
MentionInputwears Input's border and focus recipe (upstream:ring-1 ring-ringand ashadow-xs) with Textarea's background — transparent, and--input-fillin dark.MentionContentis the popover surface withshadow-overlay, and it grows out of the caret side (origin-(--dice-transform-origin)) instead of from its centre.MentionItemuses 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'smessages. - Precedence:
aria-labelonMentionContent→ the nearestComitorLocaleProvider→ English. There is nolabelsprop and noDEFAULT_*_LABELSconstant for this component. - The list is portalled to the end of
<body>, outside any wrapper you setlangon — one more reason the attribute belongs on<html>. - Where the provider goes, and how
localeis 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.
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
value | string[] | — | The values of the items mentioned so far, in the order they were inserted — repeats included. Controlled. |
defaultValue | string[] | [] | 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. |
inputValue | string | — | 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 / onOpenChange | boolean / boolean / (open: boolean) => void | false | Whether the list is open. onOpenChange is what a composer watches so that Enter never sends while a person is highlighted. |
trigger | string | '@' | 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. |
exactMatch | boolean | false | No 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. |
loop | boolean | false | ↓ and ↑ wrap around the list. |
modal | boolean | false | While the list is open: page scroll is locked, pointer events outside are blocked, and Tab inserts the highlighted item instead of closing the list. |
disabled | boolean | false | Disables the field; the list never opens. |
readonly | boolean | false | Lower-case, as the headless core spells it — not readOnly. The field becomes read-only and the list never opens. |
required | boolean | false | Set on the hidden form input only, which browsers never validate. Nothing blocks an empty submit. |
name | string | — | 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. |
className | string | — | 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:
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
asChild | boolean | false | Put 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. |
placeholder | string | — | No default. Say that the trigger exists: “Type @ to mention someone…”. |
aria-label | string | — | 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. |
name | string | — | 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:
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
aria-label | string | '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). |
sideOffset | number | 4 | Gap in pixels between the caret line and the list. |
align | 'start' | 'center' | 'end' | 'start' | Alignment against the caret; mirrored under dir='rtl'. |
alignOffset | number | 0 | Shift along the aligned edge. |
avoidCollisions / collisionPadding | boolean / number | true / 0 | Keep the list inside the viewport (or collisionBoundary), with padding. |
fitViewport | boolean | false | Cap the list at the space available in the viewport. |
forceMount | boolean | false | Keep 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. |
className | string | — | 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:
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
valuebắt buộc | string | — | A stable id — what value reports, and what onFilter is handed. An empty string throws when the item mounts. |
label | string | value | The text inserted after the trigger — @Anna Nguyen — and the text the built-in filter searches. The visible row is your children. |
disabled | boolean | false | Shown 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 akeyto 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. AnonFilterof 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,@ulists everyone. Harmless as long as people do not type the start of your ids. - A pasted @Name stays text unless an item's
valueis 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 invalue. - 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
MentionContentmax-h-64 overflow-auto; the highlighted row scrolls into view. valuecan drift from the text. It lists repeats; deleting one of two mentions of the same person removes that person fromvalueentirely, 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.
inputValuedoes not set the text; remount with akeyafter sending. - Form fields are half the story. The root's
nameposts only the ids,requiredis not enforced, and the read-only flag is spelledreadonly. 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.
| Key | When | What happens |
|---|---|---|
| @ | At the start of the text, or after a space or line break | Opens the list, filtered by what you type next. Inside a word — [email protected] — it does nothing. |
| ↓↑ | List open | Next or previous suggestion; the first or last when nothing is highlighted. |
| HomeEnd | List open | First or last suggestion. |
| Enter | List open | Inserts the highlighted suggestion and a space after it; closes the list if nothing is highlighted. |
| Tab | List open | Closes the list and moves on — or inserts, with modal. |
| Esc | List open | Closes the list. |
| Space | Typing a query | Ends the query and closes the list. |
| Backspace | Against or inside an inserted name | Deletes the whole name, and the space after it. |
| ←→ | Next to an inserted name | Jumps over it; with Ctrl or ⌘, to its edges. |
| BackspaceDelete | A selection that touches an inserted name | Deletes the selection, and those people leave value. Cut does the same. |
- The field is a
role="combobox"witharia-autocomplete="list",aria-expanded,aria-controlspointing at the list (the port's fix) andaria-activedescendantfollowing the highlight — so the highlighted person is announced while typing carries on. - The list is a named
role="listbox"ofrole="option"rows.aria-selectedmarks people already mentioned in this text, not the highlighted row. - An option is named by its whole content.
LetterAvatarcarries a hidden copy of the name for screen readers, so next to a visible name it is read twice — the demos mark itaria-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. MentionLabellabels the field throughhtmlForandaria-labelledby— and the field carries thataria-labelledbywhether or not the label is rendered. Where a design has no visible label, render it withclassName="sr-only", as the thread demo does. Anaria-labelalone still names the field, but leaves the reference dangling for an audit to flag.- In a
<form>, the root'snameposts the mentioned values as JSON from a hidden input; the text needs anameof its own.requiredis 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
valueagainst the text before notifying anyone, and thelangattribute on<html>. MentionInputis 16px belowmdand 14px frommdup, like every field since 3.13.4 — so aMentionInput asChildover a<textarea>needs no text class to match a Textarea. More on Input.