Nothing selected yet
import * as React from "react";
import {
ChatBubbleLeftRightIcon,
Cog6ToothIcon,
DocumentTextIcon,
PlusIcon,
SparklesIcon,
UserIcon,
} from "@heroicons-animated/react";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import {
CommandMenu,
CommandMenuEmpty,
CommandMenuFooter,
CommandMenuGroup,
CommandMenuInput,
CommandMenuItem,
CommandMenuList,
useCommandMenuShortcut,
} from "@/components/pandacoderz-ui/command-menu";
export default function CommandMenuDemo() {
const [open, setOpen] = React.useState(false);
const [last, setLast] = React.useState<string | null>(null);
useCommandMenuShortcut(React.useCallback(() => setOpen((o) => !o), []));
const pick = (label: string) => {
setLast(label);
setOpen(false);
};
return (
<div className="flex flex-col items-center gap-3">
<Button variant="outline" onClick={() => setOpen(true)}>
Open command menu
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</Button>
<p className="text-xs text-muted-foreground">{last ? `Selected: ${last}` : "Nothing selected yet"}</p>
<CommandMenu open={open} onOpenChange={setOpen}>
<CommandMenuInput placeholder="Search or ask…" />
<CommandMenuList>
<CommandMenuGroup heading="Actions">
<CommandMenuItem icon={<PlusIcon size={16} className="flex" />} shortcut="N" onSelect={() => pick("New conversation")}>New conversation</CommandMenuItem>
<CommandMenuItem icon={<DocumentTextIcon size={16} className="flex" />} onSelect={() => pick("Summarize page")}>Summarize this page</CommandMenuItem>
<CommandMenuItem icon={<Cog6ToothIcon size={16} className="flex" />} shortcut="," onSelect={() => pick("Settings")}>Open settings</CommandMenuItem>
</CommandMenuGroup>
<CommandMenuGroup heading="Recent">
<CommandMenuItem icon={<ChatBubbleLeftRightIcon size={16} className="flex" />} onSelect={() => pick("Streaming responses")}>Streaming responses in Astro</CommandMenuItem>
<CommandMenuItem icon={<UserIcon size={16} className="flex" />} onSelect={() => pick("Onboarding copy")}>Onboarding copy review</CommandMenuItem>
</CommandMenuGroup>
<CommandMenuGroup heading="AI">
<CommandMenuItem alwaysVisible icon={<SparklesIcon size={16} className="flex" />} onSelect={() => pick("Ask AI")}>Ask AI about this…</CommandMenuItem>
</CommandMenuGroup>
<CommandMenuEmpty />
</CommandMenuList>
<CommandMenuFooter>
<span><Kbd>↑↓</Kbd> navigate</span>
<span><Kbd>↵</Kbd> select</span>
</CommandMenuFooter>
</CommandMenu>
</div>
);
}Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/command-menu.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/command-menu.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/command-menu.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/command-menu.jsonInstall the dependencies:
npm install radix-ui @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add kbdCopy the source into your project:
"use client";
import * as React from "react";
import { Dialog } from "radix-ui";
import { MagnifyingGlassIcon } from "@heroicons-animated/react";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";
/**
* Cmd-K style menu built on Radix Dialog. Filtering is by `value` text and
* keyboard navigation walks the visible items, so groups can be static JSX.
*/
type CommandMenuContextValue = {
query: string;
setQuery: (q: string) => void;
listRef: React.RefObject<HTMLDivElement | null>;
close: () => void;
};
const CommandMenuContext = React.createContext<CommandMenuContextValue | null>(null);
function useCommandMenu(component: string) {
const ctx = React.useContext(CommandMenuContext);
if (!ctx) throw new Error(`${component} must be used within <CommandMenu>`);
return ctx;
}
type CommandMenuProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Controlled search text (optional). */
query?: string;
onQueryChange?: (q: string) => void;
className?: string;
children: React.ReactNode;
};
function CommandMenu({ open, onOpenChange, query: queryProp, onQueryChange, className, children }: CommandMenuProps) {
const [internalQuery, setInternalQuery] = React.useState("");
const query = queryProp ?? internalQuery;
const setQuery = React.useCallback(
(q: string) => {
setInternalQuery(q);
onQueryChange?.(q);
},
[onQueryChange],
);
const listRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (!open) setQuery("");
}, [open, setQuery]);
const value = React.useMemo<CommandMenuContextValue>(
() => ({ query, setQuery, listRef, close: () => onOpenChange(false) }),
[query, setQuery, onOpenChange],
);
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-background/60 backdrop-blur-sm data-closed:animate-out data-closed:fade-out-0 data-open:animate-in data-open:fade-in-0" />
<Dialog.Content
data-slot="command-menu"
aria-describedby={undefined}
className={cn(
"fixed top-[15%] left-1/2 z-50 w-[min(640px,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-modal outline-none data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
className,
)}
>
<Dialog.Title className="sr-only">Command menu</Dialog.Title>
<CommandMenuContext.Provider value={value}>{children}</CommandMenuContext.Provider>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
/** Inline variant for embedding in a page instead of a dialog. */
function CommandMenuInline({
className,
children,
query: queryProp,
onQueryChange,
}: {
className?: string;
children: React.ReactNode;
query?: string;
onQueryChange?: (q: string) => void;
}) {
const [internalQuery, setInternalQuery] = React.useState("");
const query = queryProp ?? internalQuery;
const setQuery = React.useCallback(
(q: string) => {
setInternalQuery(q);
onQueryChange?.(q);
},
[onQueryChange],
);
const listRef = React.useRef<HTMLDivElement | null>(null);
const value = React.useMemo<CommandMenuContextValue>(
() => ({ query, setQuery, listRef, close: () => {} }),
[query, setQuery],
);
return (
<div data-slot="command-menu" className={cn("w-full overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-xs", className)}>
<CommandMenuContext.Provider value={value}>{children}</CommandMenuContext.Provider>
</div>
);
}
function visibleItems(list: HTMLElement | null) {
return Array.from(list?.querySelectorAll<HTMLElement>("[data-command-item]:not([hidden])") ?? []);
}
type CommandMenuInputProps = Omit<React.ComponentProps<"input">, "value" | "onChange"> & {
/** Slot rendered at the right of the input (e.g. a Kbd hint). */
trailing?: React.ReactNode;
};
function CommandMenuInput({ className, placeholder = "Type a command or search…", trailing, onKeyDown, ...props }: CommandMenuInputProps) {
const { query, setQuery, listRef, close } = useCommandMenu("CommandMenuInput");
const move = (dir: 1 | -1) => {
const items = visibleItems(listRef.current);
if (!items.length) return;
const current = items.findIndex((el) => el.dataset.active === "true");
const next = (current + dir + items.length) % items.length;
items.forEach((el, i) => (el.dataset.active = i === next ? "true" : "false"));
items[next].scrollIntoView({ block: "nearest" });
};
return (
<div className="flex h-12 items-center gap-2 border-b px-4">
<MagnifyingGlassIcon size={16} className="flex shrink-0 text-muted-foreground" />
<input
data-slot="command-menu-input"
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className={cn("h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground", className)}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
if (e.key === "ArrowDown") {
e.preventDefault();
move(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
move(-1);
} else if (e.key === "Enter") {
const active = visibleItems(listRef.current).find((el) => el.dataset.active === "true");
if (active) {
e.preventDefault();
active.click();
}
} else if (e.key === "Escape") {
close();
}
}}
{...props}
/>
{trailing ?? <Kbd className="hidden sm:inline-flex">esc</Kbd>}
</div>
);
}
function CommandMenuList({ className, children, ...props }: React.ComponentProps<"div">) {
const { listRef, query } = useCommandMenu("CommandMenuList");
// Make sure exactly one visible item is active whenever the query changes.
React.useEffect(() => {
const items = visibleItems(listRef.current);
if (!items.some((el) => el.dataset.active === "true") && items[0]) {
items[0].dataset.active = "true";
}
}, [query, listRef, children]);
return (
<div
ref={listRef}
data-slot="command-menu-list"
role="listbox"
className={cn("max-h-[min(60vh,420px)] overflow-y-auto p-2", className)}
{...props}
>
{children}
</div>
);
}
function CommandMenuGroup({ heading, className, children, ...props }: React.ComponentProps<"div"> & { heading?: React.ReactNode }) {
return (
<div data-slot="command-menu-group" role="group" className={cn("py-1 [&:has([data-command-item]:not([hidden]))]:block hidden", className)} {...props}>
{heading ? <div className="px-2 pb-1.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{heading}</div> : null}
{children}
</div>
);
}
type CommandMenuItemProps = Omit<React.ComponentProps<"button">, "value" | "onSelect"> & {
/** Text used for filtering. Defaults to the string children. */
value?: string;
/** Always show regardless of the query (e.g. "Ask AI about …"). */
alwaysVisible?: boolean;
icon?: React.ReactNode;
shortcut?: string;
onSelect?: () => void;
};
function CommandMenuItem({ value, alwaysVisible, icon, shortcut, onSelect, className, children, ...props }: CommandMenuItemProps) {
const { query } = useCommandMenu("CommandMenuItem");
const text = value ?? (typeof children === "string" ? children : "");
const hidden = !alwaysVisible && query.trim() !== "" && !text.toLowerCase().includes(query.trim().toLowerCase());
return (
<button
type="button"
role="option"
data-command-item
hidden={hidden}
aria-selected={false}
onMouseMove={(e) => {
const list = e.currentTarget.closest("[data-slot=command-menu-list]");
visibleItems(list as HTMLElement | null).forEach((el) => (el.dataset.active = el === e.currentTarget ? "true" : "false"));
}}
onClick={() => onSelect?.()}
className={cn(
"flex h-10 w-full items-center gap-3 rounded-lg px-2 text-left text-sm text-foreground outline-none transition-colors data-[active=true]:bg-accent",
className,
)}
{...props}
>
{icon ? <span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">{icon}</span> : null}
<span className="min-w-0 flex-1 truncate">{children}</span>
{shortcut ? <Kbd>{shortcut}</Kbd> : null}
</button>
);
}
function CommandMenuEmpty({ className, children = "No results.", ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="command-menu-empty"
className={cn("px-2 py-8 text-center text-sm text-muted-foreground [[data-slot=command-menu-list]:has([data-command-item]:not([hidden]))_&]:hidden", className)}
{...props}
>
{children}
</div>
);
}
function CommandMenuSeparator({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="command-menu-separator" className={cn("my-1 h-px bg-border", className)} {...props} />;
}
function CommandMenuFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="command-menu-footer" className={cn("flex items-center gap-3 border-t px-4 py-2 text-[11px] text-muted-foreground", className)} {...props} />;
}
/** Bind a global shortcut (⌘K / Ctrl+K by default) to a toggle callback. */
function useCommandMenuShortcut(toggle: () => void, key = "k") {
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === key) {
e.preventDefault();
toggle();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [toggle, key]);
}
export {
CommandMenu,
CommandMenuInline,
CommandMenuInput,
CommandMenuList,
CommandMenuGroup,
CommandMenuItem,
CommandMenuEmpty,
CommandMenuSeparator,
CommandMenuFooter,
useCommandMenuShortcut,
};Built on Radix Dialog with no cmdk dependency. Items filter on their text and arrow keys walk whatever is visible, so groups can be plain JSX.
Usage
import {
CommandMenu,
CommandMenuEmpty,
CommandMenuGroup,
CommandMenuInput,
CommandMenuItem,
CommandMenuList,
useCommandMenuShortcut,
} from "@/components/pandacoderz-ui/command-menu";const [open, setOpen] = React.useState(false);
useCommandMenuShortcut(() => setOpen((o) => !o));
<CommandMenu open={open} onOpenChange={setOpen}>
<CommandMenuInput placeholder="Search or ask…" />
<CommandMenuList>
<CommandMenuGroup heading="Actions">
<CommandMenuItem shortcut="N" onSelect={newChat}>New conversation</CommandMenuItem>
</CommandMenuGroup>
<CommandMenuGroup heading="AI">
<CommandMenuItem alwaysVisible onSelect={askAI}>Ask AI about this…</CommandMenuItem>
</CommandMenuGroup>
<CommandMenuEmpty />
</CommandMenuList>
</CommandMenu>Use CommandMenuInline to embed the same parts in a page instead of a dialog.
API Reference
CommandMenu
| Prop | Type | Description |
|---|---|---|
open / onOpenChange |
controlled | Dialog state. |
query / onQueryChange |
controlled | Search text, if you need it outside. |
CommandMenuItem
| Prop | Type | Description |
|---|---|---|
value |
string |
Filter text. Defaults to string children. |
alwaysVisible |
boolean |
Ignore the filter. |
icon / shortcut |
ReactNode / string |
Leading icon and trailing key hint. |
onSelect |
() => void |
Called on click or Enter. |