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.
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}
1"use client";23import { useState } from "react";4import { MentionPicker, type MentionItem } from "@/components/nextui/mention-picker";56export function ContextMentions({ items }: { items: MentionItem[] }) {7const [selected, setSelected] = useState<string[]>([]);89return (10<MentionPicker11items={items}12selectedIds={selected}13onSelect={(item) => setSelected((ids) => [...ids, item.id])}14onRemove={(id) => setSelected((ids) => ids.filter((x) => x !== id))}15/>16);17}
| Prop | Type | Default | Description |
|---|---|---|---|
| items | MentionItem[] | – | Available files, symbols, git diffs, docs and web sources with token estimates |
| selectedIds | string[] | – | Controlled list of attached mention IDs |
| placeholder | string | "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 |
| autoPlay | boolean | false | Loop a scripted search and attach demo |
| className | string | – | Extra classes on the wrapper |
"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>
);
}