Skip to contentVibraUI
Inputs & filters

Mention input

A textarea where @ offers teammates at the caret, by keyboard or pointer, storing each mention as @[Name](id) for the server to resolve against its rows.

The field shows each mention as @Name and stores it as @[Name](id): onValueChange and the hidden name input carry that stored text, never the words on screen, and the textarea itself posts nothing. On the server the markup is only what the browser sent, so an action runs resolveMentions(text, (id) => member row) before keeping a comment: a known id takes the row's own name whatever the markup claimed, and an unknown one is turned back into plain text, so a crafted name cannot pass for a teammate. It reads its answer back before handing it over and returns null when a mention in it would not name a member by the member's own name — words that close into a mention once an unknown one goes back to words — and the action refuses that text. mentionIds(text) names everyone mentioned, once each, for a notification; mentionsToPlain(text) is the text as a reader sees it. MentionText draws stored text with each mention as @Name at 500 — never the markup — and is server-compatible: import it from mention-input/mention-text. An @ at the start of the text or after a space opens the list under it — in a popover, so a card around the field never clips it — matching names by their start or a word's, accents aside. ArrowDown and ArrowUp move through it (aria-activedescendant follows), Enter or a click takes the person, Escape puts the list away until the next @, and a status line says how many match. A mention is one thing in the text: Backspace just after it, or Delete just before it, removes it whole, and typing inside one lets it go as plain words. A key that ends an IME composition never picks. candidates is a list the field searches itself, or a function — sync or async — asked once per query, the newest answer winning; while it is out the last answer stays up, dimmed, and cannot be taken. A query that has grown a space and matches no one is read as a sentence, and the list steps aside.

Install

npx shadcn@latest add @vibra/mention-input

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
candidatesMentionCandidate[] | ((query: string) => MentionCandidate[] | Promise<MentionCandidate[]>)—Who can be mentioned: { id, name, avatarUrl?, description? }. A list is searched by the field; a function answers each query itself.
valuestring—The stored text, controlled — what was typed, each mention written @[Name](id).
defaultValuestring""Where an uncontrolled field starts, as stored text.
onValueChange(stored: string) => void—Every change, as stored text.
maxSuggestionsnumber6How many people the list offers at once.
emptyTextstring"No one matches"Shown under the @, and spoken, when a query matches no one.
namestring—Posts the stored text with its form, in a hidden input of this name.
classNamestring—Merged onto the wrapper. Every other textarea prop — id, placeholder, rows, disabled, aria-* — goes to the textarea.
MentionTextReact.ComponentProps<"span"> & { text: string }—From mention-input/mention-text: stored text drawn with each mention as @Name, server-compatible.
resolveMentions / mentionIds / mentionsToPlain(stored, lookup: (id) => { name } | undefined) => string | null / (stored) => string[] / (stored) => string—From mention-input/mentions: the server's re-read against its rows (null for text to refuse), everyone mentioned, and the plain text.
parseMentions / serializeMentions / MENTION_PATTERN(stored) => MentionSegment[] / (segments) => string / RegExp—The stored form read into text and mentions and written back; a name with brackets or a line break goes out as plain @Name.

Dependencies

Source

components/ui/mention-input/index.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Popover, PopoverContent } from "@/components/ui/popover"
import { Textarea } from "@/components/ui/textarea"

import { caretRect } from "./caret"
import {
  insertMention,
  matchCandidates,
  mentionQuery,
  mentionRanges,
  rebaseMentions,
  removeMention,
  storedText,
  type MentionCandidate,
  type MentionRange,
} from "./mentions"

