CommandPalette

Give power users one place to run commands. The palette fuzzy-matches label, keywords and description, highlights matched characters, groups commands, shows recentIds first while the query is empty, and renders shortcuts with ShortcutHint. hotkey together with onOpen registers the global shortcut. Each command runs its own onSelect, and the palette's onSelect sees every choice, for example to update the recent list. useFuzzySearch is available for custom lists.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Basic
Recent commands
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

primitives/CommandPalette/CommandPalette.tsx
import React, { memo, useEffect, useId, useMemo, useRef, useState } from 'react';
import { Box, Mark, Modal, NavLink, ScrollArea, Stack, Text, TextInput, rem } from '@mantine/core';
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons-react';
import { findEdgeEnabledIndex, findNextEnabledIndex } from '../EntityList/entity-list';
import { ShortcutHint } from '../ShortcutHint/ShortcutHint';
import { buildPaletteSections, type PaletteCommand } from './command-palette';
import { splitByIndices } from './fuzzy';
import classes from './CommandPalette.module.css';
import { OVERLAY_INNER_CLASS } from '../../styles/overlay';

export interface CommandPaletteLabels {
  /** Header of the recent commands section */
  recent: string;
  /** Accessible label of the search input */
  search: string;
  /** Accessible label of the results list */
  results: string;
  /** Text shown when nothing matches, `No commands found` by default */
  empty: string;
}

export interface CommandPaletteProps {
  /** Whether the palette is open */
  opened: boolean;
  /** Called when the palette should close: Escape, outside click or after running a command */
  onClose: () => void;
  /** Commands to search and run */
  commands: PaletteCommand[];
  /** Called with the command that runs, after its own `onSelect` */
  onSelect?: (command: PaletteCommand) => void;
  /** Search input placeholder, `Search commands...` by default */
  placeholder?: string;
  /** Ids of recently used commands, listed first while the query is empty */
  recentIds?: string[];
  /** Hotkey that toggles the palette, for example `mod+K`; registered whenever set: it calls `onOpen` while closed and `onClose` while open, so without `onOpen` it can only close the palette */
  hotkey?: string;
  /** Called when the hotkey is pressed while the palette is closed; set `opened` to `true` here */
  onOpen?: () => void;
  /** Maximum height of the results list, `400` by default */
  maxHeight?: number | string;
  /** Overrides of the default English labels */
  labels?: Partial<CommandPaletteLabels>;
}

export const DEFAULT_COMMAND_PALETTE_LABELS: CommandPaletteLabels = {
  recent: 'Recent',
  search: 'Search commands',
  results: 'Commands',
  empty: 'No commands found',
};

function HighlightedLabel({ text, indices }: { text: string; indices: number[] }) {
  return (
    <>
      {splitByIndices(text, indices).map((part, index) =>
        part.highlighted ? (
          <Mark key={index} className={classes.mark}>
            {part.text}
          </Mark>
        ) : (
          <React.Fragment key={index}>{part.text}</React.Fragment>
        )
      )}
    </>
  );
}

