Inputs
Mention Input
A plain-text textarea where a trigger character (default @) opens a caret-anchored, filtered picker — mentions, or repurposed for slash commands.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/mention-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MentionInput" component (no extra npm
dependencies).
Contract
- Export a forwardRef textarea component. Props: value (string, controlled),
onValueChange(value), items (MentionItem[] = { id, label, description?,
avatar? }), trigger? (string, default "@"), onMention?(item), maxRows?
(number, default 6), emptyText? (default "No matches found"), label?
(renders an associated <label>), disabled?, plus native textarea props
(value/onChange omitted in favor of value/onValueChange).
Behavior
- Mention mode opens when `trigger` is typed at a word boundary (start of
text, or preceded by whitespace) and stays open while the characters typed
since then contain no whitespace — that run is the query. Any whitespace,
a newline, Escape, or blur closes mention mode (blur only fires on a real
focus-away; clicking a row prevents it via mousedown preventDefault).
- Filter items by a case-insensitive SUBSEQUENCE match of the query against
each item's label (not a plain substring) — recompute on every keystroke
and reset the highlighted index to 0 whenever the query changes.
- ArrowUp/ArrowDown move the highlighted index (wrapping). Enter or Tab
inserts the highlighted item and both preventDefault (Enter never submits
a form, Tab never leaves the field) — unless there are zero matches, in
which case Enter/Tab do nothing special and fall through to their native
behavior. Escape closes the panel without touching the text. Clicking a
row inserts that row.
- Insertion replaces the trigger+query span with `${trigger}${item.label} `
(label plus one trailing space) and moves the caret right after it; call
onMention(item) once the value is committed.
- IME safety: ignore Enter/Tab/Escape handling entirely while
e.nativeEvent.isComposing is true, so composing CJK text is never
hijacked mid-composition.
- Auto-grow: the field grows with content up to maxRows lines (measuring
scrollHeight against computed line-height + padding/border via a
useLayoutEffect keyed on value — a DOM write, not a setState, so it's
outside the set-state-in-effect rule), then switches to internal
scrolling.
- The mention panel is anchored near the caret: a hidden mirror div copies
the textarea's font, padding, border and width, receives the same text up
to the caret plus a one-character marker span, and the marker's
offsetTop/offsetLeft (minus the textarea's own scrollTop/scrollLeft) is
the caret's pixel position. Do this measurement — and the resulting
setState for panel position — inside the change handler (an event
callback), never inside an effect body; that keeps the panel's position
synchronous with the keystroke that caused it instead of lagging a frame
behind through an effect-triggered re-render.
- Honesty: this is a PLAIN TEXT field. A mention is not an atomic token —
once inserted it's ordinary characters, so Backspace deletes it one
character at a time like any other text, and nothing stops the user from
typing inside or splitting an already-inserted mention.
Rendering & styling
- Semantic tokens only: border-input / bg-transparent / focus-visible ring
for the field, bg-popover text-popover-foreground border shadow-md for
the panel, bg-accent text-accent-foreground for the highlighted row,
bg-secondary text-secondary-foreground for any "mentioned" chips a
consumer renders from parsed text. No hardcoded colors — dark mode is
free.
- Accessibility: the <label> (when given) points at the textarea's id via
htmlFor. Do NOT set role="combobox" or aria-expanded on the textarea:
combobox is an ARIA role defined for single-line text inputs, and the
textarea's implicit "textbox" role doesn't support aria-expanded at all
(jsx-a11y will flag it). Instead wire the relationship with what
"textbox" *does* support — aria-autocomplete="list", aria-controls (the
panel's id) and aria-activedescendant (the highlighted row's id). The
panel itself is role="listbox" with role="option" aria-selected rows when
there are matches, or role="status" aria-live="polite" holding emptyText
when there are none.
Customization levers
- Trigger character(s): change the `trigger` prop to repurpose the whole
component for "/" commands, "#" tags, "::" snippets, etc. — the word-
boundary + query-scanning logic is trigger-agnostic.
- Row content: swap in richer row markup (badges, secondary metadata) by
editing the option row's JSX; avatar and description are already optional
per item.
- Panel size: the fixed w-64 / max-h-56 are the only two knobs — widen for
longer labels, shrink max-h for a more compact picker.
- Insertion format: `${trigger}${item.label} ` (trailing space) is the only
place that assembles the inserted text — swap it for
`${trigger}${item.id}` or a bracketed `@[${item.label}](${item.id})` form
if the consumer needs a machine-parseable, non-space-terminated shape.
- Match algorithm: the subsequence matcher is the only filtering knob — swap
it for a plain substring test or a fuzzy-score library without touching
caret math or keyboard handling.Concepts
- Word-boundary trigger — the trigger character only opens mention mode when it sits at the start of the text or right after whitespace, so
email@hosttyped inline never accidentally opens the picker. - Caret-anchored mirror measurement — a hidden div replicates the textarea's exact box model (font, padding, border, width) to compute the caret's pixel position without any native caret-position API.
- Case-insensitive subsequence filter — the query matches when every one of its characters appears somewhere in the label, in order (e.g. "gh" matches "Grace Hopper"), not just as a contiguous substring.
- Plain-text mention (honesty) — the inserted
@Labelis ordinary text the moment it lands, not an atomic token; it deletes one character at a time and can be split by further edits, same as any other text. - IME-safe key handling — Enter/Tab/Escape are only intercepted when the field isn't mid-composition, so typing CJK text through an IME is never hijacked.
- Trigger-agnostic engine — the same word-boundary-plus-subsequence-filter logic drives both
@mentions and/slash commands off onetriggerprop.
Prompt Input
An autosizing chat input with attachments, streaming stop/send, IME-safe submit and a character counter.
API Key Field
A settings-page secret field — fixed-length mask, reveal/hide with optional auto re-mask, copy-to-clipboard with a visible failure fallback, and a two-step confirm before regenerating.