Command Menu
Spotlight-style searchable command palette with groups and keyboard shortcuts.
Installation#
npx @aniui/cli add command-menuWeb preview — components render natively on iOS & Android
import { useState } from "react";
import { CommandMenu } from "@/components/ui/command-menu";
import { Button } from "@/components/ui/button";
const items = [
{ label: "New File", value: "new-file", group: "Actions" },
{ label: "Save", value: "save", group: "Actions" },
{ label: "Home", value: "home", group: "Navigation" },
{ label: "Settings", value: "settings", group: "Navigation" },
];
export function MyScreen() {
const [open, setOpen] = useState(false);
return (
<>
<Button onPress={() => setOpen(true)}>Open Command Menu</Button>
<CommandMenu
open={open}
onOpenChange={setOpen}
items={items}
onSelect={(value) => console.log("Selected:", value)}
/>
</>
);
}Usage#
app/index.tsx
import { useState } from "react";
import { CommandMenu } from "@/components/ui/command-menu";
import { Button } from "@/components/ui/button";
const items = [
{ label: "New File", value: "new-file", group: "Actions" },
{ label: "Save", value: "save", group: "Actions" },
{ label: "Home", value: "home", group: "Navigation" },
{ label: "Settings", value: "settings", group: "Navigation" },
];
export function MyScreen() {
const [open, setOpen] = useState(false);
return (
<>
<Button onPress={() => setOpen(true)}>Open Command Menu</Button>
<CommandMenu
open={open}
onOpenChange={setOpen}
items={items}
onSelect={(value) => console.log("Selected:", value)}
/>
</>
);
}Groups#
Organize items under section headers using the group property on each item. Items with the same group are rendered together with a header.
Grouped items
const items = [
{ label: "New File", value: "new-file", group: "Actions" },
{ label: "Save", value: "save", group: "Actions" },
{ label: "Export", value: "export", group: "Actions" },
{ label: "Home", value: "home", group: "Navigation" },
{ label: "Settings", value: "settings", group: "Navigation" },
{ label: "Profile", value: "profile", group: "Navigation" },
];
<CommandMenu
open={open}
onOpenChange={setOpen}
items={items}
onSelect={handleSelect}
/>Keyboard Shortcuts#
Add shortcut to items to display keyboard shortcut badges. Shortcuts are split on + and rendered as individual key caps.
Keyboard shortcuts
const items = [
{ label: "New File", value: "new-file", shortcut: "Cmd+N", group: "Actions" },
{ label: "Save", value: "save", shortcut: "Cmd+S", group: "Actions" },
{ label: "Export", value: "export", shortcut: "Cmd+E", group: "Actions" },
{ label: "Settings", value: "settings", shortcut: "Cmd+,", group: "Navigation" },
];
<CommandMenu
open={open}
onOpenChange={setOpen}
items={items}
onSelect={handleSelect}
/>Custom Icons#
Pass any React.ReactNode as the icon property. Icons render in a fixed-width container to the left of the label.
Custom icons
import Svg, { Path } from "react-native-svg";
const FileIcon = (
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<Path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<Path d="M14 2v6h6" />
</Svg>
);
const items = [
{ label: "New File", value: "new-file", icon: FileIcon, group: "Actions" },
{ label: "Settings", value: "settings", icon: GearIcon, group: "Navigation" },
];
<CommandMenu
open={open}
onOpenChange={setOpen}
items={items}
onSelect={handleSelect}
/>Disabled Items#
Set disabled: true on items to prevent selection. Disabled items appear at reduced opacity.
Disabled items
const items = [
{ label: "New File", value: "new-file", group: "Actions" },
{ label: "Delete All", value: "delete-all", group: "Actions", disabled: true },
{ label: "Home", value: "home", group: "Navigation" },
];
<CommandMenu
open={open}
onOpenChange={setOpen}
items={items}
onSelect={handleSelect}
/>Custom Placeholder#
Customize the search placeholder and emptyText for when no results match.
Custom text
<CommandMenu
open={open}
onOpenChange={setOpen}
items={items}
placeholder="What do you need?"
emptyText="Nothing matches your search."
onSelect={handleSelect}
/>Props#
CommandMenuProps#
PropTypeDefault
openboolean-onOpenChange(open: boolean) => void-itemsCommandItem[]-placeholderstring"Type a command or search..."emptyTextstring"No results found."onSelect(value: string) => void-classNamestring-CommandItem#
PropTypeDefault
labelstring-valuestring-iconReact.ReactNode-shortcutstring-groupstring-disabledbooleanfalseonSelect() => void-Also accepts all View props from React Native.
Sub-components#
For advanced composition, the module also exports convenience sub-components:
CommandInput-- Styled search TextInput with border-bottom.CommandEmpty-- Empty state placeholder view.CommandSeparator-- Horizontal rule between groups.
Accessibility#
- Search input has
accessibilityLabel="Command search". - Each item has
accessibilityRole="button". accessibilityStatetracksdisabledstate for each item.- Modal can be dismissed via Android back button (
onRequestClose). - Backdrop press closes the menu for intuitive dismissal.
Source#
components/ui/command-menu.tsx
import React, { useState, useMemo } from "react";
import { View, Text, TextInput, Pressable, Modal, SectionList } from "react-native";
import { cn } from "@/lib/utils";
import Svg, { Path } from "react-native-svg";
export interface CommandItem {
label: string;
value: string;
icon?: React.ReactNode;
shortcut?: string;
group?: string;
disabled?: boolean;
onSelect?: () => void;
}
export interface CommandMenuProps extends React.ComponentPropsWithoutRef<typeof View> {
open: boolean;
onOpenChange: (open: boolean) => void;
items: CommandItem[];
placeholder?: string;
emptyText?: string;
onSelect?: (value: string) => void;
className?: string;
}
export function CommandMenu({
open,
onOpenChange,
items,
placeholder = "Type a command or search...",
emptyText = "No results found.",
onSelect,
className,
...props
}: CommandMenuProps) {
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
const q = search.toLowerCase();
return items.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
item.value.toLowerCase().includes(q) ||
(item.group ?? "").toLowerCase().includes(q)
);
}, [items, search]);
const sections = useMemo(() => {
const groups: Record<string, CommandItem[]> = {};
for (const item of filtered) {
const key = item.group ?? "";
(groups[key] ??= []).push(item);
}
return Object.entries(groups).map(([title, data]) => ({ title, data }));
}, [filtered]);
const handleSelect = (item: CommandItem) => {
if (item.disabled) return;
item.onSelect?.();
onSelect?.(item.value);
onOpenChange(false);
setSearch("");
};
const close = () => {
onOpenChange(false);
setSearch("");
};
return (
<Modal visible={open} transparent animationType="fade" onRequestClose={close}>
<Pressable className="flex-1 bg-black/50 justify-start pt-24" onPress={close}>
<Pressable
className={cn("mx-4 rounded-xl border border-border bg-card shadow-lg overflow-hidden max-h-[70%]", className)}
onPress={() => {}}
{...props}
>
<View className="flex-row items-center px-4 border-b border-border">
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="#71717a" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<Path d="M11 17.25a6.25 6.25 0 1 1 0-12.5 6.25 6.25 0 0 1 0 12.5Z" />
<Path d="m16 16 4.5 4.5" />
</Svg>
<TextInput
className="flex-1 min-h-12 ps-3 text-base text-foreground"
placeholder={placeholder}
placeholderTextColor="#71717a"
value={search}
onChangeText={setSearch}
autoFocus
accessibilityLabel="Command search"
/>
</View>
{filtered.length === 0 ? (
<View className="py-8 items-center">
<Text className="text-sm text-muted-foreground">{emptyText}</Text>
</View>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.value}
renderSectionHeader={({ section }) =>
section.title ? (
<View className="px-4 pt-3 pb-1.5 bg-card">
<Text className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{section.title}
</Text>
</View>
) : null
}
renderItem={({ item }) => (
<Pressable
className={cn(
"flex-row items-center px-4 py-2.5 gap-3",
item.disabled && "opacity-40"
)}
onPress={() => handleSelect(item)}
disabled={item.disabled}
accessibilityRole="button"
accessibilityState={{ disabled: item.disabled }}
>
{item.icon && <View className="w-5 items-center">{item.icon}</View>}
<Text className="flex-1 text-sm text-foreground">{item.label}</Text>
{item.shortcut && (
<View className="flex-row items-center gap-0.5">
{item.shortcut.split("+").map((key, i) => (
<React.Fragment key={i}>
{i > 0 && <Text className="text-[10px] text-muted-foreground">+</Text>}
<View className="items-center justify-center rounded border border-border bg-muted px-1.5 min-h-5">
<Text className="text-[10px] font-mono text-muted-foreground">{key.trim()}</Text>
</View>
</React.Fragment>
))}
</View>
)}
</Pressable>
)}
stickySectionHeadersEnabled={false}
/>
)}
</Pressable>
</Pressable>
</Modal>
);
}
export interface CommandInputProps extends React.ComponentPropsWithoutRef<typeof TextInput> {
className?: string;
}
export function CommandInput({ className, ...props }: CommandInputProps) {
return (
<TextInput
className={cn("min-h-12 px-4 text-base text-foreground border-b border-border", className)}
placeholderTextColor="#71717a"
{...props}
/>
);
}
export function CommandEmpty({ children, className }: { children?: React.ReactNode; className?: string }) {
return (
<View className={cn("py-8 items-center", className)}>
<Text className="text-sm text-muted-foreground">{children ?? "No results found."}</Text>
</View>
);
}
export function CommandSeparator({ className }: { className?: string }) {
return <View className={cn("h-px bg-border", className)} />;
}