Command Menu
Spotlight/kbar-style searchable command palette — portal-based overlay with groups, keyboard shortcuts, and solid keyboard handling on iOS and Android.
Installation#
npx @aniui/cli add command-menuRequires a PortalHost
The palette renders through @rn-primitives/portal, so your root layout needs a <PortalHost />. aniui add command-menu injects it automatically — if you installed via the shadcn CLI or copied manually, add it yourself:
import { PortalHost } from "@rn-primitives/portal";
export default function RootLayout() {
return (
<>
{/* your app */}
<PortalHost />
</>
);
}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#
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.
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.
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.
import { FileText, Settings } from "lucide-react-native";
const items = [
{ label: "New File", value: "new-file", icon: <FileText size={16} color="#71717a" />, group: "Actions" },
{ label: "Settings", value: "settings", icon: <Settings size={16} color="#71717a" />, 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.
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.
<CommandMenu
open={open}
onOpenChange={setOpen}
items={items}
placeholder="What do you need?"
emptyText="Nothing matches your search."
onSelect={handleSelect}
/>Props#
CommandMenuProps#
openboolean-onOpenChange(open: boolean) => void-itemsCommandItem[]-placeholderstring"Type a command or search..."emptyTextstring"No results found."onSelect(value: string) => void-classNamestring-CommandItem#
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.- Backdrop press closes the menu for intuitive dismissal.
- Keyboard-aware: the palette tracks
keyboardWillShow/keyboardDidShowdirectly, so results stay visible above the keyboard on both iOS and Android (RN Modal-styleadjustResizedoesn't apply to portals).
Source#
import React, { useEffect, useMemo, useRef, useState } from "react";
import { View, Text, TextInput, Pressable, SectionList, useColorScheme, useWindowDimensions, Keyboard, Platform } from "react-native";
import * as DialogPrimitive from "@rn-primitives/dialog";
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
import { entering, exiting } from "@/components/ui/animate";
import { Search } from "lucide-react-native";
import { cn } from "@/lib/utils";
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;
}
// Anchored below the status bar; the palette grows down and the results list
// scrolls within whatever space is left above the keyboard.
const TOP_OFFSET = 96;
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 dark = useColorScheme() === "dark";
const { height: winH } = useWindowDimensions();
const inputRef = useRef<TextInput>(null);
const kb = useSharedValue(0);
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]);
// Track the keyboard directly. RN Modal doesn't resize for the keyboard on
// Android, so the old version's results got covered — rendering via the rn-
// primitives Portal plus this listener keeps the palette above the keyboard.
useEffect(() => {
const showEvt = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
const hideEvt = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
const show = Keyboard.addListener(showEvt, (e) => {
kb.value = withTiming(e.endCoordinates.height, { duration: 160 });
});
const hide = Keyboard.addListener(hideEvt, () => {
kb.value = withTiming(0, { duration: 160 });
});
return () => {
show.remove();
hide.remove();
};
}, [kb]);
// Focus via ref on open (autoFocus is unreliable inside overlays on Android).
useEffect(() => {
if (!open) {
setSearch("");
return;
}
const t = setTimeout(() => inputRef.current?.focus(), Platform.OS === "android" ? 150 : 50);
return () => clearTimeout(t);
}, [open]);
const cardStyle = useAnimatedStyle(() => ({
maxHeight: Math.max(180, winH - TOP_OFFSET - kb.value - 24),
}));
const handleSelect = (item: CommandItem) => {
if (item.disabled) return;
item.onSelect?.();
onSelect?.(item.value);
onOpenChange(false);
};
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay closeOnPress className="absolute inset-0 bg-black/50" />
<DialogPrimitive.Content style={{ position: "absolute", top: TOP_OFFSET, left: 16, right: 16 }}>
<DialogPrimitive.Title style={{ position: "absolute", width: 1, height: 1, opacity: 0 }}>
Command menu
</DialogPrimitive.Title>
<Animated.View
entering={entering.fadeInDown}
exiting={exiting.fadeOutUp}
style={cardStyle}
className={cn("rounded-xl border border-border bg-card shadow-lg overflow-hidden", className)}
{...props}
>
<View className="flex-row items-center px-4 border-b border-border">
<Search size={16} color="#71717a" strokeWidth={2} />
<TextInput
ref={inputRef}
className="flex-1 min-h-12 ps-3 text-base text-foreground"
placeholder={placeholder}
placeholderTextColor={dark ? "#a1a1aa" : "#71717a"}
value={search}
onChangeText={setSearch}
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}
style={{ flexShrink: 1 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
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}
/>
)}
</Animated.View>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
// Convenience sub-components for composition pattern
export interface CommandInputProps extends React.ComponentPropsWithoutRef<typeof TextInput> {
className?: string;
}
export function CommandInput({ className, ...props }: CommandInputProps) {
const dark = useColorScheme() === "dark";
return (
<TextInput
className={cn("min-h-12 px-4 text-base text-foreground border-b border-border", className)}
placeholderTextColor={dark ? "#a1a1aa" : "#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)} />;
}