export const CommandPalette = memo(function CommandPalette({
  opened,
  onClose,
  commands,
  onSelect,
  placeholder = 'Search commands...',
  recentIds,
  hotkey,
  onOpen,
  maxHeight = 400,
  labels,
}: CommandPaletteProps) {
  const text = { ...DEFAULT_COMMAND_PALETTE_LABELS, ...labels };
  const baseId = useId();
  const listId = `${baseId}-list`;
  const fullScreen = useMediaQuery('(max-width: 36em)') ?? false;
  const [query, setQuery] = useState('');
  const [activeIndex, setActiveIndex] = useState(-1);
  const viewportRef = useRef<HTMLDivElement>(null);

  const toggleRef = useRef({ opened, onOpen, onClose });
  toggleRef.current = { opened, onOpen, onClose };
  useHotkeys(
    hotkey
      ? [
          [
            hotkey,
            () => {
              const current = toggleRef.current;
              if (current.opened) {
                current.onClose();
              } else {
                current.onOpen?.();
              }
            },
            { preventDefault: true },
          ],
        ]
      : [],
    []
  );

  const sections = useMemo(
    () => buildPaletteSections(commands, query, recentIds, text.recent),
    [commands, query, recentIds, text.recent]
  );
  const entries = useMemo(() => sections.flatMap((section) => section.entries), [sections]);
  const disabled = useMemo(() => entries.map((entry) => !!entry.command.disabled), [entries]);

  useEffect(() => {
    setActiveIndex(findEdgeEnabledIndex(disabled, 'first'));
  }, [disabled]);

  useEffect(() => {
    if (!opened) {
      setQuery('');
    }
  }, [opened]);

  const optionId = (index: number) => `${baseId}-option-${index}`;

  useEffect(() => {
    if (activeIndex === -1) {
      return;
    }
    viewportRef.current
      ?.querySelector(`[id="${optionId(activeIndex)}"]`)
      ?.scrollIntoView({ block: 'nearest' });
  }, [activeIndex]);

  const run = (command: PaletteCommand) => {
    if (command.disabled) {
      return;
    }
    command.onSelect?.();
    onSelect?.(command);
    onClose();
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    switch (event.key) {
      case 'ArrowDown':
        event.preventDefault();
        setActiveIndex((index) =>
          index === -1
            ? findEdgeEnabledIndex(disabled, 'first')
            : findNextEnabledIndex(disabled, index, 1)
        );
        break;
      case 'ArrowUp':
        event.preventDefault();
        setActiveIndex((index) =>
          index === -1
            ? findEdgeEnabledIndex(disabled, 'last')
            : findNextEnabledIndex(disabled, index, -1)
        );
        break;
      case 'Enter':
        if (!event.nativeEvent.isComposing) {
          event.preventDefault();
          if (activeIndex !== -1 && entries[activeIndex]) {
            run(entries[activeIndex].command);
          }
        }
        break;
    }
  };

  let flatIndex = 0;

  return (
    <Modal
      opened={opened}
      onClose={onClose}
      withCloseButton={false}
      size="lg"
      fullScreen={fullScreen}
      padding={0}
      classNames={{ inner: OVERLAY_INNER_CLASS, body: classes.body, content: classes.content }}
    >
      <TextInput
        data-autofocus
        size="sm"
        variant="unstyled"
        value={query}
        onChange={(event) => setQuery(event.currentTarget.value)}
        onKeyDown={handleKeyDown}
        placeholder={placeholder}
        leftSection={<IconSearch size={16} />}
        role="combobox"
        tabIndex={0}
        aria-label={text.search}
        aria-expanded
        aria-controls={listId}
        aria-autocomplete="list"
        aria-activedescendant={activeIndex === -1 ? undefined : optionId(activeIndex)}
        classNames={{ root: classes.search, input: classes.searchInput }}
      />
      <ScrollArea.Autosize
        mah={fullScreen ? undefined : maxHeight}
        viewportRef={viewportRef}
        className={classes.scroll}
        type="auto"
      >
        {entries.length === 0 ? (
          <Text size="sm" c="dimmed" ta="center" py="xl" px="md">
            {text.empty}
          </Text>
        ) : (
          <Stack id={listId} role="listbox" aria-label={text.results} gap={2} p={rem(6)}>
            {sections.map((section, sectionIndex) => {
              const labelId = `${baseId}-section-${sectionIndex}`;
              return (
                <Stack
                  key={section.key}
                  gap={2}
                  role="group"
                  aria-labelledby={section.label ? labelId : undefined}
                >
                  {section.label && (
                    <Text id={labelId} size="xs" fw={500} c="dimmed" px="sm" pt="xs" pb={2}>
                      {section.label}
                    </Text>
                  )}
                  {section.entries.map((entry) => {
                    const index = flatIndex++;
                    const { command } = entry;
                    const active = index === activeIndex;
                    return (
                      <NavLink
                        key={entry.key}
                        component="div"
                        id={optionId(index)}
                        role="option"
                        aria-selected={active}
                        aria-disabled={command.disabled || undefined}
                        active={active}
                        disabled={command.disabled}
                        variant="light"
                        className={classes.option}
                        leftSection={command.icon}
                        rightSection={
                          command.shortcut ? (
                            <Box component="span" aria-hidden>
                              <ShortcutHint keys={command.shortcut} />
                            </Box>
                          ) : undefined
                        }
                        disableRightSectionRotation
                        label={
                          <HighlightedLabel text={command.label} indices={entry.labelIndices} />
                        }
                        description={
                          command.description ? (
                            <Text component="span" size="xs" c="dimmed" truncate="end">
                              {command.description}
                            </Text>
                          ) : undefined
                        }
                        onMouseDown={(event: React.MouseEvent) => event.preventDefault()}
                        onMouseMove={() => {
                          if (!active && !command.disabled) {
                            setActiveIndex(index);
                          }
                        }}
                        onClick={() => run(command)}
                      />
                    );
                  })}
                </Stack>
              );
            })}
          </Stack>
        )}
      </ScrollArea.Autosize>
    </Modal>
  );
});

CommandPalette.displayName = 'CommandPalette';
Depends on:
API reference
Prop
Type
Required
opened
boolean
Yes
Whether the palette is open
onClose
() => void
Yes
Called when the palette should close: Escape, outside click or after running a command
commands
PaletteCommand[]
Yes
Commands to search and run
onSelect
(command: PaletteCommand) => void
No
Called with the command that runs, after its own `onSelect`
placeholder
string
No
Search input placeholder, `Search commands...` by default
recentIds
string[]
No
Ids of recently used commands, listed first while the query is empty
hotkey
string
No
Hotkey that toggles the palette, for example `mod+K`; registered whenever set: it calls `onOpen` while closed and `onClose` while open, so without `onOpen` it can only close the palette
onOpen
() => void
No
Called when the hotkey is pressed while the palette is closed; set `opened` to `true` here
maxHeight
number | string
No
Maximum height of the results list, `400` by default
labels
Partial<CommandPaletteLabels>
No
Overrides of the default English labels