Command

Fast, composable, unstyled command menu for React.

The Command component is a fast, composable command menu that can be triggered with Command+J (⌘J). It features search functionality, keyboard navigation, and grouping. Built from scratch using React and native HTML elements. No dependencies on any UI library.

Code

TypeScript: Copy this code into components/ui/command.tsx:

tsx
"use client"

import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"
import { Input } from "./input"

interface CommandContextValue {
  open: boolean
  setOpen: (open: boolean) => void
  searchValue: string
  setSearchValue: (value: string) => void
  selectedIndex: number
  setSelectedIndex: (index: number) => void
  filteredItems: CommandItem[]
  items: CommandItem[]
}

const CommandContext = React.createContext<CommandContextValue | undefined>(undefined)

const useCommand = () => {
  const context = React.useContext(CommandContext)
  if (!context) {
    throw new Error("Command components must be used within Command")
  }
  return context
}

export interface CommandItem {
  id: string
  label: string
  keywords?: string[]
  onSelect?: () => void
  icon?: React.ReactNode
  group?: string
}

interface CommandProps {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  items: CommandItem[]
  children: React.ReactNode
  className?: string
}

const Command = React.forwardRef<HTMLDivElement, CommandProps>(
  ({ open: controlledOpen, defaultOpen = false, onOpenChange, items, children, className, ...props }, ref) => {
    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
    const [searchValue, setSearchValue] = React.useState("")
    const [selectedIndex, setSelectedIndex] = React.useState(0)
    const listRef = React.useRef<HTMLDivElement>(null)

    const isControlled = controlledOpen !== undefined
    const open = isControlled ? controlledOpen : uncontrolledOpen

    const filteredItems = React.useMemo(() => {
      if (!searchValue) return items
      const search = searchValue.toLowerCase()
      return items.filter(item => {
        const labelMatch = item.label.toLowerCase().includes(search)
        const keywordMatch = item.keywords?.some(kw => kw.toLowerCase().includes(search))
        return labelMatch || keywordMatch
      })
    }, [items, searchValue])

    const handleOpenChange = React.useCallback((newOpen: boolean) => {
      if (!isControlled) {
        setUncontrolledOpen(newOpen)
      }
      onOpenChange?.(newOpen)
      if (!newOpen) {
        setSearchValue("")
        setSelectedIndex(0)
      }
    }, [isControlled, onOpenChange])

    React.useEffect(() => {
      const handleKeyDown = (e: KeyboardEvent) => {
        if ((e.metaKey || e.ctrlKey) && e.key === "j") {
          e.preventDefault()
          handleOpenChange(!open)
        }
        if (e.key === "Escape" && open) {
          handleOpenChange(false)
        }
      }
      window.addEventListener("keydown", handleKeyDown)
      return () => window.removeEventListener("keydown", handleKeyDown)
    }, [open, handleOpenChange])

    React.useEffect(() => {
      if (!open) return

      const handleKeyDown = (e: KeyboardEvent) => {
        if (e.key === "ArrowDown") {
          e.preventDefault()
          setSelectedIndex(prev => 
            prev < filteredItems.length - 1 ? prev + 1 : prev
          )
        } else if (e.key === "ArrowUp") {
          e.preventDefault()
          setSelectedIndex(prev => prev > 0 ? prev - 1 : 0)
        } else if (e.key === "Enter" && selectedIndex >= 0 && filteredItems[selectedIndex]) {
          e.preventDefault()
          filteredItems[selectedIndex].onSelect?.()
          handleOpenChange(false)
        }
      }

      window.addEventListener("keydown", handleKeyDown)
      return () => window.removeEventListener("keydown", handleKeyDown)
    }, [open, filteredItems, selectedIndex, handleOpenChange])

    React.useEffect(() => {
      if (selectedIndex >= 0 && listRef.current) {
        const selectedElement = listRef.current.children[selectedIndex] as HTMLElement
        if (selectedElement) {
          selectedElement.scrollIntoView({ block: "nearest" })
        }
      }
    }, [selectedIndex])

    React.useEffect(() => {
      setSelectedIndex(0)
    }, [searchValue])

    React.useEffect(() => {
      if (open) {
        document.body.style.overflow = "hidden"
      } else {
        document.body.style.overflow = ""
      }
      return () => {
        document.body.style.overflow = ""
      }
    }, [open])

    return (
      <CommandContext.Provider value={{
        open,
        setOpen: handleOpenChange,
        searchValue,
        setSearchValue,
        selectedIndex,
        setSelectedIndex,
        filteredItems,
        items,
      }}>
        <div ref={ref} className={cn("relative", className)} {...props}>
          {children}
        </div>
      </CommandContext.Provider>
    )
  }
)
Command.displayName = "Command"

