Context Picker UI
The @-mention menu for adding context to a prompt — files, web pages and knowledge bases with search, recent items, access states and token cost.
npx shadcn@latest add https://scrimui.dev/r/context-picker.jsonAgent promptClaude Code · Cursor · any agent
Add the Context Picker UI component from the Scrim UI registry to this project and use it in the configuration described below.
Context Picker UI — The @-mention menu for adding context to a prompt — files, web pages and knowledge bases with search, recent items, access states and token cost.
## 1. Install
```bash
npx shadcn@latest add https://scrimui.dev/r/context-picker.json
```
This writes a single file to `components/ui/context-picker.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/context-picker to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { ContextPicker } from "@/components/ui/context-picker";
const ITEMS = [
{ id: "c1", kind: "file", title: "Q3-planning.md", detail: "docs/roadmap", tokens: 2400 },
{ id: "c2", kind: "file", title: "metrics.csv", detail: "Downloads", tokens: 9800 },
{ id: "c3", kind: "file", title: "old-spec-2024.pdf", detail: "drive/archive", status: "unavailable" },
{ id: "c4", kind: "web", title: "AI SDK — useChat", detail: "sdk.vercel.ai/docs", tokens: 3100, recent: true },
{ id: "c5", kind: "web", title: "Pricing page draft", detail: "Notion · shared", status: "permission-required" },
{ id: "c6", kind: "knowledge", title: "Support handbook", detail: "142 articles", tokens: 12400 },
{ id: "c7", kind: "app", title: "Linear", detail: "Workspace: scrim", status: "connecting" },
];
<ContextPicker
triggerLabel="Add context"
searchPlaceholder="Search files, pages, sources…"
emptyText="No matching context."
defaultOpen
items={ITEMS}
defaultSelectedIds={["c1"]}
onSelectionChange={() => {}}
onRequestAccess={() => {}}
/>
```
## 3. The configuration that matters
This is the component's "Open" state — Search closed, Recent floated up, one item already in the turn.
Every prop is at its default value. Keep the call site minimal — do not write out default values.
## 4. Wire the handlers
`onSelectionChange`, `onRequestAccess` are stubs in the snippet above (`() => {}`). Connect them to this project's own state and data layer instead of shipping the empty functions.
Reference: https://scrimui.dev/components/context-pickerFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
- Q3-planning.md
- ≈ 2.4k tokens
- Recent
- AI SDK — useChatsdk.vercel.ai/docs≈ 3.1k tokens
- Files
- Q3-planning.mddocs/roadmap≈ 2.4k tokens
- metrics.csvDownloads≈ 9.8k tokens
- old-spec-2024.pdfdrive/archiveUnavailable
- Web pages
- Pricing page draftNotion · shared
- Knowledge bases
- Support handbook142 articles≈ 12.4k tokens
- Apps
- LinearWorkspace: scrimConnecting…
Presets
Props
Also takes onSelectionChange, onRequestAccess.
Component source
Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.
"use client";
import * as React from "react";
/**
* The `@`-menu for pulling context into the current turn — files, web pages,
* knowledge bases and connected apps.
*
* The line this component holds: **context is not tools.** An item here is
* data that joins this conversation's context window (and costs tokens);
* it never enables an action. Tool availability belongs to the composer.
*
* Item status is per-source, not global: `permission-required` items are
* listed but must be granted before they can be selected, `connecting`
* sources are on their way, and `unavailable` items stay visible with their
* reason instead of vanishing (a silently missing file reads as a bug).
*/
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type ContextSourceKind = "file" | "web" | "knowledge" | "app";
export type ContextItemStatus = "available" | "permission-required" | "connecting" | "unavailable";
export type ContextItem = {
id: string;
kind: ContextSourceKind;
title: string;
/** Path, URL, or source detail shown under the title. */
detail?: string;
status?: ContextItemStatus;
/** Context cost of adding this item, surfaced so selection stays informed. */
tokens?: number;
/** Recently used — floated into a "Recent" section when the search is empty. */
recent?: boolean;
};
export type ContextPickerProps = {
items: ContextItem[];
/** Controlled selection. Omit and pass defaultSelectedIds for uncontrolled. */
selectedIds?: string[];
defaultSelectedIds?: string[];
onSelectionChange?: (ids: string[]) => void;
/** Fired when the user grants access to a permission-required item. */
onRequestAccess?: (item: ContextItem) => void;
defaultOpen?: boolean;
defaultQuery?: string;
triggerLabel?: string;
searchPlaceholder?: string;
emptyText?: string;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
const ICON_PROPS = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round",
strokeLinejoin: "round",
} as const;
function AtSignIcon() {
return (
<svg {...ICON_PROPS} width="14" height="14">
<circle cx="12" cy="12" r="4" />
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
</svg>
);
}
function FileIcon() {
return (
<svg {...ICON_PROPS} width="15" height="15">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
);
}
function GlobeIcon() {
return (
<svg {...ICON_PROPS} width="15" height="15">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
);
}
function KnowledgeIcon() {
return (
<svg {...ICON_PROPS} width="15" height="15">
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />
</svg>
);
}
function AppIcon() {
return (
<svg {...ICON_PROPS} width="15" height="15">
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
);
}
function XIcon() {
return (
<svg {...ICON_PROPS} width="11" height="11">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
function LockIcon() {
return (
<svg {...ICON_PROPS} width="13" height="13">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
function kindIcon(kind: ContextSourceKind) {
switch (kind) {
case "file":
return <FileIcon />;
case "web":
return <GlobeIcon />;
case "knowledge":
return <KnowledgeIcon />;
case "app":
return <AppIcon />;
}
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const KIND_LABELS: Record<ContextSourceKind, string> = {
file: "Files",
web: "Web pages",
knowledge: "Knowledge bases",
app: "Apps",
};
const KIND_ORDER: ContextSourceKind[] = ["file", "web", "knowledge", "app"];
function formatTokens(n: number): string {
if (n >= 1000) return `≈ ${(n / 1000).toFixed(1).replace(/\.0$/, "")}k tokens`;
return `≈ ${n} tokens`;
}
/* ------------------------------------------------------------------ */
/* ContextPicker */
/* ------------------------------------------------------------------ */
export function ContextPicker({
items,
selectedIds: selectedIdsProp,
defaultSelectedIds = [],
onSelectionChange,
onRequestAccess,
defaultOpen = false,
defaultQuery = "",
triggerLabel = "Add context",
searchPlaceholder = "Search files, pages, sources…",
emptyText = "No matching context.",
className = "",
}: ContextPickerProps) {
const idBase = React.useId();
const rootRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(defaultOpen);
const [query, setQuery] = React.useState(defaultQuery);
const [internalSelected, setInternalSelected] = React.useState<string[]>(defaultSelectedIds);
const selectedIds = selectedIdsProp ?? internalSelected;
const q = query.trim().toLowerCase();
const matches = items.filter(
(it) => !q || it.title.toLowerCase().includes(q) || (it.detail ?? "").toLowerCase().includes(q),
);
/* Items the keyboard can land on: selectable, or permission-required
(Enter requests access). Connecting/unavailable rows are inert. */
const actionable = matches.filter((it) => {
const status = it.status ?? "available";
return status === "available" || status === "permission-required";
});
const [activeId, setActiveId] = React.useState<string | undefined>(actionable[0]?.id);
const [prevActionableKey, setPrevActionableKey] = React.useState("");
const actionableKey = actionable.map((it) => it.id).join("");
if (actionableKey !== prevActionableKey) {
setPrevActionableKey(actionableKey);
if (!activeId || !actionable.some((it) => it.id === activeId)) {
setActiveId(actionable[0]?.id);
}
}
const selectedItems = selectedIds
.map((id) => items.find((it) => it.id === id))
.filter((it): it is ContextItem => Boolean(it));
const selectedTokens = selectedItems.reduce((sum, it) => sum + (it.tokens ?? 0), 0);
function setSelection(ids: string[]) {
setInternalSelected(ids);
onSelectionChange?.(ids);
}
function toggle(item: ContextItem) {
if (selectedIds.includes(item.id)) {
setSelection(selectedIds.filter((id) => id !== item.id));
} else {
setSelection([...selectedIds, item.id]);
}
}
function activate(item: ContextItem) {
const status = item.status ?? "available";
if (status === "permission-required") {
onRequestAccess?.(item);
} else if (status === "available") {
toggle(item);
}
}
function openPanel() {
setOpen(true);
window.setTimeout(() => inputRef.current?.focus(), 0);
}
/* Close on outside pointer-down. Listener lives in an effect; the state
write happens in the event callback, not the effect body. */
React.useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
function onInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (actionable.length === 0) return;
const i = actionable.findIndex((it) => it.id === activeId);
const next =
e.key === "ArrowDown"
? actionable[(i + 1 + actionable.length) % actionable.length]
: actionable[(i - 1 + actionable.length) % actionable.length];
setActiveId(next.id);
} else if (e.key === "Enter") {
e.preventDefault();
const item = actionable.find((it) => it.id === activeId);
if (item) activate(item);
} else if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
}
const recentItems = !q ? matches.filter((it) => it.recent) : [];
const recentIds = new Set(recentItems.map((it) => it.id));
const grouped = KIND_ORDER.map((kind) => ({
kind,
items: matches.filter((it) => it.kind === kind && !recentIds.has(it.id)),
})).filter((g) => g.items.length > 0);
function renderOption(item: ContextItem) {
const status = item.status ?? "available";
const selected = selectedIds.includes(item.id);
const inert = status === "connecting" || status === "unavailable";
const active = item.id === activeId && !inert;
return (
<li
key={item.id}
id={`${idBase}-option-${item.id}`}
role="option"
aria-selected={selected}
aria-disabled={inert || undefined}
onMouseEnter={() => !inert && setActiveId(item.id)}
onClick={() => activate(item)}
className={`flex cursor-pointer items-center gap-2.5 px-3 py-2 ${
active ? "bg-zinc-100 dark:bg-zinc-800" : ""
} ${inert ? "cursor-default opacity-60" : ""}`}
>
<span className="shrink-0 text-zinc-400 dark:text-zinc-500">{kindIcon(item.kind)}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-zinc-800 dark:text-zinc-100">
{item.title}
</span>
{item.detail && (
<span className="block truncate text-xs text-zinc-500 dark:text-zinc-400">{item.detail}</span>
)}
</span>
{status === "permission-required" && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRequestAccess?.(item);
}}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-zinc-200 px-2 py-0.5 text-[11px] font-medium text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<LockIcon />
Grant
</button>
)}
{status === "connecting" && (
<span className="shrink-0 text-[11px] text-zinc-400 dark:text-zinc-500">Connecting…</span>
)}
{status === "unavailable" && (
<span className="shrink-0 text-[11px] text-zinc-400 dark:text-zinc-500">Unavailable</span>
)}
{status === "available" && item.tokens != null && (
<span className="shrink-0 text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">
{formatTokens(item.tokens)}
</span>
)}
{selected && (
<span className="shrink-0 text-blue-600 dark:text-blue-400" aria-label="Selected">
<svg {...ICON_PROPS} width="14" height="14">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
)}
</li>
);
}
return (
<div ref={rootRef} className={`relative ${className}`}>
{/* Selected context — chips so removal is one click, no reopening. */}
{selectedItems.length > 0 && (
<ul className="mb-2 flex flex-wrap items-center gap-1.5" aria-label="Selected context">
{selectedItems.map((item) => (
<li
key={item.id}
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-zinc-50 py-1 pl-2 pr-1 text-xs font-medium text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800/70 dark:text-zinc-200"
>
<span className="text-zinc-400 dark:text-zinc-500">{kindIcon(item.kind)}</span>
<span className="max-w-[180px] truncate">{item.title}</span>
<button
type="button"
onClick={() => toggle(item)}
aria-label={`Remove ${item.title} from context`}
className="rounded p-0.5 text-zinc-400 hover:bg-zinc-200 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
>
<XIcon />
</button>
</li>
))}
{selectedTokens > 0 && (
<li className="pl-1 text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">
{formatTokens(selectedTokens)}
</li>
)}
</ul>
)}
<button
type="button"
onClick={() => (open ? setOpen(false) : openPanel())}
aria-expanded={open}
aria-controls={`${idBase}-listbox`}
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<AtSignIcon />
{triggerLabel}
</button>
{open && (
<div className="absolute bottom-full left-0 z-20 mb-2 w-[320px] overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<div className="border-b border-zinc-100 px-3 py-2 dark:border-zinc-800">
<input
ref={inputRef}
role="combobox"
aria-expanded="true"
aria-controls={`${idBase}-listbox`}
aria-activedescendant={activeId ? `${idBase}-option-${activeId}` : undefined}
aria-label="Search context"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKeyDown}
placeholder={searchPlaceholder}
className="w-full bg-transparent text-[13px] text-zinc-800 outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500"
/>
</div>
<div className="max-h-[280px] overflow-y-auto">
{matches.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-zinc-500 dark:text-zinc-400">
<p>{emptyText}</p>
{q && (
<button
type="button"
onClick={() => setQuery("")}
className="mt-2 rounded-md border border-zinc-200 px-2.5 py-1 text-[11px] font-medium text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
Clear search
</button>
)}
</div>
) : (
<ul role="listbox" id={`${idBase}-listbox`} aria-label="Available context" className="py-1">
{recentItems.length > 0 && (
<>
<li className="px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
Recent
</li>
{recentItems.map(renderOption)}
</>
)}
{grouped.map((g) => (
<React.Fragment key={g.kind}>
<li className="px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
{KIND_LABELS[g.kind]}
</li>
{g.items.map(renderOption)}
</React.Fragment>
))}
</ul>
)}
</div>
{selectedItems.length > 0 && (
<div className="border-t border-zinc-100 px-3 py-1.5 text-[11px] text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
{selectedItems.length} in this turn
{selectedTokens > 0 && ` · ${formatTokens(selectedTokens)}`}
</div>
)}
</div>
)}
</div>
);
}
When to use it
- Open from a visible trigger or the @ key in the composer — the menu is the same either way.
- Keep context and tools apart: an item here adds data to the turn and costs tokens; it never enables an action.
- Show the token cost per source and for the whole selection, so adding a 200-page PDF is an informed choice.
- List permission-required and unavailable sources with their state instead of silently omitting them.
- Surface the selection where the turn is composed — chips above the input, mirrored in a Context Files panel.
What breaks in production
- Mixing tool toggles into the context list — one is data for this turn, the other is an enabled capability.
- Auto-advancing focus or stealing keyboard control from the composer when the menu opens.
- Hiding a source the user attached last week with no 'unavailable' marker — it reads as data loss.
- Letting selection live only inside the popover — closed menu, invisible context, surprise token bill.
Related Components
A drag-and-drop upload zone — per-file progress bars, type icons, and error states for files too big or of the wrong type.
The files currently in the model's context — sizes, how many tokens each one costs, and removal without leaving the chat.
How much of the context window is gone and to what — system prompt, files, history — plus what gets dropped first when the next message does not fit.
The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.