export type MentionInputProps = Omit<React.ComponentProps<"textarea">, "value" | "defaultValue" | "onChange"> & {
  /** The stored text, controlled: what was typed, each mention written `@[Name](id)`. */
  value?: string
  defaultValue?: string
  /** Every change, as stored text. */
  onValueChange?: (stored: string) => void
  /** Who can be mentioned: a list the field searches itself, or a function — sync or async — that answers a query. */
  candidates: MentionCandidate[] | ((query: string) => MentionCandidate[] | Promise<MentionCandidate[]>)
  maxSuggestions?: number
  /** Shown, and spoken, when a query matches no one. */
  emptyText?: string
}

type Model = { text: string; ranges: MentionRange[] }

/**
 * A textarea where `@` offers people: a list anchored at the `@`, walked with
 * the arrow keys and taken with Enter or a click. The field shows each
 * mention as `@Name`, keeps where each one sits beside the text, and moves
 * them with every edit; the value is the stored text, mentions written
 * `@[Name](id)`, which the server reads back through resolveMentions.
 */
function MentionInput({
  value,
  defaultValue,
  onValueChange,
  candidates,
  maxSuggestions = 6,
  emptyText = "No one matches",
  name,
  className,
  disabled,
  readOnly,
  ref,
  onKeyDown,
  onFocus,
  onBlur,
  ...props
}: MentionInputProps) {
  const listId = React.useId()
  const optionId = (index: number) => `${listId}-option-${index}`
  const field = React.useRef<HTMLTextAreaElement>(null)
  // The field keeps its own handle — the caret and the list's anchor go
  // through it — and hands the caller's ref the same element.
  const setField = React.useCallback(
    (node: HTMLTextAreaElement | null) => {
      field.current = node
      if (typeof ref === "function") ref(node)
      else if (ref) ref.current = node
    },
    [ref]
  )
  const pendingCaret = React.useRef<number | null>(null)
  // A field that cannot be written in offers no one and takes nothing out.
  const inert = Boolean(disabled || readOnly)

  const [local, setLocal] = React.useState<Model>(() => mentionRanges(defaultValue ?? ""))
  const controlledModel = React.useMemo(() => (value === undefined ? null : mentionRanges(value)), [value])
  const model = controlledModel ?? local
  const [selection, setSelection] = React.useState({ start: 0, end: 0 })
  const [focused, setFocused] = React.useState(false)
  const [dismissed, setDismissed] = React.useState<number | null>(null)

  const asked = focused && !inert && selection.start === selection.end ? mentionQuery(model.text, selection.start, model.ranges) : null
  const open = asked !== null && asked.start !== dismissed

  // An async source is asked once per query; the newest answer wins.
  const remote = typeof candidates === "function"
  const source = React.useRef(candidates)
  React.useEffect(() => {
    source.current = candidates
  })
  const [answer, setAnswer] = React.useState<{ query: string; items: MentionCandidate[] } | null>(null)
  const query = open ? asked.query : null
  React.useEffect(() => {
    const ask = source.current
    if (!remote || query === null || typeof ask !== "function") return
    let cancelled = false
    const settle = (items: MentionCandidate[]) => !cancelled && setAnswer({ query, items: items.slice(0, maxSuggestions) })
    Promise.resolve(ask(query)).then(settle, () => settle([]))
    return () => {
      cancelled = true
    }
  }, [remote, query, maxSuggestions])

  const pending = remote && query !== null && answer?.query !== query
  const items = query === null ? [] : remote ? (answer?.items ?? []) : matchCandidates(candidates as MentionCandidate[], query, maxSuggestions)
  // A query that has grown a space and matches no one is a sentence, not a name: the list steps aside.
  const shown = query !== null && (items.length > 0 || (!pending && !/\s/.test(query)))

  // The active option belongs to the query it was chosen in; a new query starts at the top.
  const [active, setActive] = React.useState({ query: "", index: 0 })
  const queryKey = asked ? `${asked.start}:${asked.query}` : ""
  const activeIndex = active.query === queryKey ? Math.min(active.index, Math.max(items.length - 1, 0)) : 0

  React.useLayoutEffect(() => {
    if (pendingCaret.current === null || !field.current) return
    field.current.setSelectionRange(pendingCaret.current, pendingCaret.current)
    pendingCaret.current = null
  })

  function commit(next: Model & { caret?: number }) {
    if (value === undefined) setLocal({ text: next.text, ranges: next.ranges })
    if (next.caret !== undefined) {
      pendingCaret.current = next.caret
      setSelection({ start: next.caret, end: next.caret })
    }
    onValueChange?.(storedText(next.text, next.ranges))
  }

  function pick(candidate: MentionCandidate) {
    if (asked && !inert) commit(insertMention(model.text, model.ranges, asked.start, selection.start, candidate))
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
    onKeyDown?.(event)
    // A key that ends an IME composition belongs to the composition.
    if (inert || event.defaultPrevented || event.nativeEvent.isComposing || event.keyCode === 229) return
    const { text, ranges } = model

    if (shown && items.length > 0 && !pending) {
      if (event.key === "ArrowDown" || event.key === "ArrowUp") {
        event.preventDefault()
        const step = event.key === "ArrowDown" ? 1 : -1
        setActive({ query: queryKey, index: (activeIndex + step + items.length) % items.length })
        return
      }
      if (event.key === "Enter") {
        event.preventDefault()
        pick(items[activeIndex])
        return
      }
    }
    if (shown && event.key === "Escape") {
      event.preventDefault()
      event.stopPropagation()
      setDismissed(asked!.start)
      return
    }

    // A mention goes as one: Backspace just after it, or Delete just before it.
    if (selection.start !== selection.end) return
    const whole =
      event.key === "Backspace"
        ? ranges.find((range) => range.end === selection.start)
        : event.key === "Delete"
          ? ranges.find((range) => range.start === selection.start)
          : undefined
    if (!whole) return
    event.preventDefault()
    commit(removeMention(text, ranges, whole))
  }

  const readSelection = (target: HTMLTextAreaElement) => setSelection({ start: target.selectionStart, end: target.selectionEnd })

  // The list hangs under the `@`, measured each time the popover places
  // itself, so a scroll or a wrap moves it too.
  const anchorAt = asked?.start ?? null
  const anchor = React.useMemo(
    () => ({
      getBoundingClientRect: () =>
        field.current && anchorAt !== null ? caretRect(field.current, anchorAt) : { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 },
      get contextElement() {
        return field.current ?? undefined
      },
    }),
    [anchorAt]
  )

  const status = !shown ? "" : items.length > 0 ? `${items.length} ${items.length === 1 ? "person matches" : "people match"}` : emptyText

  return (
    <div data-slot="mention-input" data-open={shown || undefined} className={cn("relative w-full", className)}>
      <Textarea
        ref={setField}
        role="combobox"
        aria-expanded={shown}
        aria-controls={shown ? listId : undefined}
        aria-activedescendant={shown && items.length > 0 && !pending ? optionId(activeIndex) : undefined}
        aria-autocomplete="list"
        aria-haspopup="listbox"
        autoComplete="off"
        disabled={disabled}
        readOnly={readOnly}
        value={model.text}
        onChange={(event) => {
          const next = event.target.value
          const ranges = rebaseMentions(model.ranges, model.text, next, event.target.selectionEnd)
          // A list put away stays away while its query is typed on, and comes back for the next `@`.
          if (mentionQuery(next, event.target.selectionEnd, ranges)?.start !== dismissed) setDismissed(null)
          commit({ text: next, ranges })
          readSelection(event.target)
        }}
        onSelect={(event) => readSelection(event.currentTarget)}
        onKeyDown={handleKeyDown}
        onFocus={(event) => {
          onFocus?.(event)
          setFocused(true)
          readSelection(event.currentTarget)
        }}
        onBlur={(event) => {
          onBlur?.(event)
          setFocused(false)
        }}
        {...props}
      />
      {name ? <input type="hidden" name={name} value={storedText(model.text, model.ranges)} /> : null}
      <Popover
        open={shown}
        onOpenChange={(next) => {
          if (!next && asked) setDismissed(asked.start)
        }}
      >
        <PopoverContent
          id={listId}
          role={items.length > 0 ? "listbox" : undefined}
          aria-label={items.length > 0 ? "People" : undefined}
          aria-busy={pending || undefined}
          anchor={anchor}
          side="bottom"
          align="start"
          initialFocus={false}
          finalFocus={false}
          className="max-h-64 w-64 gap-0 overflow-y-auto p-1 aria-busy:opacity-60"
        >
          {items.length === 0 ? (
            <p className="px-2 py-3 text-center text-sm text-muted-foreground">{emptyText}</p>
          ) : (
            items.map((item, index) => (
              <div
                key={item.id}
                id={optionId(index)}
                role="option"
                aria-selected={index === activeIndex}
                data-active={index === activeIndex || undefined}
                // Taking a person must not move the focus out of the text first.
                onMouseDown={(event) => event.preventDefault()}
                onClick={() => pick(item)}
                onPointerMove={() => {
                  if (index !== activeIndex) setActive({ query: queryKey, index })
                }}
                className="flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm data-active:bg-accent data-active:text-accent-foreground"
              >
                <Avatar size="sm" aria-hidden="true">
                  {item.avatarUrl ? <AvatarImage src={item.avatarUrl} alt="" /> : null}
                  <AvatarFallback>{getInitials(item.name)}</AvatarFallback>
                </Avatar>
                <span className="flex min-w-0 flex-col">
                  <span className="truncate">{item.name}</span>
                  {item.description ? <span className="truncate text-xs text-muted-foreground">{item.description}</span> : null}
                </span>
              </div>
            ))
          )}
        </PopoverContent>
      </Popover>
      {/* One region, so a count is re-announced as it changes rather than each option on the way past. */}
      <span role="status" aria-live="polite" className="sr-only">{status}</span>
    </div>
  )
}