interface CommandDialogProps extends React.HTMLAttributes<HTMLDivElement> {}

const CommandDialog = React.forwardRef<HTMLDivElement, CommandDialogProps>(
  ({ className, children, ...props }, ref) => {
    const { open, setOpen } = useCommand()

    if (!open) return null

    const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
      if (e.target === e.currentTarget) {
        setOpen(false)
      }
    }

    const content = (
      <div
        className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
        onClick={handleBackdropClick}
      >
        <div className="fixed inset-0 bg-black/50" />
        <div
          ref={ref}
          className={cn(
            "relative z-50 w-full max-w-lg rounded-lg border-2 border-foreground bg-background neobrutalism-shadow overflow-hidden",
            className
          )}
          onClick={(e) => e.stopPropagation()}
          {...props}
        >
          {children}
        </div>
      </div>
    )

    return ReactDOM.createPortal(content, document.body)
  }
)
CommandDialog.displayName = "CommandDialog"

interface CommandInputProps extends React.InputHTMLAttributes<HTMLInputElement> {}

const CommandInput = React.forwardRef<HTMLInputElement, CommandInputProps>(
  ({ className, ...props }, ref) => {
    const { searchValue, setSearchValue } = useCommand()

    return (
      <div className="p-2 border-b-2 border-foreground">
        <Input
          ref={ref}
          value={searchValue}
          onChange={(e) => setSearchValue(e.target.value)}
          placeholder="Type a command or search..."
          className={className}
          {...props}
        />
      </div>
    )
  }
)
CommandInput.displayName = "CommandInput"

interface CommandListProps extends React.HTMLAttributes<HTMLDivElement> {}

const CommandList = React.forwardRef<HTMLDivElement, CommandListProps>(
  ({ className, ...props }, ref) => {
    const { filteredItems, selectedIndex, setSelectedIndex } = useCommand()
    const listRef = React.useRef<HTMLDivElement>(null)

    React.useImperativeHandle(ref, () => listRef.current as HTMLDivElement)

    if (filteredItems.length === 0) {
      return (
        <div className="p-8 text-sm text-muted-foreground text-center">
          No results found.
        </div>
      )
    }

    const groupedItems = React.useMemo(() => {
      const groups: Record<string, typeof filteredItems> = {}
      filteredItems.forEach(item => {
        const group = item.group || "Other"
        if (!groups[group]) {
          groups[group] = []
        }
        groups[group].push(item)
      })
      return groups
    }, [filteredItems])

    return (
      <div
        ref={listRef}
        className={cn("max-h-[300px] overflow-y-auto p-2", className)}
        {...props}
      >
        {Object.entries(groupedItems).map(([groupName, groupItems]) => (
          <div key={groupName} className="mb-4 last:mb-0">
            {groupName !== "Other" && (
              <div className="px-2 py-1.5 text-xs font-bold text-muted-foreground uppercase">
                {groupName}
              </div>
            )}
            {groupItems.map((item, index) => {
              const globalIndex = filteredItems.indexOf(item)
              const isHighlighted = selectedIndex === globalIndex

              return (
                <button
                  key={item.id}
                  type="button"
                  onClick={() => {
                    item.onSelect?.()
                    setOpen(false)
                  }}
                  className={cn(
                    "w-full flex items-center gap-2 px-2 py-1.5 text-sm font-bold rounded transition-colors text-left",
                    isHighlighted
                      ? "bg-accent text-accent-foreground"
                      : "hover:bg-muted",
                    className
                  )}
                  onMouseEnter={() => setSelectedIndex(globalIndex)}
                >
                  {item.icon && <span className="flex-shrink-0">{item.icon}</span>}
                  <span className="flex-1">{item.label}</span>
                </button>
              )
            })}
          </div>
        ))}
      </div>
    )
  }
)
CommandList.displayName = "CommandList"

export { Command, CommandDialog, CommandInput, CommandList }

JavaScript: Copy this code into components/ui/command.jsx:

