nextui.dev
Home
Docs
Saved
Pricing
Components37
  • Forms & Inputs7
  • Cards13
  • Buttons, Badges & Avatars8
  • Feedback & Display6
  • Effects3
Agents46
  • Input & Context9
  • Thinking & Status6
  • Tools & Execution7
  • Human-in-the-Loop & Safety7
  • Multi-Agent & Workflows4
  • Output & Artifacts8
  • Voice & Memory5
Blocks20
  • Agent chatNew
  • Pill heroNew
  • Grid hero
  • Moodboard generatorNew
  • Waitlist heroNew
  • Intro bannerNew
  • Creative home heroNew
  • Chat heroNew
  • App shelfNew
  • Usage card
  • Design resultsNew
  • Pricing tiers
  • Dashboard home
  • The shiftNew
  • Launch perksNew
  • Testimonials
  • Quote
  • Team
  • Leads tableNew
  • Sign in
Pages16
  • ASCII workspace$49
  • Research agent$49
  • Support agent$49
  • Coding agent$49
  • CRM overviewNew
  • Marketing dashboardNew
  • Developer portfolio$49
  • Particle landing$49
  • Particle story$49
  • Statement converter$49
  • AI photo studio$49
  • Group card launch$49
  • Month-end storyNew
  • Task HubNew
  • Global MediaNew
  • Hexora ecosystem$49
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
UpgradeDark modeLight modeFeedbackGitHub
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextui.dev

© 2026 nextui.dev • All rights reserved.

UpgradeDark modeLight modeFeedbackGitHub
ComponentsAI & AgentsMention picker
Copy promptCopy prompt

Customize

Placeholder in the @ filter input

Loop a scripted search and attach demo

JSX · copied with Copy prompt

<MentionPicker autoPlay />

Mention pickerNew

Context mention popover for an AI prompt composer: type @ to filter and attach files, symbols, git diffs, docs or web pages with keyboard navigation and live token estimates.

Installation

Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.

$pnpm dlx shadcn@latest add @nextui/mention-picker
1{
2 "registries": {
3 "@nextui": {
4 "url": "https://nextui.dev/r/{name}.json",
5 "headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }
6 }
7 }
8}

Usage

1"use client";
2
3import { useState } from "react";
4import { MentionPicker, type MentionItem } from "@/components/nextui/mention-picker";
5
6export function ContextMentions({ items }: { items: MentionItem[] }) {
7 const [selected, setSelected] = useState<string[]>([]);
8
9 return (
10 <MentionPicker
11 items={items}
12 selectedIds={selected}
13 onSelect={(item) => setSelected((ids) => [...ids, item.id])}
14 onRemove={(id) => setSelected((ids) => ids.filter((x) => x !== id))}
15 />
16 );
17}

API reference

Edit props live in the preview above, or see all 7 props
PropTypeDefaultDescription
itemsMentionItem[]–Available files, symbols, git diffs, docs and web sources with token estimates
selectedIdsstring[]–Controlled list of attached mention IDs
placeholderstring"Ask anything · type @ to add context"Placeholder in the @ filter input
onSelect(item: MentionItem) => void–Called when a mention candidate is attached
onRemove(id: string) => void–Called when an attached chip is removed
autoPlaybooleanfalseLoop a scripted search and attach demo
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/mention-picker.tsx
"use client";

