Skip to main content

Overview

The commandPalette widget provides a searchable command launcher for quick access to app functionality. Supports multiple command sources, async item loading, keyboard navigation, and prefix-based filtering.

Basic Usage

import { ui } from "@rezi-ui/core";
import { defineWidget } from "@rezi-ui/core";

const CommandPaletteExample = defineWidget((ctx) => {
  const [paletteOpen, setPaletteOpen] = ctx.useState(false);
  const [query, setQuery] = ctx.useState("");
  const [selectedIndex, setSelectedIndex] = ctx.useState(0);

  const commandSource: CommandSource = {
    id: "commands",
    name: "Commands",
    prefix: ">",
    getItems: (q) => [
      {
        id: "new-file",
        label: "New File",
        description: "Create a new file",
        shortcut: "Ctrl+N",
        icon: "📄",
        sourceId: "commands",
      },
      {
        id: "open-file",
        label: "Open File",
        description: "Open an existing file",
        shortcut: "Ctrl+O",
        icon: "📂",
        sourceId: "commands",
      },
      {
        id: "save",
        label: "Save",
        description: "Save current file",
        shortcut: "Ctrl+S",
        icon: "💾",
        sourceId: "commands",
      },
    ].filter((item) => item.label.toLowerCase().includes(q.toLowerCase())),
  };

  return ui.layers([
    ui.column({ gap: 1, p: 1 }, [
      ui.button({
        id: "open-palette",
        label: "Open Command Palette",
        onPress: () => setPaletteOpen(true),
      }),
      ui.text("Press Ctrl+K to open", { variant: "caption", dim: true }),
    ]),
    paletteOpen &&
      ui.commandPalette({
        id: "cmd-palette",
        open: paletteOpen,
        query,
        sources: [commandSource],
        selectedIndex,
        onQueryChange: setQuery,
        onSelect: (item) => {
          handleCommand(item.id);
          setPaletteOpen(false);
          setQuery("");
        },
        onClose: () => {
          setPaletteOpen(false);
          setQuery("");
        },
        onSelectionChange: setSelectedIndex,
      }),
  ]);
});

Props

id
string
required
Unique identifier for focus routing.
open
boolean
required
Visible state of the palette.
query
string
required
Current search query.
sources
readonly CommandSource[]
required
Command sources to search.
selectedIndex
number
required
Selected item index in results list.
onQueryChange
(query: string) => void
required
Callback when query changes.
onSelect
(item: CommandItem) => void
required
Callback when item is selected.
onClose
() => void
required
Callback when palette should close.
onSelectionChange
(index: number) => void
Callback when selection index changes.
loading
boolean
default:"false"
Loading state for async sources.
placeholder
string
default:"'Type a command...'"
Placeholder text for search input.
maxVisible
number
default:"10"
Maximum visible items in results list.
width
number
default:"60"
Palette width in cells.
focusable
boolean
default:"true"
Opt out of Tab focus order.
accessibleLabel
string
Optional semantic label for accessibility.

Styling

frameStyle
OverlayFrameStyle
Frame/surface colors for palette background, text, and border.
selectionStyle
TextStyle
Optional style override for selected result row highlighting.
focusConfig
FocusConfig
Optional focus appearance configuration.

Keyboard Navigation

KeyAction
UpSelect previous item
DownSelect next item
EnterExecute selected command
ESCClose palette
Ctrl+KCommon shortcut to open palette (app-level binding)

Command Sources

CommandSource Interface

type CommandSource = {
  id: string;
  name: string;
  prefix?: string; // e.g., ">", "@", "#"
  getItems: (query: string) => CommandItem[] | Promise<CommandItem[]>;
  priority?: number; // Higher = first
};

CommandItem Interface

type CommandItem = {
  id: string;
  label: string;
  description?: string;
  shortcut?: string;
  icon?: string;
  sourceId: string;
  data?: unknown; // Payload for onSelect
  disabled?: boolean;
};

Multiple Command Sources

import { defineWidget } from "@rezi-ui/core";