export { MentionInput }
// The renderer has a module of its own with no client boundary, so a server
// page draws a comment without loading the field: import it from mention-text.
export type { MentionTextProps } from "./mention-text"
components/ui/mention-input/mentions.ts
/**
 * The mention input's stored text: what the reader typed, with each mention
 * written `@[Name](id)`. No React in here, so a server action reads a posted
 * comment with the same rules the field wrote it under — and `resolveMentions`
 * is the step it must not skip: a name in the markup is only what the
 * browser sent, and the row's own name replaces it.
 */

/** Someone the field can mention. */
export type MentionCandidate = { id: string; name: string; avatarUrl?: string; description?: string }

/** A stored text, read into its runs of text and its mentions. */
export type MentionSegment = { type: "text"; text: string } | { type: "mention"; id: string; name: string }

/** One mention in the stored text: a name of 1–80 characters with no brackets or line break, and an id of 1–40 letters, digits, `_` or `-`. */
export const MENTION_PATTERN = /@\[([^[\]\n]{1,80})\]\(([A-Za-z0-9_-]{1,40})\)/g

const NAME = /^[^[\]\n]{1,80}$/
const ID = /^[A-Za-z0-9_-]{1,40}$/

/** The stored text, in order: the text between mentions, and each mention. */
export function parseMentions(stored: string): MentionSegment[] {
  const segments: MentionSegment[] = []
  let last = 0
  for (const match of stored.matchAll(MENTION_PATTERN)) {
    const at = match.index ?? 0
    if (at > last) segments.push({ type: "text", text: stored.slice(last, at) })
    segments.push({ type: "mention", id: match[2], name: match[1] })
    last = at + match[0].length
  }
  if (last < stored.length) segments.push({ type: "text", text: stored.slice(last) })
  return segments
}

