Works natively with Cursor. Add npx -y bare-ui-mcp to your MCP settings.

Command Palette

Fast, composable, unstyled command menu. Zero dependencies.

Press Cmd/Ctrl + K or click the button.

Installation

1. Add the CSS to your global stylesheet:

css
/* Command Palette */
.cmd-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); z-index: 100; display: flex; align-items: flex-start; justify-content: center; padding-top: 10vh; }
.cmd-dialog { width: 100%; max-width: 600px; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 12px; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); overflow: hidden; display: flex; flex-direction: column; }
.cmd-header { display: flex; align-items: center; padding: 16px; border-bottom: 1px solid var(--border-color); }
.cmd-search-icon { color: var(--text-muted); margin-right: 12px; }
.cmd-input { flex: 1; border: none; outline: none; background: transparent; color: var(--text-primary); font-size: 16px; width: 100%; }
.cmd-input::placeholder { color: var(--text-muted); }
.cmd-esc { background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-muted); font-size: 12px; padding: 4px 8px; border-radius: 6px; cursor: pointer; }
.cmd-list { max-height: 400px; overflow-y: auto; padding: 8px; }
.cmd-empty { padding: 16px; text-align: center; color: var(--text-muted); }
.cmd-group { margin-bottom: 8px; }
.cmd-heading { padding: 8px 12px; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.cmd-item { display: flex; align-items: center; padding: 12px; cursor: pointer; border-radius: 8px; transition: background 0.2s, color 0.2s; color: var(--text-primary); }
.cmd-item.selected { background: var(--bg-secondary); color: var(--accent); }
.cmd-icon { margin-right: 12px; display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; color: var(--text-muted); }
.cmd-item.selected .cmd-icon { color: var(--accent); }
.cmd-label { font-size: 14px; font-weight: 500; }

2. Copy the React component into your project:

tsx
"use client";

import * as React from "react";

export interface CommandPaletteOption {
  id: string;
  label: string;
  icon?: React.ReactNode;
  onSelect: () => void;
}

export interface CommandPaletteGroup {
  heading?: string;
  items: CommandPaletteOption[];
}

export interface CommandPaletteProps {
  isOpen: boolean;
  onClose: () => void;
  groups: CommandPaletteGroup[];
  placeholder?: string;
}

export function CommandPalette({
  isOpen,
  onClose,
  groups,
  placeholder = "Type a command or search...",
}: CommandPaletteProps) {
  const [search, setSearch] = React.useState("");
  const [selectedIndex, setSelectedIndex] = React.useState(0);
  const inputRef = React.useRef<HTMLInputElement>(null);

  // Filter items
  const filteredGroups = React.useMemo(() => {
    if (!search) return groups;
    const lowerSearch = search.toLowerCase();
    return groups
      .map((group) => ({
        ...group,
        items: group.items.filter((item) =>
          item.label.toLowerCase().includes(lowerSearch),
        ),
      }))
      .filter((group) => group.items.length > 0);
  }, [groups, search]);

  const allFilteredItems = React.useMemo(() => {
    return filteredGroups.flatMap((group) => group.items);
  }, [filteredGroups]);

  React.useEffect(() => {
    setSelectedIndex(0);
  }, [search, isOpen]);

  React.useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = "hidden";
      // Use timeout to ensure input is focused after mount and transition
      const timer = setTimeout(() => {
        inputRef.current?.focus();
      }, 50);
      return () => {
        document.body.style.overflow = "";
        clearTimeout(timer);
      };
    }
  }, [isOpen]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setSelectedIndex(
        (prev) => (prev + 1) % Math.max(allFilteredItems.length, 1),
      );
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setSelectedIndex(
        (prev) =>
          (prev - 1 + allFilteredItems.length) %
          Math.max(allFilteredItems.length, 1),
      );
    } else if (e.key === "Enter") {
      e.preventDefault();
      if (allFilteredItems[selectedIndex]) {
        allFilteredItems[selectedIndex].onSelect();
        onClose();
        setSearch("");
      }
    } else if (e.key === "Escape") {
      e.preventDefault();
      onClose();
      setSearch("");
    }
  };

  if (!isOpen) return null;

  return (
    <div
      className="cmd-overlay"
      onClick={() => {
        onClose();
        setSearch("");
      }}
    >
      <div
        className="cmd-dialog"
        onClick={(e) => e.stopPropagation()}
        role="dialog"
        aria-modal="true"
      >
        <div className="cmd-header">
          <svg
            className="cmd-search-icon"
            width="20"
            height="20"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <circle cx="11" cy="11" r="8"></circle>
            <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
          </svg>
          <input
            ref={inputRef}
            className="cmd-input"
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder={placeholder}
          />
          <button
            className="cmd-esc"
            onClick={() => {
              onClose();
              setSearch("");
            }}
          >
            ESC
          </button>
        </div>

        <div className="cmd-list">
          {allFilteredItems.length === 0 && (
            <div className="cmd-empty">No results found.</div>
          )}

          {filteredGroups.map((group, groupIndex) => {
            // Calculate starting index for this group's items to match global selectedIndex
            const prevItemsCount = filteredGroups
              .slice(0, groupIndex)
              .reduce((acc, g) => acc + g.items.length, 0);

            return (
              <div key={groupIndex} className="cmd-group">
                {group.heading && (
                  <div className="cmd-heading">{group.heading}</div>
                )}
                {group.items.map((item, itemIndex) => {
                  const globalIndex = prevItemsCount + itemIndex;
                  const isSelected = globalIndex === selectedIndex;

                  return (
                    <div
                      key={item.id}
                      className={`cmd-item ${isSelected ? "selected" : ""}`}
                      onMouseEnter={() => setSelectedIndex(globalIndex)}
                      onClick={() => {
                        item.onSelect();
                        onClose();
                        setSearch("");
                      }}
                    >
                      {item.icon && (
                        <span className="cmd-icon">{item.icon}</span>
                      )}
                      <span className="cmd-label">{item.label}</span>
                    </div>
                  );
                })}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

Usage

tsx
import { CommandPalette } from '@/components/ui/command-palette';

export default function MyComponent() {
  const groups = [
    {
      heading: 'Navigation',
      items: [
        { id: 'home', label: 'Home', onSelect: () => console.log('Home') },
        { id: 'settings', label: 'Settings', onSelect: () => console.log('Settings') }
      ]
    }
  ];

  return (
    <>
      <button onClick={() => {
        // Trigger command palette via keyboard shortcut or state
      }}>Open Menu</button>

      <CommandPalette
        isOpen={true}
        onClose={() => {}}
        groups={groups}
      />
    </>
  );
}