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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const PANEL_WIDTH = 256

export interface MentionItem {
  id: string
  label: string
  description?: string
  avatar?: string
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/mention-input.json

Prompt

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@host typed 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 @Label is 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 one trigger prop.

On This Page