/**
 * The segments written back as stored text. A mention whose name or id the
 * pattern cannot hold is written as the `@Name` a reader saw, so it never
 * turns into markup that reads back as something else.
 */
export function serializeMentions(segments: MentionSegment[]): string {
  return segments
    .map((segment) =>
      segment.type === "text"
        ? segment.text
        : NAME.test(segment.name) && ID.test(segment.id)
          ? `@[${segment.name}](${segment.id})`
          : `@${segment.name}`
    )
    .join("")
}

/** Everyone the text mentions, once each, in the order they first appear. */
export function mentionIds(stored: string): string[] {
  return [...new Set(parseMentions(stored).flatMap((segment) => (segment.type === "mention" ? [segment.id] : [])))]
}

/**
 * The stored text with every mention re-read from the rows: a known id gets
 * the row's own name, whatever the markup said, and an unknown one becomes
 * the plain `@Name` it was typed as. A server action runs this before it
 * keeps a comment, so a crafted name cannot pass for a teammate.
 *
 * Null when the answer would not read back as it was written. An unknown
 * mention's brackets can be all that keeps an open `@[` before it from a
 * `](id)` after it, and turned back into words it lets them meet — as a
 * mention of someone under a name the browser chose. So the answer is read
 * once more, and every mention in it has to name a member by the member's
 * own name; the action refuses the text otherwise.
 */