const MultiSourcePalette = defineWidget((ctx) => {
  const [paletteOpen, setPaletteOpen] = ctx.useState(false);
  const [query, setQuery] = ctx.useState("");
  const [selectedIndex, setSelectedIndex] = ctx.useState(0);

  const commandSource: CommandSource = {
    id: "commands",
    name: "Commands",
    prefix: ">",
    priority: 1,
    getItems: (q) =>
      commands.filter((cmd) => cmd.label.toLowerCase().includes(q.toLowerCase())),
  };

  const fileSource: CommandSource = {
    id: "files",
    name: "Files",
    prefix: "@",
    priority: 2,
    getItems: async (q) => {
      const files = await searchFiles(q);
      return files.map((file) => ({
        id: file.path,
        label: file.name,
        description: file.path,
        icon: "📄",
        sourceId: "files",
        data: file,
      }));
    },
  };

  const symbolSource: CommandSource = {
    id: "symbols",
    name: "Symbols",
    prefix: "#",
    priority: 3,
    getItems: (q) =>
      symbols
        .filter((sym) => sym.name.toLowerCase().includes(q.toLowerCase()))
        .map((sym) => ({
          id: sym.id,
          label: sym.name,
          description: sym.type,
          icon: sym.kind === "function" ? "ƒ" : "Ξ",
          sourceId: "symbols",
          data: sym,
        })),
  };

  return ui.layers([
    MainContent(),
    paletteOpen &&
      ui.commandPalette({
        id: "cmd-palette",
        open: paletteOpen,
        query,
        sources: [commandSource, fileSource, symbolSource],
        selectedIndex,
        placeholder: "Type > for commands, @ for files, # for symbols",
        onQueryChange: setQuery,
        onSelect: (item) => {
          handleSelection(item);
          setPaletteOpen(false);
        },
        onClose: () => setPaletteOpen(false),
        onSelectionChange: setSelectedIndex,
      }),
  ]);
});

Async Command Loading

import { defineWidget } from "@rezi-ui/core";

const AsyncPalette = defineWidget((ctx) => {
  const [paletteOpen, setPaletteOpen] = ctx.useState(false);
  const [query, setQuery] = ctx.useState("");
  const [selectedIndex, setSelectedIndex] = ctx.useState(0);
  const [loading, setLoading] = ctx.useState(false);

  const asyncSource: CommandSource = {
    id: "api-search",
    name: "API Search",
    getItems: async (q) => {
      if (!q) return [];
      setLoading(true);
      try {
        const results = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
        const data = await results.json();
        return data.items.map((item: any) => ({
          id: item.id,
          label: item.name,
          description: item.description,
          sourceId: "api-search",
          data: item,
        }));
      } finally {
        setLoading(false);
      }
    },
  };

  return ui.layers([
    MainContent(),
    paletteOpen &&
      ui.commandPalette({
        id: "async-palette",
        open: paletteOpen,
        query,
        sources: [asyncSource],
        selectedIndex,
        loading,
        onQueryChange: setQuery,
        onSelect: handleSelection,
        onClose: () => setPaletteOpen(false),
        onSelectionChange: setSelectedIndex,
      }),
  ]);
});

Prefix Filtering

const sources: CommandSource[] = [
  {
    id: "commands",
    name: "Commands",
    prefix: ">", // Triggered by ">command"
    getItems: getCommands,
  },
  {
    id: "files",
    name: "Files",
    prefix: "@", // Triggered by "@filename"
    getItems: searchFiles,
  },
  {
    id: "symbols",
    name: "Symbols",
    prefix: "#", // Triggered by "#symbol"
    getItems: searchSymbols,
  },
];

// User types: ">new file"
// Only "commands" source is queried with "new file"

// User types: "@main.ts"
// Only "files" source is queried with "main.ts"

Recent Commands

import { defineWidget } from "@rezi-ui/core";

const PaletteWithRecents = defineWidget((ctx) => {
  const [paletteOpen, setPaletteOpen] = ctx.useState(false);
  const [query, setQuery] = ctx.useState("");
  const [selectedIndex, setSelectedIndex] = ctx.useState(0);
  const [recentCommands, setRecentCommands] = ctx.useState<CommandItem[]>([]);

  const recordCommand = (item: CommandItem) => {
    const updated = [
      item,
      ...recentCommands.filter((cmd) => cmd.id !== item.id),
    ].slice(0, 5); // Keep last 5
    setRecentCommands(updated);
  };

  const recentSource: CommandSource = {
    id: "recent",
    name: "Recent",
    priority: 10,
    getItems: (q) => {
      if (q) return [];
      return recentCommands;
    },
  };

  const commandSource: CommandSource = {
    id: "all-commands",
    name: "All Commands",
    getItems: (q) =>
      allCommands.filter((cmd) => cmd.label.toLowerCase().includes(q.toLowerCase())),
  };

  return ui.layers([
    MainContent(),
    paletteOpen &&
      ui.commandPalette({
        id: "palette-with-recents",
        open: paletteOpen,
        query,
        sources: [recentSource, commandSource],
        selectedIndex,
        placeholder: query ? "Search commands..." : "Recent commands",
        onQueryChange: setQuery,
        onSelect: (item) => {
          recordCommand(item);
          executeCommand(item);
          setPaletteOpen(false);
        },
        onClose: () => setPaletteOpen(false),
        onSelectionChange: setSelectedIndex,
      }),
  ]);
});

Global Keybinding

import { createNodeApp } from "@rezi-ui/node";

