Command Center

Searchable command palette with grouped actions, keyboard navigation, and announcement-style motion.

Palette

Grouped commands with keyboard navigation and motion.

function CommandCenterDemo() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button variant="secondary" onClick={() => setOpen(true)}>
        Open command center
      </Button>
      <CommandCenter
        isOpen={open}
        onClose={() => setOpen(false)}
        onOpenChange={setOpen}
        items={COMMAND_CENTER_ITEMS}
        shortcut="k"
        onSelect={() => setOpen(false)}
      />
    </>
  );
}
function CommandCenterDemo() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button variant="secondary" onClick={() => setOpen(true)}>
        Open command center
      </Button>
      <CommandCenter
        isOpen={open}
        onClose={() => setOpen(false)}
        onOpenChange={setOpen}
        items={COMMAND_CENTER_ITEMS}
        shortcut="k"
        onSelect={() => setOpen(false)}
      />
    </>
  );
}

Installation

npx shadcn@latest add https://boardcn.dev/r/command-center.json
npx shadcn@latest add https://boardcn.dev/r/command-center.json

npm packages

  • @remixicon/react
  • motion
  • react-aria
  • react-aria-components

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/blocks/command-center/command-center.tsx
"use client";

import {
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type ComponentType,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "motion/react";
import { FocusScope } from "react-aria";
import { RiSearchLine } from "@remixicon/react";
import { CloseButton } from "@/components/base/buttons/close-button";
import { InputBase, TextField } from "@/components/base/input/input";
import { Kbd } from "@/components/base/kbd/kbd";
import { usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx, sortCx } from "@/utils/cx";

/**
 * BoardCN command palette — a top-centered, search-first overlay for jumping
 * to actions, pages, and docs.
 *
 * Interaction follows the familiar command-menu pattern (type to filter,
 * grouped results, arrow keys, Enter, Escape) without pulling in cmdk.
 * Motion matches Announcement: blur + fade + scale from 0.85, rising 15px
 * on enter; soft blur + scale-down on exit via `motion/react`.
 *
 * Controlled like SettingsModal: parent owns `isOpen` / `onClose`. Dismiss
 * (close button, Esc, overlay) calls `onClose` immediately; AnimatePresence
 * plays the exit while the portal stays mounted. Pass `shortcut="k"` to
 * register ⌘/Ctrl+K — closing uses `onClose`, opening uses `onOpenChange(true)`
 * when provided.
 *
 * The leading `icon` defaults to `RiSearchLine` (the same Remix glyph as
 * sidebar Quick Search). Pass any Remix Icon component reference
 * (`RiShieldStarFill`, not `<RiShieldStarFill />`). `introDelay` (seconds)
 * delays the entrance after open — omit for delay 0 (still animates).
 *
 * Spacing, type, and row chrome follow the dropdown / announcement recipes:
 * 20px icons, 8px row padding, 10px panel inset, 12px header inset.
 */

type IconComponent = ComponentType<{
  className?: string;
  "aria-hidden"?: boolean | "true" | "false";
}>;

export type CommandCenterItem = {
  id: string;
  label: string;
  description?: string;
  group?: string;
  icon?: IconComponent;
  shortcut?: string;
  href?: string;
  disabled?: boolean;
  keywords?: string[];
};

export interface CommandCenterProps {
  items: CommandCenterItem[];
  isOpen: boolean;
  onClose: () => void;
  /** Needed for `shortcut` to open the palette (toggle). */
  onOpenChange?: (open: boolean) => void;
  onSelect?: (item: CommandCenterItem) => void;
  title?: ReactNode;
  placeholder?: string;
  emptyMessage?: string;
  /** Header icon. Defaults to RiSearchLine. */
  icon?: IconComponent;
  closeLabel?: string;
  /**
   * Seconds to wait before playing the blur/fade/scale-up entrance after open.
   * Omit for delay 0 — the palette still animates in.
   */
  introDelay?: number;
  /** Letter key for ⌘/Ctrl+shortcut toggle, e.g. `"k"`. */
  shortcut?: string;
}

const styles = sortCx({
  panel: [
    "relative flex w-full max-w-[480px] flex-col overflow-hidden",
    "rounded-3xl border border-border-button-default bg-background-primary-default shadow-dropdown",
  ].join(" "),
  header: "flex items-center gap-2 border-b border-border-button-default px-3 py-3 pr-10",
  headerIcon: "size-5 shrink-0 text-foreground-icon-secondary",
  headerTitle: "min-w-0 flex-1 truncate text-body-medium text-text-primary",
  close: "absolute right-3 top-3",
  field: "border-b border-border-button-default px-3 py-3",
  list: "flex max-h-[min(52vh,420px)] flex-col gap-1 overflow-y-auto p-2.5",
  group: "flex w-full flex-col gap-1.5 pt-1",
  groupLabel: "pl-2 text-body-medium text-text-secondary",
  groupItems: "flex w-full flex-col gap-1",
  item: [
    "flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-left",
    "text-text-primary outline-none transition-colors",
  ].join(" "),
  itemActive: "bg-dropdown-item-hover-background",
  itemDisabled: "cursor-not-allowed opacity-50",
  itemIcon: "size-5 shrink-0 text-foreground-icon-secondary",
  itemText: "flex min-w-0 flex-1 flex-col gap-0.5",
  itemLabel: "truncate text-body-medium text-text-primary",
  itemDescription: "truncate text-body-2-medium text-text-secondary",
  empty: "px-3 py-8 text-center text-body-2-regular text-text-tertiary",
  footer: [
    "flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-border-button-default",
    "px-3 py-2 text-caption-1-regular text-text-tertiary",
  ].join(" "),
  footerHint: "inline-flex items-center gap-1.5",
});

function scoreItem(item: CommandCenterItem, query: string): number {
  const label = item.label.toLowerCase();
  if (label === query) return 0;
  if (label.startsWith(query)) return 1;
  if (label.includes(query)) return 2;
  if (item.group?.toLowerCase().includes(query)) return 3;
  if (item.description?.toLowerCase().includes(query)) return 4;
  if (item.keywords?.some((keyword) => keyword.toLowerCase().includes(query))) return 5;
  return Number.POSITIVE_INFINITY;
}

function filterItems(items: CommandCenterItem[], query: string): CommandCenterItem[] {
  const trimmed = query.trim().toLowerCase();
  if (!trimmed) return items;
  return items
    .map((item) => ({ item, rank: scoreItem(item, trimmed) }))
    .filter((row) => Number.isFinite(row.rank))
    .sort((a, b) => a.rank - b.rank || a.item.label.localeCompare(b.item.label))
    .map((row) => row.item);
}

function groupItems(items: CommandCenterItem[]): { label: string; items: CommandCenterItem[] }[] {
  const order: string[] = [];
  const map = new Map<string, CommandCenterItem[]>();

  for (const item of items) {
    const label = item.group?.trim() || "Commands";
    if (!map.has(label)) {
      map.set(label, []);
      order.push(label);
    }
    map.get(label)!.push(item);
  }

  return order.map((label) => ({ label, items: map.get(label)! }));
}

export function CommandCenter({
  items,
  isOpen,
  onClose,
  onOpenChange,
  onSelect,
  title = "Command",
  placeholder = "Search commands…",
  emptyMessage = "Nothing matches that search.",
  icon: Icon = RiSearchLine,
  closeLabel = "Close command center",
  introDelay,
  shortcut,
}: CommandCenterProps) {
  const listId = useId();
  const reducedMotion = usePrefersReducedMotion();
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLDivElement>(null);
  const [portalReady, setPortalReady] = useState(false);

  const results = useMemo(() => filterItems(items, query), [items, query]);
  const groups = useMemo(() => groupItems(results), [results]);
  const flatIds = useMemo(() => results.map((item) => item.id), [results]);

  useEffect(() => {
    setPortalReady(true);
  }, []);

  useEffect(() => {
    if (!isOpen) {
      setQuery("");
      setActive(0);
    }
  }, [isOpen]);

  useEffect(() => {
    setActive(0);
  }, [query]);

  useEffect(() => {
    if (!isOpen) return;
    const previous = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = previous;
    };
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        event.preventDefault();
        onClose();
      }
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [isOpen, onClose]);

  useEffect(() => {
    if (!shortcut) return;
    const key = shortcut.toLowerCase();
    const onKeyDown = (event: KeyboardEvent) => {
      if (!(event.metaKey || event.ctrlKey)) return;
      if (event.key.toLowerCase() !== key) return;
      event.preventDefault();
      if (isOpen) {
        onClose();
        onOpenChange?.(false);
      } else {
        onOpenChange?.(true);
      }
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [shortcut, isOpen, onClose, onOpenChange]);

  useEffect(() => {
    if (!isOpen) return;
    const option = listRef.current?.querySelector<HTMLElement>(
      `[data-command-id="${CSS.escape(flatIds[active] ?? "")}"]`,
    );
    option?.scrollIntoView({ block: "nearest" });
  }, [active, flatIds, isOpen]);

  const selectActive = () => {
    const item = results[active];
    if (!item || item.disabled) return;
    onSelect?.(item);
  };

  const onInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Escape") {
      // Document-level listener also handles Escape; keep this for input focus.
      event.preventDefault();
      onClose();
      return;
    }
    if (event.key === "ArrowDown") {
      event.preventDefault();
      if (results.length === 0) return;
      setActive((index) => Math.min(index + 1, results.length - 1));
      return;
    }
    if (event.key === "ArrowUp") {
      event.preventDefault();
      if (results.length === 0) return;
      setActive((index) => Math.max(index - 1, 0));
      return;
    }
    if (event.key === "Home") {
      event.preventDefault();
      setActive(0);
      return;
    }
    if (event.key === "End") {
      event.preventDefault();
      if (results.length === 0) return;
      setActive(results.length - 1);
      return;
    }
    if (event.key === "Enter") {
      event.preventDefault();
      selectActive();
    }
  };

  const activeId = flatIds[active] ? `${listId}-${flatIds[active]}` : undefined;

  const enterTransition = {
    duration: reducedMotion ? 0.12 : 0.25,
    ease: "easeOut" as const,
    delay: introDelay ?? 0,
  };
  const exitTransition = {
    duration: reducedMotion ? 0.12 : 0.25,
    ease: "easeInOut" as const,
  };

  const panelInitial = reducedMotion
    ? { opacity: 0, x: "-50%" }
    : { opacity: 0, x: "-50%", y: 15, scale: 0.85, filter: "blur(6px)" };
  const panelAnimate = reducedMotion
    ? { opacity: 1, x: "-50%", transition: enterTransition }
    : {
        opacity: 1,
        x: "-50%",
        y: 0,
        scale: 1,
        filter: "blur(0px)",
        transition: enterTransition,
      };
  const panelExit = reducedMotion
    ? { opacity: 0, x: "-50%", transition: exitTransition }
    : {
        opacity: 0,
        x: "-50%",
        scale: 0.85,
        filter: "blur(6px)",
        transition: exitTransition,
      };

  if (!portalReady) return null;

  const emptyCopy = query.trim()
    ? emptyMessage.includes("{query}")
      ? emptyMessage.replace("{query}", query.trim())
      : `Nothing matches “${query.trim()}”.`
    : emptyMessage;

  return createPortal(
    <AnimatePresence>
      {isOpen ? (
        <motion.button
          key="command-center-backdrop"
          type="button"
          aria-label="Dismiss command center"
          tabIndex={-1}
          initial={{ opacity: 0 }}
          animate={{
            opacity: 1,
            transition: { duration: reducedMotion ? 0.12 : 0.2 },
          }}
          exit={{
            opacity: 0,
            transition: { duration: reducedMotion ? 0.12 : 0.2, ease: "easeInOut" },
          }}
          onClick={onClose}
          className="fixed inset-0 z-50 cursor-default bg-black/40 backdrop-blur-[2px]"
        />
      ) : null}
      {isOpen ? (
        <motion.div
          key="command-center-panel"
          role="dialog"
          aria-modal="true"
          aria-label={typeof title === "string" ? title : "Command center"}
          initial={panelInitial}
          animate={panelAnimate}
          exit={panelExit}
          className={cx(
            styles.panel,
            "fixed top-[12vh] left-1/2 z-50 w-[calc(100%-2rem)] max-w-[480px] sm:top-[14vh]",
          )}
        >
          <FocusScope contain restoreFocus>
            <div className="relative flex w-full flex-col overflow-hidden">
              <div className={styles.header}>
                <Icon className={styles.headerIcon} aria-hidden />
                <p className={styles.headerTitle}>{title}</p>
              </div>

              <div className={styles.field}>
                <TextField
                  aria-label={placeholder}
                  value={query}
                  onChange={setQuery}
                  className="gap-0"
                  autoFocus
                >
                  <InputBase
                    ref={inputRef}
                    leadingIcon={RiSearchLine}
                    placeholder={placeholder}
                    autoComplete="off"
                    autoCorrect="off"
                    spellCheck={false}
                    aria-controls={listId}
                    aria-autocomplete="list"
                    aria-activedescendant={activeId}
                    onKeyDown={onInputKeyDown}
                  />
                </TextField>
              </div>

              {results.length === 0 ? (
                <p className={styles.empty}>{emptyCopy}</p>
              ) : (
                <div
                  ref={listRef}
                  id={listId}
                  role="listbox"
                  aria-label="Commands"
                  className={styles.list}
                >
                  {groups.map((group) => (
                    <div
                      key={group.label}
                      className={styles.group}
                      role="group"
                      aria-label={group.label}
                    >
                      <p className={styles.groupLabel}>{group.label}</p>
                      <div className={styles.groupItems}>
                        {group.items.map((item) => {
                          const index = flatIds.indexOf(item.id);
                          const isActive = index === active;
                          const ItemIcon = item.icon;
                          return (
                            <button
                              key={item.id}
                              id={`${listId}-${item.id}`}
                              type="button"
                              role="option"
                              data-command-id={item.id}
                              aria-selected={isActive}
                              disabled={item.disabled}
                              onMouseEnter={() => {
                                if (!item.disabled) setActive(index);
                              }}
                              onClick={() => {
                                if (item.disabled) return;
                                onSelect?.(item);
                              }}
                              className={cx(
                                styles.item,
                                isActive && styles.itemActive,
                                item.disabled && styles.itemDisabled,
                              )}
                            >
                              {ItemIcon ? (
                                <ItemIcon className={styles.itemIcon} aria-hidden />
                              ) : null}
                              <span className={styles.itemText}>
                                <span className={styles.itemLabel}>{item.label}</span>
                                {item.description ? (
                                  <span className={styles.itemDescription}>
                                    {item.description}
                                  </span>
                                ) : null}
                              </span>
                              {item.shortcut ? <Kbd>{item.shortcut}</Kbd> : null}
                            </button>
                          );
                        })}
                      </div>
                    </div>
                  ))}
                </div>
              )}

              <div className={styles.footer}>
                <span className={styles.footerHint}>
                  <Kbd>↑</Kbd>
                  <Kbd>↓</Kbd>
                  navigate
                </span>
                <span className={styles.footerHint}>
                  <Kbd>↵</Kbd>
                  select
                </span>
                <span className={styles.footerHint}>
                  <Kbd>esc</Kbd>
                  close
                </span>
              </div>

              <CloseButton
                size="xs"
                aria-label={closeLabel}
                onClick={onClose}
                className={cx(styles.close, "z-10")}
              />
            </div>
          </FocusScope>
        </motion.div>
      ) : null}
    </AnimatePresence>,
    document.body,
  );
}
"use client";