import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { AtSign, BookOpen, Braces, FileCode, Folder, GitCompare, Globe, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { cn } from "@/lib/utils";

export type MentionKind = "file" | "folder" | "symbol" | "docs" | "web" | "git";

export type MentionItem = {
  id: string;
  label: string;
  detail: string;
  kind: MentionKind;
  /** Estimated tokens the item adds to the context window. */
  tokens: number;
};

export type MentionPickerProps = {
  items?: MentionItem[];
  /** Controlled list of attached item ids. */
  selectedIds?: string[];
  placeholder?: string;
  onSelect?: (item: MentionItem) => void;
  onRemove?: (id: string) => void;
  /** Loop a scripted type → filter → attach demo. */
  autoPlay?: boolean;
  className?: string;
};

const DEFAULT_ITEMS: MentionItem[] = [
  { id: "f-auth", label: "auth-session.ts", detail: "src/lib/auth-session.ts", kind: "file", tokens: 1240 },
  { id: "f-mw", label: "middleware.ts", detail: "src/middleware.ts", kind: "file", tokens: 860 },
  { id: "f-login", label: "login-form.tsx", detail: "app/login/login-form.tsx", kind: "file", tokens: 1860 },
  { id: "d-auth", label: "auth/", detail: "src/lib/auth · 6 files", kind: "folder", tokens: 4200 },
  { id: "s-refresh", label: "refreshToken()", detail: "auth-session.ts:42", kind: "symbol", tokens: 420 },
  { id: "s-use", label: "useSession", detail: "hooks/use-session.ts:8", kind: "symbol", tokens: 310 },
  { id: "docs-session", label: "Session API reference", detail: "docs/session-api.md", kind: "docs", tokens: 2100 },
  { id: "w-rfc", label: "Refresh tokens · RFC 6749 §6", detail: "Fetched from the web", kind: "web", tokens: 1680 },
  { id: "g-diff", label: "Working tree diff", detail: "3 files · +84 −12", kind: "git", tokens: 950 },
  { id: "g-last", label: "Last commit a1f9c2e", detail: "fix: cookie domain", kind: "git", tokens: 640 },
];

const KINDS: { id: "all" | MentionKind; label: string }[] = [
  { id: "all", label: "All" },
  { id: "file", label: "Files" },
  { id: "folder", label: "Folders" },
  { id: "symbol", label: "Symbols" },
  { id: "docs", label: "Docs" },
  { id: "web", label: "Web" },
  { id: "git", label: "Git diff" },
];

const ICONS: Record<MentionKind, typeof FileCode> = {
  file: FileCode,
  folder: Folder,
  symbol: Braces,
  docs: BookOpen,
  web: Globe,
  git: GitCompare,
};

const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 10400;
const BASE_TEXT = "Fix the refresh bug in ";

function useDemoClock(enabled: boolean, loop: number) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible, setVisible] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  useEffect(() => {
    const el = ref.current;
    if (!el || !enabled) return;
    const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, [enabled]);
  useEffect(() => {
    if (!enabled || !visible) return;
    const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
    return () => clearInterval(id);
  }, [enabled, visible]);
  return { ref, t: elapsed % loop };
}

/** Subsequence match. Returns matched indexes and a score (higher is better), or null. */
function fuzzy(query: string, text: string): { idx: number[]; score: number } | null {
  if (!query) return { idx: [], score: 0 };
  const q = query.toLowerCase();
  const s = text.toLowerCase();
  const idx: number[] = [];
  let score = 0;
  let from = 0;
  for (const ch of q) {
    const at = s.indexOf(ch, from);
    if (at === -1) return null;
    score += at === from ? 3 : 1;
    if (at === 0 || /[\s/._-]/.test(s[at - 1] ?? "")) score += 2;
    idx.push(at);
    from = at + 1;
  }
  return { idx, score: score - idx[0] * 0.1 };
}

function search(items: MentionItem[], query: string, kind: "all" | MentionKind) {
  return items
    .filter((i) => kind === "all" || i.kind === kind)
    .map((item) => {
      const m = fuzzy(query, item.label);
      const d = m ? null : fuzzy(query, item.detail);
      return { item, match: m, score: m ? m.score + 2 : d ? d.score : -1 };
    })
    .filter((r) => r.score >= 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, 5);
}

type View = { text: string; query: string | null; kind: "all" | MentionKind; active: number; selected: string[] };

function typed(str: string, t: number, start: number, per: number) {
  if (t < start) return "";
  return str.slice(0, Math.min(str.length, Math.floor((t - start) / per) + 1));
}

function demoView(t: number): View {
  const v: View = { text: BASE_TEXT, query: null, kind: "all", active: 0, selected: [] };
  if (t >= 600 && t < 3000) {
    v.query = typed("auth", t, 1000, 200);
    v.active = t >= 2000 && t < 2500 ? 1 : 0;
  }
  if (t >= 3000) {
    v.selected = ["f-auth"];
    v.text = `${BASE_TEXT}@auth-session.ts ${typed("against ", t, 3500, 80)}`;
  }
  if (t >= 4300 && t < 5600) {
    v.query = "";
    v.kind = t >= 4900 ? "git" : "all";
  }
  if (t >= 5600) {
    v.selected = ["f-auth", "g-diff"];
    v.text = `${BASE_TEXT}@auth-session.ts against @working-tree ${typed("per ", t, 6100, 80)}`;
  }
  if (t >= 6500 && t < 8200) v.query = typed("sesapi", t, 6900, 150);
  if (t >= 8200) {
    v.selected = ["f-auth", "g-diff", "docs-session"];
    v.text = `${BASE_TEXT}@auth-session.ts against @working-tree per @session-api `;
  }
  return v;
}