export function resolveMentions(stored: string, lookup: (id: string) => { name: string } | undefined): string | null {
  const resolved = serializeMentions(
    parseMentions(stored).map((segment): MentionSegment => {
      if (segment.type === "text") return segment
      const row = lookup(segment.id)
      return row ? { type: "mention", id: segment.id, name: row.name } : { type: "text", text: `@${segment.name}` }
    })
  )
  const faithful = parseMentions(resolved).every((segment) => segment.type === "text" || lookup(segment.id)?.name === segment.name)
  return faithful ? resolved : null
}

/** The text as a reader sees it: each mention as `@Name`. */
export function mentionsToPlain(stored: string): string {
  return parseMentions(stored)
    .map((segment) => (segment.type === "text" ? segment.text : `@${segment.name}`))
    .join("")
}

/** Where one mention sits in the text the field shows: `@Name`, from `start` up to `end`. */
export type MentionRange = { start: number; end: number; id: string; name: string }

/** A stored text as the field shows it — each mention as `@Name` — with where each mention sits. */
export function mentionRanges(stored: string): { text: string; ranges: MentionRange[] } {
  let text = ""
  const ranges: MentionRange[] = []
  for (const segment of parseMentions(stored)) {
    if (segment.type === "text") {
      text += segment.text
    } else {
      ranges.push({ start: text.length, end: text.length + segment.name.length + 1, id: segment.id, name: segment.name })
      text += `@${segment.name}`
    }
  }
  return { text, ranges }
}

/** The shown text back as stored text: each range still reading `@Name` is a mention, anything else the words it holds. */
export function storedText(text: string, ranges: MentionRange[]): string {
  const segments: MentionSegment[] = []
  let last = 0
  for (const range of [...ranges].sort((a, b) => a.start - b.start)) {
    if (range.start < last || text.slice(range.start, range.end) !== `@${range.name}`) continue
    if (range.start > last) segments.push({ type: "text", text: text.slice(last, range.start) })
    segments.push({ type: "mention", id: range.id, name: range.name })
    last = range.end
  }
  if (last < text.length) segments.push({ type: "text", text: text.slice(last) })
  return serializeMentions(segments)
}

const commonPrefix = (a: string, b: string) => {
  let length = 0
  while (length < a.length && length < b.length && a[length] === b[length]) length += 1
  return length
}

const commonSuffix = (a: string, b: string) => {
  let length = 0
  while (length < a.length && length < b.length && a[a.length - 1 - length] === b[b.length - 1 - length]) length += 1
  return length
}

