AniUI

Command Menu

Spotlight-style searchable command palette with groups and keyboard shortcuts.

Installation#

npx @aniui/cli add command-menu
Web 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
open
boolean
-
onOpenChange
(open: boolean) => void
-
items
CommandItem[]
-
placeholder
string
"Type a command or search..."
emptyText
string
"No results found."
onSelect
(value: string) => void
-
className
string
-

CommandItem#

PropTypeDefault
label
string
-
value
string
-
icon
React.ReactNode
-
shortcut
string
-
group
string
-
disabled
boolean
false
onSelect
() => 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".
  • accessibilityState tracks disabled state 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)} />;
}