/** The token written into the prompt for an attached item, e.g. "@auth-session.ts". */
function handleFor(item: MentionItem) {
  if (item.kind === "file" || item.kind === "folder" || item.kind === "symbol") return item.label;
  return item.label.toLowerCase().split(/\s+/).slice(0, 2).join("-");
}

function Mentions({ text }: { text: string }) {
  return (
    <>
      {text.split(/(@\S+)/).map((part, i) =>
        part.startsWith("@") ? (
          <span key={i} className="rounded-sm bg-foreground/[0.07] font-medium">
            {part}
          </span>
        ) : (
          part
        ),
      )}
    </>
  );
}

function formatTokens(n: number) {
  return n >= 1000 ? `~${(n / 1000).toFixed(1)}k tok` : `~${Math.round(n)} tok`;
}

function Highlight({ text, idx }: { text: string; idx: number[] }) {
  if (!idx.length) return <>{text}</>;
  const set = new Set(idx);
  return (
    <>
      {[...text].map((ch, i) =>
        set.has(i) ? (
          <mark key={i} className="rounded-[2px] bg-foreground/10 text-foreground underline decoration-foreground/40 underline-offset-2">
            {ch}
          </mark>
        ) : (
          <span key={i}>{ch}</span>
        ),
      )}
    </>
  );
}

function TokenMeter({ tokens }: { tokens: number }) {
  const level = tokens < 600 ? 1 : tokens < 1500 ? 2 : tokens < 3000 ? 3 : 4;
  return (
    <span className="flex items-end gap-px" aria-hidden>
      {[1, 2, 3, 4].map((b) => (
        <span key={b} className={cn("w-[3px] rounded-[1px]", b <= level ? "bg-foreground/60" : "bg-foreground/15")} style={{ height: 3 + b * 2 }} />
      ))}
    </span>
  );
}