jsx
"use client"

import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"
import { Input } from "./input"

const CommandContext = React.createContext(undefined)

const useCommand = () => {
  const context = React.useContext(CommandContext)
  if (!context) {
    throw new Error("Command components must be used within Command")
  }
  return context
}

const Command = React.forwardRef(
  ({ open: controlledOpen, defaultOpen = false, onOpenChange, items, children, className, ...props }, ref) => {
    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
    const [searchValue, setSearchValue] = React.useState("")
    const [selectedIndex, setSelectedIndex] = React.useState(0)
    const listRef = React.useRef(null)

    const isControlled = controlledOpen !== undefined
    const open = isControlled ? controlledOpen : uncontrolledOpen

    const filteredItems = React.useMemo(() => {
      if (!searchValue) return items
      const search = searchValue.toLowerCase()
      return items.filter(item => {
        const labelMatch = item.label.toLowerCase().includes(search)
        const keywordMatch = item.keywords?.some(kw => kw.toLowerCase().includes(search))
        return labelMatch || keywordMatch
      })
    }, [items, searchValue])

    const handleOpenChange = React.useCallback((newOpen) => {
      if (!isControlled) {
        setUncontrolledOpen(newOpen)
      }
      onOpenChange?.(newOpen)
      if (!newOpen) {
        setSearchValue("")
        setSelectedIndex(0)
      }
    }, [isControlled, onOpenChange])

    React.useEffect(() => {
      const handleKeyDown = (e) => {
        if ((e.metaKey || e.ctrlKey) && e.key === "j") {
          e.preventDefault()
          handleOpenChange(!open)
        }
        if (e.key === "Escape" && open) {
          handleOpenChange(false)
        }
      }
      window.addEventListener("keydown", handleKeyDown)
      return () => window.removeEventListener("keydown", handleKeyDown)
    }, [open, handleOpenChange])

    React.useEffect(() => {
      if (!open) return

      const handleKeyDown = (e) => {
        if (e.key === "ArrowDown") {
          e.preventDefault()
          setSelectedIndex(prev => 
            prev < filteredItems.length - 1 ? prev + 1 : prev
          )
        } else if (e.key === "ArrowUp") {
          e.preventDefault()
          setSelectedIndex(prev => prev > 0 ? prev - 1 : 0)
        } else if (e.key === "Enter" && selectedIndex >= 0 && filteredItems[selectedIndex]) {
          e.preventDefault()
          filteredItems[selectedIndex].onSelect?.()
          handleOpenChange(false)
        }
      }

      window.addEventListener("keydown", handleKeyDown)
      return () => window.removeEventListener("keydown", handleKeyDown)
    }, [open, filteredItems, selectedIndex, handleOpenChange])

    React.useEffect(() => {
      if (selectedIndex >= 0 && listRef.current) {
        const selectedElement = listRef.current.children[selectedIndex]
        if (selectedElement) {
          selectedElement.scrollIntoView({ block: "nearest" })
        }
      }
    }, [selectedIndex])

    React.useEffect(() => {
      setSelectedIndex(0)
    }, [searchValue])

    React.useEffect(() => {
      if (open) {
        document.body.style.overflow = "hidden"
      } else {
        document.body.style.overflow = ""
      }
      return () => {
        document.body.style.overflow = ""
      }
    }, [open])

    return (
      <CommandContext.Provider value={{
        open,
        setOpen: handleOpenChange,
        searchValue,
        setSearchValue,
        selectedIndex,
        setSelectedIndex,
        filteredItems,
        items,
      }}>
        <div ref={ref} className={cn("relative", className)} {...props}>
          {children}
        </div>
      </CommandContext.Provider>
    )
  }
)
Command.displayName = "Command"

const CommandDialog = React.forwardRef(
  ({ className, children, ...props }, ref) => {
    const { open, setOpen } = useCommand()

    if (!open) return null

    const handleBackdropClick = (e) => {
      if (e.target === e.currentTarget) {
        setOpen(false)
      }
    }

    const content = (
      <div
        className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
        onClick={handleBackdropClick}
      >
        <div className="fixed inset-0 bg-black/50" />
        <div
          ref={ref}
          className={cn(
            "relative z-50 w-full max-w-lg rounded-lg border-2 border-foreground bg-background neobrutalism-shadow overflow-hidden",
            className
          )}
          onClick={(e) => e.stopPropagation()}
          {...props}
        >
          {children}
        </div>
      </div>
    )

    return ReactDOM.createPortal(content, document.body)
  }
)
CommandDialog.displayName = "CommandDialog"