const app = createNodeApp({ view, initialState });

// Register Ctrl+K to open command palette
app.keys("default", [
  {
    sequence: "Ctrl+K",
    description: "Open command palette",
    action: () => {
      app.update((state) => ({ ...state, paletteOpen: true }));
    },
  },
]);

Command Categories

const categorizedSource: CommandSource = {
  id: "commands",
  name: "Commands",
  getItems: (query) => {
    const categories = [
      { name: "File", commands: fileCommands },
      { name: "Edit", commands: editCommands },
      { name: "View", commands: viewCommands },
      { name: "Help", commands: helpCommands },
    ];

    return categories.flatMap((cat) =>
      cat.commands
        .filter((cmd) => cmd.label.toLowerCase().includes(query.toLowerCase()))
        .map((cmd) => ({
          ...cmd,
          description: `${cat.name}: ${cmd.description}`,
        }))
    );
  },
};

Custom Styling

ui.commandPalette({
  id: "styled-palette",
  open: paletteOpen,
  query,
  sources,
  selectedIndex,
  frameStyle: {
    background: { r: 20, g: 20, b: 30 },
    foreground: { r: 220, g: 220, b: 230 },
    border: { r: 80, g: 90, b: 120 },
  },
  selectionStyle: {
    bg: { r: 50, g: 100, b: 150 },
    fg: { r: 255, g: 255, b: 255 },
  },
  onQueryChange: setQuery,
  onSelect: handleSelect,
  onClose: closeP alette,
  onSelectionChange: setSelectedIndex,
});

Best Practices

  1. Fuzzy matching - Implement fuzzy search for better UX
  2. Recent commands - Show recently used commands when query is empty
  3. Keyboard shortcuts - Display shortcuts next to commands
  4. Icons - Use icons to visually distinguish command types
  5. Categories - Group related commands with prefixes or descriptions
  6. Async loading - Show loading state for slow sources
  7. Global hotkey - Bind to Ctrl+K or Ctrl+P for quick access

Examples

VS Code-style Command Palette

import { defineWidget } from "@rezi-ui/core";

const EditorPalette = defineWidget((ctx) => {
  const [paletteOpen, setPaletteOpen] = ctx.useState(false);
  const [query, setQuery] = ctx.useState("");
  const [selectedIndex, setSelectedIndex] = ctx.useState(0);

  const sources: CommandSource[] = [
    {
      id: "commands",
      name: "Commands",
      prefix: ">",
      getItems: (q) => [
        {
          id: "file.new",
          label: "File: New File",
          shortcut: "Ctrl+N",
          icon: "📄",
          sourceId: "commands",
        },
        {
          id: "file.open",
          label: "File: Open File",
          shortcut: "Ctrl+O",
          icon: "📂",
          sourceId: "commands",
        },
        {
          id: "view.commandPalette",
          label: "View: Command Palette",
          shortcut: "Ctrl+Shift+P",
          icon: "⌨️",
          sourceId: "commands",
        },
      ].filter((item) => item.label.toLowerCase().includes(q.toLowerCase())),
    },
    {
      id: "files",
      name: "Go to File",
      getItems: async (q) => {
        const files = await searchWorkspaceFiles(q);
        return files.map((file) => ({
          id: file.path,
          label: file.name,
          description: file.path,
          icon: getFileIcon(file),
          sourceId: "files",
          data: file,
        }));
      },
    },
    {
      id: "symbols",
      name: "Go to Symbol",
      prefix: "@",
      getItems: async (q) => {
        const symbols = await searchSymbols(q);
        return symbols.map((sym) => ({
          id: sym.id,
          label: sym.name,
          description: `${sym.kind} in ${sym.file}`,
          icon: getSymbolIcon(sym.kind),
          sourceId: "symbols",
          data: sym,
        }));
      },
    },
  ];

  return ui.commandPalette({
    id: "editor-palette",
    open: paletteOpen,
    query,
    sources,
    selectedIndex,
    width: 70,
    maxVisible: 15,
    placeholder: "Type > for commands, @ for symbols, or search files",
    onQueryChange: setQuery,
    onSelect: (item) => {
      handleSelection(item);
      setPaletteOpen(false);
    },
    onClose: () => setPaletteOpen(false),
    onSelectionChange: setSelectedIndex,
  });
});

Accessibility

  • Keyboard-first design with arrow key navigation
  • Search input is auto-focused when palette opens
  • Selected item is visually highlighted
  • ESC key always closes palette
  • Screen readers announce selected items
  • Dropdown - For contextual menus
  • Modal - For blocking dialogs
  • Input - For text input fields

Location in Source

  • Types: packages/core/src/widgets/types.ts:1605-1680
  • Factory: packages/core/src/widgets/ui.ts:1543

Build docs developers (and LLMs) love