/** "@" context menu for a prompt box: fuzzy search over files, folders, symbols, docs, web and git, with token estimates and keyboard navigation. */
export function MentionPicker({
  items = DEFAULT_ITEMS,
  selectedIds: controlledSelected,
  placeholder = "Ask anything · type @ to add context",
  onSelect,
  onRemove,
  autoPlay = false,
  className,
}: MentionPickerProps) {
  const reduce = useReducedMotion() ?? false;
  const uid = useId();
  const [touched, setTouched] = useState(false);
  const [focused, setFocused] = useState(false);
  const [view, setView] = useState<View>({ text: "", query: null, kind: "all", active: 0, selected: ["f-auth", "g-diff"] });
  const demo = autoPlay && !reduce && !touched;
  const { ref, t } = useDemoClock(demo, LOOP);

  // Sync the scripted frame during render (no effect → no extra commit per tick).
  const [syncedT, setSyncedT] = useState(-1);
  if (demo && t !== syncedT) {
    setSyncedT(t);
    setView(demoView(t));
  }

  const selectedIds = controlledSelected ?? view.selected;
  const results = useMemo(() => (view.query === null ? [] : search(items, view.query, view.kind)), [items, view.query, view.kind]);
  const selectedItems = selectedIds.map((id) => items.find((i) => i.id === id)).filter(Boolean) as MentionItem[];
  const total = selectedItems.reduce((n, i) => n + i.tokens, 0);

  const totalSpring = useSpring(total, { stiffness: 140, damping: 22 });
  useEffect(() => {
    totalSpring.set(total);
  }, [total, totalSpring]);
  const totalText = useTransform(totalSpring, (v) => formatTokens(v));

  const shownText = view.query === null ? view.text : `${view.text}@${view.query}`;

  const attach = (item: MentionItem) => {
    setTouched(true);
    if (!selectedIds.includes(item.id)) {
      onSelect?.(item);
      if (!controlledSelected) setView((v) => ({ ...v, selected: [...v.selected, item.id] }));
    }
    setView((v) => ({ ...v, text: `${v.text}@${handleFor(item)} `, query: null, kind: "all", active: 0 }));
  };

  const detach = (id: string) => {
    setTouched(true);
    onRemove?.(id);
    if (!controlledSelected) setView((v) => ({ ...v, selected: v.selected.filter((x) => x !== id) }));
  };

  const onChange = (value: string) => {
    setTouched(true);
    const m = value.match(/(^|\s)@(\S*)$/);
    setView((v) =>
      m
        ? { ...v, text: value.slice(0, value.length - m[2].length - 1), query: m[2], active: 0 }
        : { ...v, text: value, query: null, kind: "all" },
    );
  };

  const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (view.query === null) {
      if (e.key === "Enter" && !e.shiftKey) e.preventDefault();
      return;
    }
    if (e.key === "Escape") {
      e.preventDefault();
      setView((v) => ({ ...v, text: shownText, query: null }));
    } else if (!results.length) return;
    else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      const d = e.key === "ArrowDown" ? 1 : -1;
      setView((v) => ({ ...v, active: (v.active + d + results.length) % results.length }));
    } else if (e.key === "Enter" || e.key === "Tab") {
      e.preventDefault();
      const target = results[view.active];
      if (target) attach(target.item);
    }
  };

  const open = view.query !== null;
  const listId = `${uid}-list`;

  return (
    <div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
      <div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
        {/* Attached context */}
        <div className="flex min-h-11 items-center gap-2 border-b bg-gradient-to-b from-muted/50 to-transparent px-3 py-2">
          <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
            <AnimatePresence mode="popLayout" initial={false}>
              {selectedItems.length === 0 && (
                <motion.span key="empty" layout className="px-1 text-xs text-muted-foreground" exit={{ opacity: 0 }}>
                  No context attached
                </motion.span>
              )}
              {selectedItems.map((item) => {
                const Icon = ICONS[item.kind];
                return (
                  <motion.span
                    key={item.id}
                    layout={!reduce}
                    initial={reduce ? false : { opacity: 0, scale: 0.6, y: 10, filter: "blur(4px)" }}
                    animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }}
                    exit={reduce ? undefined : { opacity: 0, scale: 0.7 }}
                    transition={{ type: "spring", stiffness: 520, damping: 30 }}
                    className="inline-flex h-6 items-center gap-1 rounded-md border bg-background pr-1 pl-1.5 text-[11px] font-medium shadow-xs"
                  >
                    <Icon className="size-3 text-muted-foreground" aria-hidden />
                    <span className="max-w-[118px] truncate">{item.label}</span>
                    <button
                      type="button"
                      aria-label={`Remove ${item.label}`}
                      onClick={() => detach(item.id)}
                      className="grid size-4 cursor-pointer place-items-center rounded text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                    >
                      <X className="size-2.5" aria-hidden />
                    </button>
                  </motion.span>
                );
              })}
            </AnimatePresence>
          </div>
          <motion.span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground" aria-live="polite">
            {totalText}
          </motion.span>
        </div>

        {/* Prompt line */}
        <div className="relative flex items-start gap-2 px-3.5 py-3">
          <AtSign className="mt-[3px] size-3.5 shrink-0 text-muted-foreground" aria-hidden />
          <div className="relative min-w-0 flex-1">
            <textarea
              rows={2}
              role="combobox"
              aria-expanded={open}
              aria-controls={listId}
              aria-activedescendant={open && results[view.active] ? `${uid}-${results[view.active].item.id}` : undefined}
              aria-autocomplete="list"
              value={shownText}
              onChange={(e) => onChange(e.target.value)}
              onKeyDown={onKeyDown}
              onFocus={() => setFocused(true)}
              onBlur={() => setFocused(false)}
              placeholder={placeholder}
              className={cn(
                "block h-10 w-full resize-none bg-transparent text-[13px] leading-5 outline-none placeholder:text-muted-foreground",
                !focused && demo && "text-transparent",
              )}
            />
            {!focused && demo && (
              <span className="pointer-events-none absolute inset-0 overflow-hidden text-[13px] leading-5 break-words whitespace-pre-wrap" aria-hidden>
                <Mentions text={view.text} />
                {view.query !== null && <span className="rounded-sm bg-foreground/[0.07] font-medium">@{view.query}</span>}
                <span className="mp-caret ml-px inline-block h-[1.05em] w-[1.5px] translate-y-[2px] bg-foreground align-baseline" />
              </span>
            )}
          </div>
        </div>

        {/* Popover */}
        <AnimatePresence initial={false}>
          {open && (
            <motion.div
              key="popover"
              initial={reduce ? false : { height: 0, opacity: 0 }}
              animate={{ height: "auto", opacity: 1 }}
              exit={reduce ? undefined : { height: 0, opacity: 0 }}
              transition={{ duration: 0.32, ease: EASE }}
              className="overflow-hidden border-t"
            >
              <div className="flex gap-1 overflow-x-auto px-2.5 pt-2 [scrollbar-width:none]" role="tablist" aria-label="Context type">
                {KINDS.map((k) => {
                  const on = view.kind === k.id;
                  return (
                    <button
                      key={k.id}
                      type="button"
                      role="tab"
                      aria-selected={on}
                      onMouseDown={(e) => e.preventDefault()}
                      onClick={() => {
                        setTouched(true);
                        setView((v) => ({ ...v, kind: k.id, active: 0 }));
                      }}
                      className={cn(
                        "relative h-6 shrink-0 cursor-pointer rounded-md px-2 text-[11px] font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                        on ? "text-background" : "text-muted-foreground hover:text-foreground",
                      )}
                    >
                      {on && (
                        <motion.span
                          layoutId={`${uid}-tab`}
                          className="absolute inset-0 rounded-md bg-foreground"
                          transition={{ type: "spring", stiffness: 500, damping: 36 }}
                        />
                      )}
                      <span className="relative">{k.label}</span>
                    </button>
                  );
                })}
              </div>
              <ul id={listId} role="listbox" className="space-y-px p-1.5">
                {results.map(({ item, match }, idx) => {
                  const Icon = ICONS[item.kind];
                  const on = idx === view.active;
                  const added = selectedIds.includes(item.id);
                  return (
                    <li key={item.id} id={`${uid}-${item.id}`} role="option" aria-selected={on}>
                      <button
                        type="button"
                        tabIndex={-1}
                        onMouseDown={(e) => e.preventDefault()}
                        onMouseEnter={() => setView((v) => ({ ...v, active: idx }))}
                        onClick={() => attach(item)}
                        className="relative flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-xs outline-none"
                      >
                        {on && (
                          <motion.span
                            layoutId={`${uid}-active`}
                            className="absolute inset-0 rounded-lg bg-muted"
                            transition={{ type: "spring", stiffness: 600, damping: 40 }}
                          />
                        )}
                        <span className={cn("relative grid size-6 shrink-0 place-items-center rounded-md border bg-background transition-colors", on && "border-foreground/20")}>
                          <Icon className="size-3.5 text-muted-foreground" aria-hidden />
                        </span>
                        <span className="relative min-w-0 flex-1">
                          <span className="block truncate font-medium">
                            <Highlight text={item.label} idx={match?.idx ?? []} />
                          </span>
                          <span className="block truncate font-mono text-[10px] text-muted-foreground">{item.detail}</span>
                        </span>
                        <span className="relative flex shrink-0 items-center gap-1.5 font-mono text-[10px] tabular-nums text-muted-foreground">
                          {added ? <span className="rounded bg-foreground px-1 text-background">added</span> : formatTokens(item.tokens)}
                          <TokenMeter tokens={item.tokens} />
                        </span>
                      </button>
                    </li>
                  );
                })}
                {results.length === 0 && <li className="px-3 py-5 text-center text-xs text-muted-foreground">No matches for “{view.query}”</li>}
              </ul>
              <div className="flex items-center gap-3 border-t px-3.5 py-1.5 font-mono text-[10px] text-muted-foreground">
                <span>
                  <Kbd>↑</Kbd>
                  <Kbd>↓</Kbd> move
                </span>
                <span>
                  <Kbd>↵</Kbd> attach
                </span>
                <span>
                  <Kbd>esc</Kbd> close
                </span>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
      <style>{`
        .mp-caret { animation: mp-caret 1s steps(1) infinite; }
        @keyframes mp-caret { 50% { opacity: 0; } }
      `}</style>
    </div>
  );
}

function Kbd({ children }: { children: React.ReactNode }) {
  return <kbd className="mr-0.5 inline-grid min-w-4 place-items-center rounded border bg-background px-1 text-[10px] leading-4">{children}</kbd>;
}

export default function MentionPickerDemo() {
  return (
    <div className="flex h-[380px] w-full items-start justify-center pt-2">
      <MentionPicker autoPlay />
    </div>
  );
}
Sign in