Skip to content

Mentions

Form

Controlled multiline text with local @mention suggestions and keyboard selection.

Project update composerFiltered teammate listNo-results feedbackRead-only archived update
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Supply local people with unique whitespace-free ids and readable labels. Typing @ at the start of text or after whitespace opens suggestions for the current caret; matching searches ids and labels and skips disabled options. The first eight matches are shown. Selecting inserts @id plus a trailing space and preserves text on both sides of the caret. The result is plain text, with onMention for optional metadata capture. It does not send notifications, perform async searches, or enforce identity permissions.

Import
import { Mentions, type MentionOption } from "@/components/ui/mentions";

Shipped as editable source in src/components/ui/mentions.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/mentions.tsx
"use client";
import { useId, useRef, useState, type ComponentProps } from "react";
import { Label } from "@dashboardpack/core/components/ui/label";
import { cn } from "@dashboardpack/core/lib/utils";
export interface MentionOption {
  id: string;
  label: string;
  description?: string;
  disabled?: boolean;
}
export interface MentionQuery {
  start: number;
  end: number;
  query: string;
}
export function getMentionQuery(
  value: string,
  caret: number,
): MentionQuery | null {
  const before = value.slice(0, caret);
  const match = /(?:^|\s)@([a-zA-Z0-9_-]*)$/.exec(before);
  return match
    ? { start: caret - match[1].length - 1, end: caret, query: match[1] }
    : null;
}
export interface MentionsProps
  extends Omit<ComponentProps<"textarea">, "value" | "onChange" | "children"> {
  label: string;
  value: string;
  onValueChange: (value: string) => void;
  options: MentionOption[];
  onMention?: (option: MentionOption) => void;
}
export function Mentions({
  label,
  value,
  onValueChange,
  options,
  onMention,
  disabled,
  readOnly,
  className,
  id,
  ...props
}: MentionsProps) {
  const generated = useId();
  const inputId = id ?? generated;
  const listId = `${inputId}-suggestions`;
  const input = useRef<HTMLTextAreaElement>(null);
  const [query, setQuery] = useState<MentionQuery | null>(null);
  const [active, setActive] = useState(0);
  const [dismissed, setDismissed] = useState(false);
  const matches = options
    .filter(
      (option) =>
        !option.disabled &&
        (option.label
          .toLowerCase()
          .includes(query?.query.toLowerCase() ?? "") ||
          option.id.toLowerCase().includes(query?.query.toLowerCase() ?? "")),
    )
    .slice(0, 8);
  const open = !!query && !dismissed && !disabled && !readOnly;
  const index = Math.min(active, Math.max(0, matches.length - 1));
  const locate = () => {
    const next = getMentionQuery(
      input.current?.value ?? value,
      input.current?.selectionStart ?? value.length,
    );
    if (
      query?.start !== next?.start ||
      query?.end !== next?.end ||
      query?.query !== next?.query
    ) {
      setQuery(next);
      setActive(0);
      setDismissed(false);
    }
  };
  const choose = (option: MentionOption) => {
    if (!query) return;
    const inserted = `@${option.id} `;
    const next =
      value.slice(0, query.start) + inserted + value.slice(query.end);
    onValueChange(next);
    setQuery(null);
    onMention?.(option);
    requestAnimationFrame(() => {
      input.current?.focus();
      input.current?.setSelectionRange(
        query.start + inserted.length,
        query.start + inserted.length,
      );
    });
  };
  return (
    <div className={cn("relative min-w-0 space-y-2", className)}>
      <Label htmlFor={inputId}>{label}</Label>
      <textarea
        {...props}
        ref={input}
        id={inputId}
        value={value}
        disabled={disabled}
        readOnly={readOnly}
        aria-autocomplete="list"
        aria-controls={open ? listId : undefined}
        aria-activedescendant={
          open && matches.length ? `${listId}-${index}` : undefined
        }
        aria-describedby={`${inputId}-hint`}
        onChange={(event) => {
          onValueChange(event.target.value);
          locate();
        }}
        onSelect={locate}
        onBlur={() => setDismissed(true)}
        onKeyDown={(event) => {
          props.onKeyDown?.(event);
          if (event.defaultPrevented || event.nativeEvent.isComposing || !open)
            return;
          if (event.key === "Escape") {
            event.preventDefault();
            setDismissed(true);
          }
          if (
            matches.length &&
            (event.key === "ArrowDown" || event.key === "ArrowUp")
          ) {
            event.preventDefault();
            setActive(
              (index + (event.key === "ArrowDown" ? 1 : -1) + matches.length) %
                matches.length,
            );
          }
          if (matches.length && event.key === "Enter") {
            event.preventDefault();
            choose(matches[index]);
          }
        }}
        className="min-h-28 w-full rounded-xl border border-input bg-background p-3 text-sm leading-6 outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
      />
      <p id={`${inputId}-hint`} className="text-xs text-muted-foreground">
        Type @ to mention someone. Use arrow keys and Enter to choose; Escape
        closes suggestions.
      </p>
      {open && (
        <div className="absolute start-0 top-full z-20 mt-1 w-full max-w-sm overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg">
          <ul
            id={listId}
            role="listbox"
            aria-label="Mention suggestions"
            className="max-h-64 overflow-auto p-1"
          >
            {matches.map((option, position) => (
              <li
                key={option.id}
                id={`${listId}-${position}`}
                role="option"
                aria-selected={position === index}
                onPointerDown={(event) => event.preventDefault()}
                onClick={() => choose(option)}
                className={cn(
                  "flex cursor-pointer items-center gap-3 rounded-md p-3",
                  position === index && "bg-accent text-accent-foreground",
                )}
              >
                <span
                  aria-hidden
                  className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary"
                >
                  {option.label
                    .split(" ")
                    .map((part) => part[0])
                    .slice(0, 2)
                    .join("")}
                </span>
                <span className="min-w-0">
                  <span className="block text-sm font-medium">
                    {option.label}
                  </span>
                  <span className="block text-xs text-muted-foreground">
                    @{option.id}
                    {option.description ? ` · ${option.description}` : ""}
                  </span>
                </span>
              </li>
            ))}
          </ul>
          {matches.length === 0 && (
            <p role="status" className="p-3 text-sm text-muted-foreground">
              No people found.
            </p>
          )}
        </div>
      )}
    </div>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

Mentions props
PropType / DefaultDescription
labelstring

Default: required

Visible textarea label.
valuestring

Default: required

Controlled plain-text content.
onValueChange(value: string) => void

Default: required

Apply edits and inserted mentions.
optionsMentionOption[]

Default: required

Unique id, label, optional description, and disabled flag.
onMention(option: MentionOption) => void

Default: —

Observe a chosen person; does not send notifications.
disabled / readOnlyboolean

Default: false

Disable editing or preserve readable archived text.
...propstextarea attributes

Default: —

Native textarea settings, excluding the controlled value/onChange contract.

Build with these too