/**
 * The mentions after one edit of the shown text. The edit is found by the
 * caret — a typed or pasted run ends at it — so a letter repeated beside a
 * mention is placed where it was typed. Mentions before the edit stay,
 * those after it move with it, and one the edit touched is let go: its
 * words stay in the text, as plain text.
 */
export function rebaseMentions(ranges: MentionRange[], before: string, after: string, caret = after.length): MentionRange[] {
  if (before === after) return ranges
  const tail = after.slice(Math.min(Math.max(caret, 0), after.length))
  const suffix = before.endsWith(tail) ? tail.length : commonSuffix(before, after)
  const prefix = Math.min(commonPrefix(before, after), before.length - suffix, after.length - suffix)
  const editedTo = before.length - suffix
  const shift = after.length - before.length
  return ranges.flatMap((range) => {
    if (range.end <= prefix) return [range]
    if (range.start >= editedTo) return [{ ...range, start: range.start + shift, end: range.end + shift }]
    return []
  })
}

/** Up to 40 characters on one line, no second @, and not starting with a space. */
const QUERY = /^(?!\s)[^@\n]{0,40}$/

/**
 * The mention being typed at the caret: an `@` at the start of the text or
 * after a space or an opening bracket, and up to 40 characters after it on
 * the same line, not starting with a space. Null when the caret is not in
 * one — or is in a mention already made.
 */