const CommandInput = React.forwardRef(
  ({ className, ...props }, ref) => {
    const { searchValue, setSearchValue } = useCommand()

    return (
      <div className="p-2 border-b-2 border-foreground">
        <Input
          ref={ref}
          value={searchValue}
          onChange={(e) => setSearchValue(e.target.value)}
          placeholder="Type a command or search..."
          className={className}
          {...props}
        />
      </div>
    )
  }
)
CommandInput.displayName = "CommandInput"

const CommandList = React.forwardRef(
  ({ className, ...props }, ref) => {
    const { filteredItems, selectedIndex, setSelectedIndex, setOpen } = useCommand()
    const listRef = React.useRef(null)

    React.useImperativeHandle(ref, () => listRef.current)

    if (filteredItems.length === 0) {
      return (
        <div className="p-8 text-sm text-muted-foreground text-center">
          No results found.
        </div>
      )
    }

    const groupedItems = React.useMemo(() => {
      const groups = {}
      filteredItems.forEach(item => {
        const group = item.group || "Other"
        if (!groups[group]) {
          groups[group] = []
        }
        groups[group].push(item)
      })
      return groups
    }, [filteredItems])

    return (
      <div
        ref={listRef}
        className={cn("max-h-[300px] overflow-y-auto p-2", className)}
        {...props}
      >
        {Object.entries(groupedItems).map(([groupName, groupItems]) => (
          <div key={groupName} className="mb-4 last:mb-0">
            {groupName !== "Other" && (
              <div className="px-2 py-1.5 text-xs font-bold text-muted-foreground uppercase">
                {groupName}
              </div>
            )}
            {groupItems.map((item, index) => {
              const globalIndex = filteredItems.indexOf(item)
              const isHighlighted = selectedIndex === globalIndex

              return (
                <button
                  key={item.id}
                  type="button"
                  onClick={() => {
                    item.onSelect?.()
                    setOpen(false)
                  }}
                  className={cn(
                    "w-full flex items-center gap-2 px-2 py-1.5 text-sm font-bold rounded transition-colors text-left",
                    isHighlighted
                      ? "bg-accent text-accent-foreground"
                      : "hover:bg-muted",
                    className
                  )}
                  onMouseEnter={() => setSelectedIndex(globalIndex)}
                >
                  {item.icon && <span className="flex-shrink-0">{item.icon}</span>}
                  <span className="flex-1">{item.label}</span>
                </button>
              )
            })}
          </div>
        ))}
      </div>
    )
  }
)
CommandList.displayName = "CommandList"

export { Command, CommandDialog, CommandInput, CommandList }

Usage

TypeScript:

tsx
import {
  Command,
  CommandDialog,
  CommandInput,
  CommandList,
} from "@/components/ui/command"

const items = [
  { id: "1", label: "Calendar", group: "Applications", onSelect: () => console.log("Calendar") },
  { id: "2", label: "Search Emoji", group: "Applications", onSelect: () => console.log("Search Emoji") },
  { id: "3", label: "Calculator", group: "Applications", onSelect: () => console.log("Calculator") },
]

function MyComponent() {
  const [open, setOpen] = React.useState(false)

  return (
    <Command items={items} open={open} onOpenChange={setOpen}>
      <CommandDialog>
        <CommandInput />
        <CommandList />
      </CommandDialog>
    </Command>
  )
}

JavaScript:

jsx
import {
  Command,
  CommandDialog,
  CommandInput,
  CommandList,
} from "@/components/ui/command"

const items = [
  { id: "1", label: "Calendar", group: "Applications", onSelect: () => console.log("Calendar") },
  { id: "2", label: "Search Emoji", group: "Applications", onSelect: () => console.log("Search Emoji") },
  { id: "3", label: "Calculator", group: "Applications", onSelect: () => console.log("Calculator") },
]

function MyComponent() {
  const [open, setOpen] = React.useState(false)

  return (
    <Command items={items} open={open} onOpenChange={setOpen}>
      <CommandDialog>
        <CommandInput />
        <CommandList />
      </CommandDialog>
    </Command>
  )
}

Press + J to open the command menu.

Examples

Press + J to open