SaaS & Ops

Mention Input

A textarea with @mention autocomplete: an anchored, keyboard-navigable popover of teammates that completes the token and restores the caret.

Install

npx shadcn@latest add @paragon/mention-input

Also installs: popover

mention-input.tsx

"use client";

import * as React from "react";
import {
  Popover,
  PopoverAnchor,
  PopoverContent,
} from "@/registry/paragon/ui/popover";
import { cn } from "@/lib/utils";

export interface MentionUser {
  id: string;
  name: string;
  /** Shown under the name, e.g. a handle or role. */
  handle?: string;
}

export interface MentionInputProps
  extends Omit<
    React.ComponentProps<"textarea">,
    "value" | "defaultValue" | "onChange"
  > {
  users?: MentionUser[];
  defaultValue?: string;
  onValueChange?: (value: string) => void;
}

function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return "•";
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

function hueOf(value: string): number {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (hash << 5) - hash + value.charCodeAt(i);
    hash |= 0;
  }
  return Math.abs(hash) % 360;
}

const DEFAULT_USERS: MentionUser[] = [
  { id: "priya", name: "Priya Raghavan", handle: "@priya · Identity" },
  { id: "marcus", name: "Marcus Kane", handle: "@marcus · Platform" },
  { id: "jamie", name: "Jamie Lin", handle: "@jamie · Growth" },
  { id: "dana", name: "Dana Ortiz", handle: "@dana · Security" },
  { id: "sofia", name: "Sofia Marin", handle: "@sofia · Design" },
  { id: "noah", name: "Noah Bennett", handle: "@noah · Support" },
];

/** Find an active "@token" ending at the caret, if any. */
function activeMention(
  value: string,
  caret: number,
): { start: number; query: string } | null {
  const upto = value.slice(0, caret);
  const match = upto.match(/(^|\s)@([\w-]*)$/);
  if (!match) return null;
  const query = match[2];
  const start = caret - query.length - 1; // include the "@"
  return { start, query };
}

/**
 * A textarea with @mention autocomplete. As you type "@" followed by a name,
 * a popover of matching people opens, pinned to the field; arrow keys move a
 * highlight, Enter/Tab completes the token, Escape dismisses. Completion
 * splices the chosen name back into the text and restores the caret after it.
 * Matching is on name and handle. Selection is index-driven, so it never
 * depends on hover.
 */
export function MentionInput({
  users = DEFAULT_USERS,
  defaultValue = "",
  onValueChange,
  className,
  placeholder = "Write a comment… use @ to mention a teammate",
  rows = 3,
  onKeyDown,
  ...props
}: MentionInputProps) {
  const [value, setValue] = React.useState(defaultValue);
  const [open, setOpen] = React.useState(false);
  const [active, setActive] = React.useState(0);
  const [mention, setMention] = React.useState<{
    start: number;
    query: string;
  } | null>(null);
  const ref = React.useRef<HTMLTextAreaElement>(null);
  const listRef = React.useRef<HTMLDivElement>(null);

  const matches = React.useMemo(() => {
    if (!mention) return [];
    const q = mention.query.toLowerCase();
    return users
      .filter((u) =>
        `${u.name} ${u.handle ?? ""}`.toLowerCase().includes(q),
      )
      .slice(0, 6);
  }, [users, mention]);

  const sync = (next: string, caret: number) => {
    setValue(next);
    onValueChange?.(next);
    const found = activeMention(next, caret);
    setMention(found);
    setOpen(!!found);
    setActive(0);
  };

  React.useEffect(() => {
    if (!open) return;
    listRef.current
      ?.querySelector<HTMLElement>(`[data-index="${active}"]`)
      ?.scrollIntoView({ block: "nearest" });
  }, [active, open]);

  const complete = (user: MentionUser) => {
    if (!mention) return;
    const before = value.slice(0, mention.start);
    const after = value.slice(mention.start + 1 + mention.query.length);
    const insert = `@${user.name} `;
    const next = before + insert + after;
    const caret = (before + insert).length;
    setValue(next);
    onValueChange?.(next);
    setOpen(false);
    setMention(null);
    requestAnimationFrame(() => {
      const el = ref.current;
      if (el) {
        el.focus();
        el.setSelectionRange(caret, caret);
      }
    });
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
    onKeyDown?.(event);
    if (open && matches.length) {
      if (event.key === "ArrowDown") {
        event.preventDefault();
        setActive((i) => (i + 1) % matches.length);
        return;
      }
      if (event.key === "ArrowUp") {
        event.preventDefault();
        setActive((i) => (i - 1 + matches.length) % matches.length);
        return;
      }
      if (event.key === "Enter" || event.key === "Tab") {
        event.preventDefault();
        complete(matches[active]);
        return;
      }
      if (event.key === "Escape") {
        event.preventDefault();
        setOpen(false);
        setMention(null);
        return;
      }
    }
  };

  return (
    <Popover open={open && matches.length > 0} onOpenChange={setOpen}>
      <PopoverAnchor asChild>
        <div className="w-full max-w-md">
          <textarea
            ref={ref}
            value={value}
            rows={rows}
            placeholder={placeholder}
            onChange={(event) =>
              sync(event.target.value, event.target.selectionStart)
            }
            onKeyDown={handleKeyDown}
            className={cn(
              "w-full resize-none rounded-lg bg-card px-3 py-2.5 text-sm text-card-foreground shadow-border outline-none",
              "placeholder:text-muted-foreground",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              className,
            )}
            {...props}
          />
        </div>
      </PopoverAnchor>
      <PopoverContent
        align="start"
        sideOffset={6}
        onOpenAutoFocus={(event) => event.preventDefault()}
        className="w-64 p-1"
      >
        <div
          ref={listRef}
          role="listbox"
          aria-label="Mention a teammate"
          className="max-h-60 overflow-y-auto [scrollbar-width:thin]"
        >
          {matches.map((user, index) => {
            const isActive = index === active;
            return (
              <button
                key={user.id}
                type="button"
                role="option"
                aria-selected={isActive}
                data-index={index}
                onMouseEnter={() => setActive(index)}
                onClick={() => complete(user)}
                className={cn(
                  "flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left outline-none",
                  isActive ? "bg-accent text-accent-foreground" : "text-foreground",
                )}
              >
                <span
                  className="flex size-6 shrink-0 items-center justify-center rounded-full text-[10px] font-medium"
                  style={{
                    backgroundColor: `oklch(0.9 0.05 ${hueOf(user.name)})`,
                    color: `oklch(0.4 0.09 ${hueOf(user.name)})`,
                  }}
                >
                  {initials(user.name)}
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-sm font-medium">
                    {user.name}
                  </span>
                  {user.handle && (
                    <span className="block truncate text-xs text-muted-foreground">
                      {user.handle}
                    </span>
                  )}
                </span>
              </button>
            );
          })}
        </div>
      </PopoverContent>
    </Popover>
  );
}