export function mentionQuery(text: string, caret: number, ranges: MentionRange[]): { start: number; query: string } | null {
  // Before the first character there is no @ behind the caret — and lastIndexOf would read a -1 as 0 and find one after it.
  if (caret <= 0) return null
  const start = text.lastIndexOf("@", caret - 1)
  if (start < 0) return null
  if (start > 0 && !/[\s([{"']/.test(text[start - 1])) return null
  if (ranges.some((range) => range.start <= start && start < range.end)) return null
  const query = text.slice(start + 1, caret)
  return QUERY.test(query) ? { start, query } : null
}

type Edit = { text: string; ranges: MentionRange[]; caret: number }

/**
 * The text with a person put in for the query from `start` to `end`: `@Name`
 * and a space (or the space already there), the mentions after it moved
 * along, and the caret past the space.
 */
export function insertMention(text: string, ranges: MentionRange[], start: number, end: number, person: { id: string; name: string }): Edit {
  const mention = `@${person.name}`
  const after = text.slice(end)
  const next = `${text.slice(0, start)}${mention}${/^\s/.test(after) ? "" : " "}${after}`
  const shift = next.length - text.length
  return {
    text: next,
    ranges: [
      ...ranges.filter((range) => range.end <= start),
      { start, end: start + mention.length, id: person.id, name: person.name },
      ...ranges.filter((range) => range.start >= end).map((range) => ({ ...range, start: range.start + shift, end: range.end + shift })),
    ],
    caret: start + mention.length + 1,
  }
}

/** The text with one mention taken out whole, the ones after it moved back, and the caret where it was. */
export function removeMention(text: string, ranges: MentionRange[], mention: MentionRange): Edit {
  const shift = mention.end - mention.start
  return {
    text: text.slice(0, mention.start) + text.slice(mention.end),
    ranges: ranges
      .filter((range) => range !== mention)
      .map((range) => (range.start >= mention.end ? { ...range, start: range.start - shift, end: range.end - shift } : range)),
    caret: mention.start,
  }
}

/** A name for matching: lower case, accents off. */
const fold = (text: string) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase()

/**
 * The candidates a query reaches, in their own order, at most `max` of
 * them: a name that starts with it, or has a word that does — "co" finds
 * Bruno Costa, "ang" finds Zoë Ångström.
 */
export function matchCandidates(candidates: MentionCandidate[], query: string, max: number): MentionCandidate[] {
  const wanted = fold(query.trim())
  return candidates
    .filter((candidate) => {
      const name = fold(candidate.name)
      return name.startsWith(wanted) || name.split(/\s+/).some((word) => word.startsWith(wanted))
    })
    .slice(0, max)
}
components/ui/mention-input/caret.ts
/**
 * Where a position in a textarea's text sits on screen. A textarea tells no
 * one where its caret is, so a hidden copy of it — same box, same type, same
 * wrapping — is written up to the position, a marker placed there and
 * measured, and the copy thrown away.
 */

const COPIED = [
  "direction",
  "boxSizing",
  "width",
  "height",
  "overflowX",
  "overflowY",
  "borderTopWidth",
  "borderRightWidth",
  "borderBottomWidth",
  "borderLeftWidth",
  "borderStyle",
  "paddingTop",
  "paddingRight",
  "paddingBottom",
  "paddingLeft",
  "fontStyle",
  "fontVariant",
  "fontWeight",
  "fontStretch",
  "fontSize",
  "fontSizeAdjust",
  "lineHeight",
  "fontFamily",
  "fontFeatureSettings",
  "textAlign",
  "textTransform",
  "textIndent",
  "letterSpacing",
  "wordSpacing",
  "tabSize",
] as const

/**
 * The top-left of `position` in the textarea, from its border box, and the
 * height of a line there — scrolled as the textarea is scrolled.
 */
export function caretOffset(textarea: HTMLTextAreaElement, position: number): { top: number; left: number; height: number } {
  const style = getComputedStyle(textarea)
  const mirror = document.createElement("div")
  for (const property of COPIED) mirror.style[property] = style[property]
  mirror.style.position = "absolute"
  mirror.style.top = "0"
  mirror.style.left = "-9999px"
  mirror.style.visibility = "hidden"
  mirror.style.whiteSpace = "pre-wrap"
  mirror.style.overflowWrap = "break-word"
  mirror.setAttribute("aria-hidden", "true")

  mirror.textContent = textarea.value.slice(0, position)
  // A zero-width marker sits on the position itself, in either direction;
  // the rest of the text follows it so the line wraps where the field's does.
  const marker = document.createElement("span")
  marker.textContent = "\u200b"
  mirror.append(marker, textarea.value.slice(position))
  document.body.append(mirror)

  const top = marker.offsetTop + parseFloat(style.borderTopWidth) - textarea.scrollTop
  const left = marker.offsetLeft + parseFloat(style.borderLeftWidth) - textarea.scrollLeft
  const height = parseFloat(style.lineHeight) || marker.offsetHeight
  mirror.remove()
  return { top, left, height }
}

/** The box of one line's height at `position`, in the window's coordinates — what a popover anchors to. */
export function caretRect(textarea: HTMLTextAreaElement, position: number) {
  const box = textarea.getBoundingClientRect()
  const at = caretOffset(textarea, position)
  const left = box.left + at.left
  const top = box.top + at.top
  return { x: left, y: top, left, top, right: left + 1, bottom: top + at.height, width: 1, height: at.height }
}
components/ui/mention-input/mention-text.tsx
import * as React from "react"

import { cn } from "@/lib/utils"

import { parseMentions } from "./mentions"

export type MentionTextProps = React.ComponentProps<"span"> & {
  /** Stored text, each mention written `@[Name](id)` — read through resolveMentions before it was kept. */
  text: string
}

/**
 * Stored text as a reader sees it: each mention drawn as `@Name` at 500,
 * never the markup. Server-compatible — no hooks, no client boundary — so a
 * page renders a comment where it reads it.
 */
function MentionText({ text, className, ...props }: MentionTextProps) {
  return (
    <span data-slot="mention-text" className={cn("wrap-break-word whitespace-pre-wrap", className)} {...props}>
      {parseMentions(text).map((segment, index) =>
        segment.type === "text" ? (
          <React.Fragment key={index}>{segment.text}</React.Fragment>
        ) : (
          <span key={index} data-slot="mention" className="font-medium text-foreground">
            @{segment.name}
          </span>
        )
      )}
    </span>
  )
}

export { MentionText }