DEV Community

Justin Clark
Justin Clark

Posted on

Building Command Menus with cmdk in React

cmdk is a fast, unstyled command menu React component that provides a composable API for building command palettes, search interfaces, and accessible comboboxes. It automatically handles filtering, sorting, keyboard navigation, and accessibility, making it perfect for creating ⌘K-style command menus. This guide walks through setting up and creating command menus using cmdk with React, from installation to a working implementation.

Prerequisites

Before you begin, make sure you have:

  • Node.js version 14.0 or higher installed
  • npm, yarn, or pnpm package manager
  • A React project (version 16.8 or higher) or create-react-app setup
  • Basic knowledge of React hooks (useState, useEffect)
  • Familiarity with JavaScript/TypeScript
  • Understanding of keyboard navigation

Installation

Install cmdk using your preferred package manager:

npm install cmdk
Enter fullscreen mode Exit fullscreen mode

Or with yarn:

yarn add cmdk
Enter fullscreen mode Exit fullscreen mode

Or with pnpm:

pnpm add cmdk
Enter fullscreen mode Exit fullscreen mode

After installation, your package.json should include:

{
  "dependencies": {
    "cmdk": "^1.0.0",
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Project Setup

cmdk requires minimal setup. Import the components and you're ready to use them. You may want to add some basic styling for a better appearance.

First Example / Basic Usage

Let's create a simple command menu. Create a new file src/CommandMenuExample.jsx:

// src/CommandMenuExample.jsx
import React, { useState } from 'react';
import { Command } from 'cmdk';

function CommandMenuExample() {
  const [open, setOpen] = useState(false);

  return (
    <div style={{ padding: '20px' }}>
      <button
        onClick={() => setOpen(!open)}
        style={{
          padding: '8px 16px',
          backgroundColor: '#007bff',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer'
        }}
      >
        Open Command Menu
      </button>
      {open && (
        <div style={{
          position: 'fixed',
          top: '50%',
          left: '50%',
          transform: 'translate(-50%, -50%)',
          width: '500px',
          backgroundColor: 'white',
          borderRadius: '8px',
          boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
          zIndex: 1000
        }}>
          <Command>
            <Command.Input placeholder="Type a command or search..." />
            <Command.List>
              <Command.Empty>No results found.</Command.Empty>
              <Command.Group heading="Suggestions">
                <Command.Item>Calendar</Command.Item>
                <Command.Item>Search Emoji</Command.Item>
                <Command.Item>Calculator</Command.Item>
              </Command.Group>
              <Command.Group heading="Settings">
                <Command.Item>Profile</Command.Item>
                <Command.Item>Billing</Command.Item>
                <Command.Item>Settings</Command.Item>
              </Command.Group>
            </Command.List>
          </Command>
        </div>
      )}
      {open && (
        <div
          style={{
            position: 'fixed',
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
            backgroundColor: 'rgba(0,0,0,0.5)',
            zIndex: 999
          }}
          onClick={() => setOpen(false)}
        />
      )}
    </div>
  );
}

export default CommandMenuExample;
Enter fullscreen mode Exit fullscreen mode

Update your App.jsx:

// src/App.jsx
import React from 'react';
import CommandMenuExample from './CommandMenuExample';
import './App.css';

function App() {
  return (
    <div className="App">
      <CommandMenuExample />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

This creates a basic command menu with searchable items grouped by category.

Understanding the Basics

cmdk provides command menu components:

  • Command: Main container component
  • Command.Input: Search input field
  • Command.List: Container for command items
  • Command.Item: Individual command item
  • Command.Group: Group of related items
  • Command.Empty: Message when no results found
  • Command.Separator: Visual separator between groups

Key concepts:

  • Automatic filtering: Items are filtered as you type
  • Keyboard navigation: Arrow keys, Enter, Escape work automatically
  • Composable API: Build complex menus with nested components
  • Accessibility: Built-in ARIA attributes and keyboard support
  • Unstyled: You provide all styling

Here's an example with more features:

// src/AdvancedCommandMenuExample.jsx
import React, { useState } from 'react';
import { Command } from 'cmdk';

function AdvancedCommandMenuExample() {
  const [open, setOpen] = useState(false);
  const [search, setSearch] = useState('');

  const items = [
    { id: '1', label: 'New File', group: 'File' },
    { id: '2', label: 'Open File', group: 'File' },
    { id: '3', label: 'Save', group: 'File' },
    { id: '4', label: 'Cut', group: 'Edit' },
    { id: '5', label: 'Copy', group: 'Edit' },
    { id: '6', label: 'Paste', group: 'Edit' },
    { id: '7', label: 'Undo', group: 'Edit' },
    { id: '8', label: 'Redo', group: 'Edit' }
  ];

  const filteredItems = items.filter(item =>
    item.label.toLowerCase().includes(search.toLowerCase())
  );

  const groupedItems = filteredItems.reduce((acc, item) => {
    if (!acc[item.group]) {
      acc[item.group] = [];
    }
    acc[item.group].push(item);
    return acc;
  }, {});

  return (
    <div style={{ padding: '20px' }}>
      <button
        onClick={() => setOpen(!open)}
        style={{
          padding: '8px 16px',
          backgroundColor: '#007bff',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer'
        }}
      >
        Open Command Menu (⌘K)
      </button>
      {open && (
        <div style={{
          position: 'fixed',
          top: '50%',
          left: '50%',
          transform: 'translate(-50%, -50%)',
          width: '500px',
          backgroundColor: 'white',
          borderRadius: '8px',
          boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
          zIndex: 1000
        }}>
          <Command>
            <Command.Input
              placeholder="Type a command or search..."
              value={search}
              onValueChange={setSearch}
            />
            <Command.List>
              <Command.Empty>No results found.</Command.Empty>
              {Object.entries(groupedItems).map(([group, groupItems]) => (
                <Command.Group key={group} heading={group}>
                  {groupItems.map(item => (
                    <Command.Item
                      key={item.id}
                      onSelect={() => {
                        console.log(`Selected: ${item.label}`);
                        setOpen(false);
                      }}
                    >
                      {item.label}
                    </Command.Item>
                  ))}
                </Command.Group>
              ))}
            </Command.List>
          </Command>
        </div>
      )}
      {open && (
        <div
          style={{
            position: 'fixed',
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
            backgroundColor: 'rgba(0,0,0,0.5)',
            zIndex: 999
          }}
          onClick={() => setOpen(false)}
        />
      )}
    </div>
  );
}

export default AdvancedCommandMenuExample;
Enter fullscreen mode Exit fullscreen mode

Practical Example / Building Something Real

Let's build a comprehensive command palette with keyboard shortcuts and actions:

// src/CommandPalette.jsx
import React, { useState, useEffect } from 'react';
import { Command } from 'cmdk';

function CommandPalette() {
  const [open, setOpen] = useState(false);
  const [search, setSearch] = useState('');

  useEffect(() => {
    const down = (e) => {
      if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((open) => !open);
      }
      if (e.key === 'Escape') {
        setOpen(false);
      }
    };

    document.addEventListener('keydown', down);
    return () => document.removeEventListener('keydown', down);
  }, []);

  const commands = [
    {
      id: 'new-file',
      label: 'New File',
      shortcut: '⌘N',
      group: 'File',
      action: () => console.log('New file created')
    },
    {
      id: 'open-file',
      label: 'Open File',
      shortcut: '⌘O',
      group: 'File',
      action: () => console.log('Open file dialog')
    },
    {
      id: 'save',
      label: 'Save',
      shortcut: '⌘S',
      group: 'File',
      action: () => console.log('File saved')
    },
    {
      id: 'cut',
      label: 'Cut',
      shortcut: '⌘X',
      group: 'Edit',
      action: () => console.log('Cut to clipboard')
    },
    {
      id: 'copy',
      label: 'Copy',
      shortcut: '⌘C',
      group: 'Edit',
      action: () => console.log('Copied to clipboard')
    },
    {
      id: 'paste',
      label: 'Paste',
      shortcut: '⌘V',
      group: 'Edit',
      action: () => console.log('Pasted from clipboard')
    },
    {
      id: 'settings',
      label: 'Settings',
      shortcut: '⌘,',
      group: 'Preferences',
      action: () => console.log('Open settings')
    },
    {
      id: 'theme',
      label: 'Toggle Theme',
      shortcut: '⌘T',
      group: 'Preferences',
      action: () => console.log('Theme toggled')
    }
  ];

  const filteredCommands = commands.filter(cmd =>
    cmd.label.toLowerCase().includes(search.toLowerCase()) ||
    cmd.group.toLowerCase().includes(search.toLowerCase())
  );

  const groupedCommands = filteredCommands.reduce((acc, cmd) => {
    if (!acc[cmd.group]) {
      acc[cmd.group] = [];
    }
    acc[cmd.group].push(cmd);
    return acc;
  }, {});

  const handleSelect = (command) => {
    command.action();
    setOpen(false);
    setSearch('');
  };

  return (
    <>
      {open && (
        <div
          style={{
            position: 'fixed',
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
            backgroundColor: 'rgba(0,0,0,0.5)',
            zIndex: 999,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center'
          }}
          onClick={() => setOpen(false)}
        >
          <div
            style={{
              width: '600px',
              maxWidth: '90vw',
              backgroundColor: 'white',
              borderRadius: '8px',
              boxShadow: '0 10px 25px rgba(0,0,0,0.2)',
              overflow: 'hidden'
            }}
            onClick={(e) => e.stopPropagation()}
          >
            <Command>
              <Command.Input
                placeholder="Type a command or search..."
                value={search}
                onValueChange={setSearch}
                style={{
                  width: '100%',
                  padding: '12px',
                  border: 'none',
                  outline: 'none',
                  fontSize: '16px',
                  borderBottom: '1px solid #eee'
                }}
              />
              <Command.List style={{ maxHeight: '400px', overflow: 'auto' }}>
                <Command.Empty style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
                  No results found.
                </Command.Empty>
                {Object.entries(groupedCommands).map(([group, groupCommands]) => (
                  <Command.Group
                    key={group}
                    heading={group}
                    style={{
                      padding: '8px 0',
                      fontSize: '12px',
                      fontWeight: 'bold',
                      color: '#666',
                      textTransform: 'uppercase',
                      paddingLeft: '12px'
                    }}
                  >
                    {groupCommands.map(cmd => (
                      <Command.Item
                        key={cmd.id}
                        onSelect={() => handleSelect(cmd)}
                        style={{
                          padding: '12px',
                          cursor: 'pointer',
                          display: 'flex',
                          justifyContent: 'space-between',
                          alignItems: 'center'
                        }}
                      >
                        <span>{cmd.label}</span>
                        <span style={{ fontSize: '12px', color: '#999' }}>
                          {cmd.shortcut}
                        </span>
                      </Command.Item>
                    ))}
                  </Command.Group>
                ))}
              </Command.List>
            </Command>
          </div>
        </div>
      )}
    </>
  );
}

export default CommandPalette;
Enter fullscreen mode Exit fullscreen mode

Now create a full application with the command palette:

// src/AppWithCommandPalette.jsx
import React from 'react';
import CommandPalette from './CommandPalette';

function AppWithCommandPalette() {
  return (
    <div style={{ padding: '40px', minHeight: '100vh' }}>
      <h1>My Application</h1>
      <p>Press ⌘K (or Ctrl+K) to open the command palette</p>
      <CommandPalette />
    </div>
  );
}

export default AppWithCommandPalette;
Enter fullscreen mode Exit fullscreen mode

Update your App.jsx:

// src/App.jsx
import React from 'react';
import AppWithCommandPalette from './AppWithCommandPalette';
import './App.css';

function App() {
  return (
    <div className="App">
      <AppWithCommandPalette />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

This example demonstrates:

  • Keyboard shortcut (⌘K) to open menu
  • Searchable command items
  • Grouped commands
  • Keyboard shortcuts display
  • Action handlers
  • Modal overlay
  • Escape key to close
  • Click outside to close

Common Issues / Troubleshooting

  1. Menu not opening: Make sure you're handling the keyboard event correctly. Check that open state is being set properly.

  2. Items not filtering: Ensure Command.Input has onValueChange handler that updates search state. Items are automatically filtered based on their text content.

  3. Styling issues: cmdk is unstyled by default. Add your own CSS or inline styles. Use Command.List with maxHeight and overflow: auto for scrollable lists.

  4. Keyboard navigation not working: Make sure Command.Item components are properly nested inside Command.List. Check that no other elements are intercepting keyboard events.

  5. Accessibility issues: cmdk includes ARIA attributes by default. Ensure your custom styling doesn't break accessibility. Test with screen readers.

  6. Performance with many items: For large lists, consider virtualizing items or implementing pagination. cmdk handles filtering efficiently, but rendering many DOM elements can be slow.

Next Steps

Now that you have an understanding of cmdk:

  • Explore advanced filtering options
  • Learn about custom item rendering
  • Implement nested command groups
  • Add icons to command items
  • Create context-aware commands
  • Integrate with routing libraries
  • Check the official documentation: https://cmdk.paco.me/

Summary

You've successfully set up cmdk in your React application and created command menus with keyboard shortcuts, search functionality, and grouped commands. cmdk provides a fast, accessible, and composable solution for building command palettes in React applications.

SEO Keywords

cmdk
cmdk React
cmdk command menu
React command palette
cmdk installation
React ⌘K menu
cmdk tutorial
React command menu component
cmdk example
React command palette library
cmdk setup
React keyboard navigation
cmdk getting started
React searchable menu
cmdk advanced usage

Top comments (0)