import {
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type ComponentType,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "motion/react";
import { FocusScope } from "react-aria";
import { RiSearchLine } from "@remixicon/react";
import { CloseButton } from "@/components/base/buttons/close-button";
import { InputBase, TextField } from "@/components/base/input/input";
import { Kbd } from "@/components/base/kbd/kbd";
import { usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx, sortCx } from "@/utils/cx";

/**
 * BoardCN command palette — a top-centered, search-first overlay for jumping
 * to actions, pages, and docs.
 *
 * Interaction follows the familiar command-menu pattern (type to filter,
 * grouped results, arrow keys, Enter, Escape) without pulling in cmdk.
 * Motion matches Announcement: blur + fade + scale from 0.85, rising 15px
 * on enter; soft blur + scale-down on exit via `motion/react`.
 *
 * Controlled like SettingsModal: parent owns `isOpen` / `onClose`. Dismiss
 * (close button, Esc, overlay) calls `onClose` immediately; AnimatePresence
 * plays the exit while the portal stays mounted. Pass `shortcut="k"` to
 * register ⌘/Ctrl+K — closing uses `onClose`, opening uses `onOpenChange(true)`
 * when provided.
 *
 * The leading `icon` defaults to `RiSearchLine` (the same Remix glyph as
 * sidebar Quick Search). Pass any Remix Icon component reference
 * (`RiShieldStarFill`, not `<RiShieldStarFill />`). `introDelay` (seconds)
 * delays the entrance after open — omit for delay 0 (still animates).
 *
 * Spacing, type, and row chrome follow the dropdown / announcement recipes:
 * 20px icons, 8px row padding, 10px panel inset, 12px header inset.
 */

type IconComponent = ComponentType<{
  className?: string;
  "aria-hidden"?: boolean | "true" | "false";
}>;

export type CommandCenterItem = {
  id: string;
  label: string;
  description?: string;
  group?: string;
  icon?: IconComponent;
  shortcut?: string;
  href?: string;
  disabled?: boolean;
  keywords?: string[];
};

export interface CommandCenterProps {
  items: CommandCenterItem[];
  isOpen: boolean;
  onClose: () => void;
  /** Needed for `shortcut` to open the palette (toggle). */
  onOpenChange?: (open: boolean) => void;
  onSelect?: (item: CommandCenterItem) => void;
  title?: ReactNode;
  placeholder?: string;
  emptyMessage?: string;
  /** Header icon. Defaults to RiSearchLine. */
  icon?: IconComponent;
  closeLabel?: string;
  /**
   * Seconds to wait before playing the blur/fade/scale-up entrance after open.
   * Omit for delay 0 — the palette still animates in.
   */
  introDelay?: number;
  /** Letter key for ⌘/Ctrl+shortcut toggle, e.g. `"k"`. */
  shortcut?: string;
}

const styles = sortCx({
  panel: [
    "relative flex w-full max-w-[480px] flex-col overflow-hidden",
    "rounded-3xl border border-border-button-default bg-background-primary-default shadow-dropdown",
  ].join(" "),
  header: "flex items-center gap-2 border-b border-border-button-default px-3 py-3 pr-10",
  headerIcon: "size-5 shrink-0 text-foreground-icon-secondary",
  headerTitle: "min-w-0 flex-1 truncate text-body-medium text-text-primary",
  close: "absolute right-3 top-3",
  field: "border-b border-border-button-default px-3 py-3",
  list: "flex max-h-[min(52vh,420px)] flex-col gap-1 overflow-y-auto p-2.5",
  group: "flex w-full flex-col gap-1.5 pt-1",
  groupLabel: "pl-2 text-body-medium text-text-secondary",
  groupItems: "flex w-full flex-col gap-1",
  item: [
    "flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-left",
    "text-text-primary outline-none transition-colors",
  ].join(" "),
  itemActive: "bg-dropdown-item-hover-background",
  itemDisabled: "cursor-not-allowed opacity-50",
  itemIcon: "size-5 shrink-0 text-foreground-icon-secondary",
  itemText: "flex min-w-0 flex-1 flex-col gap-0.5",
  itemLabel: "truncate text-body-medium text-text-primary",
  itemDescription: "truncate text-body-2-medium text-text-secondary",
  empty: "px-3 py-8 text-center text-body-2-regular text-text-tertiary",
  footer: [
    "flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-border-button-default",
    "px-3 py-2 text-caption-1-regular text-text-tertiary",
  ].join(" "),
  footerHint: "inline-flex items-center gap-1.5",
});

function scoreItem(item: CommandCenterItem, query: string): number {
  const label = item.label.toLowerCase();
  if (label === query) return 0;
  if (label.startsWith(query)) return 1;
  if (label.includes(query)) return 2;
  if (item.group?.toLowerCase().includes(query)) return 3;
  if (item.description?.toLowerCase().includes(query)) return 4;
  if (item.keywords?.some((keyword) => keyword.toLowerCase().includes(query))) return 5;
  return Number.POSITIVE_INFINITY;
}

function filterItems(items: CommandCenterItem[], query: string): CommandCenterItem[] {
  const trimmed = query.trim().toLowerCase();
  if (!trimmed) return items;
  return items
    .map((item) => ({ item, rank: scoreItem(item, trimmed) }))
    .filter((row) => Number.isFinite(row.rank))
    .sort((a, b) => a.rank - b.rank || a.item.label.localeCompare(b.item.label))
    .map((row) => row.item);
}

function groupItems(items: CommandCenterItem[]): { label: string; items: CommandCenterItem[] }[] {
  const order: string[] = [];
  const map = new Map<string, CommandCenterItem[]>();

  for (const item of items) {
    const label = item.group?.trim() || "Commands";
    if (!map.has(label)) {
      map.set(label, []);
      order.push(label);
    }
    map.get(label)!.push(item);
  }

  return order.map((label) => ({ label, items: map.get(label)! }));
}

export function CommandCenter({
  items,
  isOpen,
  onClose,
  onOpenChange,
  onSelect,
  title = "Command",
  placeholder = "Search commands…",
  emptyMessage = "Nothing matches that search.",
  icon: Icon = RiSearchLine,
  closeLabel = "Close command center",
  introDelay,
  shortcut,
}: CommandCenterProps) {
  const listId = useId();
  const reducedMotion = usePrefersReducedMotion();
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLDivElement>(null);
  const [portalReady, setPortalReady] = useState(false);

  const results = useMemo(() => filterItems(items, query), [items, query]);
  const groups = useMemo(() => groupItems(results), [results]);
  const flatIds = useMemo(() => results.map((item) => item.id), [results]);

  useEffect(() => {
    setPortalReady(true);
  }, []);

  useEffect(() => {
    if (!isOpen) {
      setQuery("");
      setActive(0);
    }
  }, [isOpen]);

  useEffect(() => {
    setActive(0);
  }, [query]);

  useEffect(() => {
    if (!isOpen) return;
    const previous = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = previous;
    };
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        event.preventDefault();
        onClose();
      }
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [isOpen, onClose]);

  useEffect(() => {
    if (!shortcut) return;
    const key = shortcut.toLowerCase();
    const onKeyDown = (event: KeyboardEvent) => {
      if (!(event.metaKey || event.ctrlKey)) return;
      if (event.key.toLowerCase() !== key) return;
      event.preventDefault();
      if (isOpen) {
        onClose();
        onOpenChange?.(false);
      } else {
        onOpenChange?.(true);
      }
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [shortcut, isOpen, onClose, onOpenChange]);

  useEffect(() => {
    if (!isOpen) return;
    const option = listRef.current?.querySelector<HTMLElement>(
      `[data-command-id="${CSS.escape(flatIds[active] ?? "")}"]`,
    );
    option?.scrollIntoView({ block: "nearest" });
  }, [active, flatIds, isOpen]);

  const selectActive = () => {
    const item = results[active];
    if (!item || item.disabled) return;
    onSelect?.(item);
  };

  const onInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Escape") {
      // Document-level listener also handles Escape; keep this for input focus.
      event.preventDefault();
      onClose();
      return;
    }
    if (event.key === "ArrowDown") {
      event.preventDefault();
      if (results.length === 0) return;
      setActive((index) => Math.min(index + 1, results.length - 1));
      return;
    }
    if (event.key === "ArrowUp") {
      event.preventDefault();
      if (results.length === 0) return;
      setActive((index) => Math.max(index - 1, 0));
      return;
    }
    if (event.key === "Home") {
      event.preventDefault();
      setActive(0);
      return;
    }
    if (event.key === "End") {
      event.preventDefault();
      if (results.length === 0) return;
      setActive(results.length - 1);
      return;
    }
    if (event.key === "Enter") {
      event.preventDefault();
      selectActive();
    }
  };

  const activeId = flatIds[active] ? `${listId}-${flatIds[active]}` : undefined;

  const enterTransition = {
    duration: reducedMotion ? 0.12 : 0.25,
    ease: "easeOut" as const,
    delay: introDelay ?? 0,
  };
  const exitTransition = {
    duration: reducedMotion ? 0.12 : 0.25,
    ease: "easeInOut" as const,
  };

  const panelInitial = reducedMotion
    ? { opacity: 0, x: "-50%" }
    : { opacity: 0, x: "-50%", y: 15, scale: 0.85, filter: "blur(6px)" };
  const panelAnimate = reducedMotion
    ? { opacity: 1, x: "-50%", transition: enterTransition }
    : {
        opacity: 1,
        x: "-50%",
        y: 0,
        scale: 1,
        filter: "blur(0px)",
        transition: enterTransition,
      };
  const panelExit = reducedMotion
    ? { opacity: 0, x: "-50%", transition: exitTransition }
    : {
        opacity: 0,
        x: "-50%",
        scale: 0.85,
        filter: "blur(6px)",
        transition: exitTransition,
      };

  if (!portalReady) return null;

  const emptyCopy = query.trim()
    ? emptyMessage.includes("{query}")
      ? emptyMessage.replace("{query}", query.trim())
      : `Nothing matches “${query.trim()}”.`
    : emptyMessage;

  return createPortal(
    <AnimatePresence>
      {isOpen ? (
        <motion.button
          key="command-center-backdrop"
          type="button"
          aria-label="Dismiss command center"
          tabIndex={-1}
          initial={{ opacity: 0 }}
          animate={{
            opacity: 1,
            transition: { duration: reducedMotion ? 0.12 : 0.2 },
          }}
          exit={{
            opacity: 0,
            transition: { duration: reducedMotion ? 0.12 : 0.2, ease: "easeInOut" },
          }}
          onClick={onClose}
          className="fixed inset-0 z-50 cursor-default bg-black/40 backdrop-blur-[2px]"
        />
      ) : null}
      {isOpen ? (
        <motion.div
          key="command-center-panel"
          role="dialog"
          aria-modal="true"
          aria-label={typeof title === "string" ? title : "Command center"}
          initial={panelInitial}
          animate={panelAnimate}
          exit={panelExit}
          className={cx(
            styles.panel,
            "fixed top-[12vh] left-1/2 z-50 w-[calc(100%-2rem)] max-w-[480px] sm:top-[14vh]",
          )}
        >
          <FocusScope contain restoreFocus>
            <div className="relative flex w-full flex-col overflow-hidden">
              <div className={styles.header}>
                <Icon className={styles.headerIcon} aria-hidden />
                <p className={styles.headerTitle}>{title}</p>
              </div>

              <div className={styles.field}>
                <TextField
                  aria-label={placeholder}
                  value={query}
                  onChange={setQuery}
                  className="gap-0"
                  autoFocus
                >
                  <InputBase
                    ref={inputRef}
                    leadingIcon={RiSearchLine}
                    placeholder={placeholder}
                    autoComplete="off"
                    autoCorrect="off"
                    spellCheck={false}
                    aria-controls={listId}
                    aria-autocomplete="list"
                    aria-activedescendant={activeId}
                    onKeyDown={onInputKeyDown}
                  />
                </TextField>
              </div>

              {results.length === 0 ? (
                <p className={styles.empty}>{emptyCopy}</p>
              ) : (
                <div
                  ref={listRef}
                  id={listId}
                  role="listbox"
                  aria-label="Commands"
                  className={styles.list}
                >
                  {groups.map((group) => (
                    <div
                      key={group.label}
                      className={styles.group}
                      role="group"
                      aria-label={group.label}
                    >
                      <p className={styles.groupLabel}>{group.label}</p>
                      <div className={styles.groupItems}>
                        {group.items.map((item) => {
                          const index = flatIds.indexOf(item.id);
                          const isActive = index === active;
                          const ItemIcon = item.icon;
                          return (
                            <button
                              key={item.id}
                              id={`${listId}-${item.id}`}
                              type="button"
                              role="option"
                              data-command-id={item.id}
                              aria-selected={isActive}
                              disabled={item.disabled}
                              onMouseEnter={() => {
                                if (!item.disabled) setActive(index);
                              }}
                              onClick={() => {
                                if (item.disabled) return;
                                onSelect?.(item);
                              }}
                              className={cx(
                                styles.item,
                                isActive && styles.itemActive,
                                item.disabled && styles.itemDisabled,
                              )}
                            >
                              {ItemIcon ? (
                                <ItemIcon className={styles.itemIcon} aria-hidden />
                              ) : null}
                              <span className={styles.itemText}>
                                <span className={styles.itemLabel}>{item.label}</span>
                                {item.description ? (
                                  <span className={styles.itemDescription}>
                                    {item.description}
                                  </span>
                                ) : null}
                              </span>
                              {item.shortcut ? <Kbd>{item.shortcut}</Kbd> : null}
                            </button>
                          );
                        })}
                      </div>
                    </div>
                  ))}
                </div>
              )}

              <div className={styles.footer}>
                <span className={styles.footerHint}>
                  <Kbd>↑</Kbd>
                  <Kbd>↓</Kbd>
                  navigate
                </span>
                <span className={styles.footerHint}>
                  <Kbd>↵</Kbd>
                  select
                </span>
                <span className={styles.footerHint}>
                  <Kbd>esc</Kbd>
                  close
                </span>
              </div>

              <CloseButton
                size="xs"
                aria-label={closeLabel}
                onClick={onClose}
                className={cx(styles.close, "z-10")}
              />
            </div>
          </FocusScope>
        </motion.div>
      ) : null}
    </AnimatePresence>,
    document.body,
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

CommandCenter

PropTypeDefaultDescription
isOpenrequiredboolean
itemsrequiredCommandCenterItem[]
onCloserequired() => void
closeLabelstringClose command center
emptyMessagestringNothing matches that search.
iconIconComponentHeader icon. Defaults to RiSearchLine.
introDelaynumberSeconds to wait before playing the blur/fade/scale-up entrance after open. Omit for delay 0 — the palette still animates in.
onOpenChange(open: boolean) => voidNeeded for `shortcut` to open the palette (toggle).
onSelect(item: CommandCenterItem) => void
placeholderstringSearch commands…
shortcutstringLetter key for ⌘/Ctrl+shortcut toggle, e.g. `"k"`.
titleReactNodeCommand