# Command Palette

> ⌘K command palette with fuzzy search, groups, shortcut hints and full keyboard control.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/command-palette
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/command-palette.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/command-palette-demo.tsx"
"use client"

import * as React from "react"
import { Calculator, CalendarDays, CreditCard, FilePlus, FolderPlus, Moon, Search, Settings, Smile, User } from "lucide-react"

import { CommandMenu, CommandPalette, type CommandGroup } from "@/components/command-palette"

const groups: CommandGroup[] = [
  {
    heading: "Suggestions",
    items: [
      { id: "calendar", label: "Calendar", icon: CalendarDays, keywords: ["events", "schedule"] },
      { id: "emoji", label: "Search emoji", icon: Smile },
      { id: "calculator", label: "Calculator", icon: Calculator, keywords: ["math"] },
    ],
  },
  {
    heading: "Create",
    items: [
      { id: "file", label: "New file", icon: FilePlus, shortcut: ["⌘", "N"] },
      { id: "folder", label: "New folder", icon: FolderPlus, shortcut: ["⇧", "⌘", "N"] },
    ],
  },
  {
    heading: "Settings",
    items: [
      { id: "profile", label: "Profile", icon: User, shortcut: ["⌘", "P"] },
      { id: "billing", label: "Billing", icon: CreditCard, shortcut: ["⌘", "B"] },
      { id: "theme", label: "Toggle dark mode", icon: Moon, keywords: ["theme", "appearance"] },
      { id: "settings", label: "Settings", icon: Settings, shortcut: ["⌘", ","] },
    ],
  },
]

export default function CommandPaletteDemo() {
  const [open, setOpen] = React.useState(false)
  const [last, setLast] = React.useState<string | null>(null)

  return (
    <div className="flex w-full max-w-lg flex-col items-center gap-4">
      <button
        type="button"
        onClick={() => setOpen(true)}
        className="flex h-9 w-full max-w-xs items-center gap-2 rounded-lg border bg-background px-3 text-sm text-muted-foreground shadow-xs hover:bg-accent"
      >
        <Search className="size-4" />
        <span className="flex-1 text-left">Search…</span>
        <kbd className="rounded border bg-muted px-1.5 font-sans text-[11px]">⌘K</kbd>
      </button>
      <CommandMenu groups={groups} onSelect={(item) => setLast(item.label)} />
      <p className="h-5 text-xs text-muted-foreground" role="status">
        {last && `Selected: ${last}`}
      </p>
      <CommandPalette open={open} onOpenChange={setOpen} groups={groups} onSelect={(item) => setLast(item.label)} />
    </div>
  )
}
```

## Source

```tsx title="components/command-palette.tsx"
"use client"

import * as React from "react"
import { CornerDownLeft, Search } from "lucide-react"

import { cn } from "@/lib/utils"

export type CommandItem = {
  id: string
  label: string
  icon?: React.ComponentType<{ className?: string }>
  /** Keys shown on the right, e.g. ["⌘", "N"] */
  shortcut?: string[]
  /** Extra words that match the search */
  keywords?: string[]
  onSelect?: () => void
}

export type CommandGroup = { heading: string; items: CommandItem[] }

export interface CommandMenuProps extends Omit<React.ComponentProps<"div">, "onSelect"> {
  groups: CommandGroup[]
  placeholder?: string
  emptyText?: string
  /** Called after an item's own onSelect */
  onSelect?: (item: CommandItem) => void
  /** Focus the search field on mount */
  autoFocusSearch?: boolean
  showFooter?: boolean
}

function score(item: CommandItem, query: string) {
  if (!query) return 1
  const haystack = [item.label, ...(item.keywords ?? [])].join(" ").toLowerCase()
  if (item.label.toLowerCase().startsWith(query)) return 3
  if (haystack.includes(query)) return 2
  // Letters in order, e.g. "nwp" matches "New project"
  let i = 0
  for (const char of haystack) if (char === query[i]) i++
  return i === query.length ? 1 : 0
}

function Kbd({ children }: { children: React.ReactNode }) {
  return (
    <kbd className="inline-flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1 font-sans text-[11px] text-muted-foreground">
      {children}
    </kbd>
  )
}

export function CommandMenu({
  groups,
  placeholder = "Type a command or search…",
  emptyText = "No results found.",
  onSelect,
  autoFocusSearch = false,
  showFooter = true,
  className,
  ...props
}: CommandMenuProps) {
  const [query, setQuery] = React.useState("")
  const [active, setActive] = React.useState(0)
  const list = React.useRef<HTMLDivElement>(null)
  const input = React.useRef<HTMLInputElement>(null)
  const id = React.useId()

  const q = query.trim().toLowerCase()
  const filtered = groups
    .map((group) => ({
      ...group,
      items: group.items
        .map((item) => ({ item, score: score(item, q) }))
        .filter((entry) => entry.score > 0)
        .sort((a, b) => b.score - a.score)
        .map((entry) => entry.item),
    }))
    .filter((group) => group.items.length)
  const flat = filtered.flatMap((group) => group.items)

  React.useEffect(() => {
    if (autoFocusSearch) input.current?.focus()
  }, [autoFocusSearch])

  React.useEffect(() => {
    const el = list.current
    const item = el?.querySelector<HTMLElement>(`[data-index="${active}"]`)
    if (!el || !item) return
    if (item.offsetTop < el.scrollTop + 32) el.scrollTop = Math.max(0, item.offsetTop - 32)
    else if (item.offsetTop + item.offsetHeight > el.scrollTop + el.clientHeight)
      el.scrollTop = item.offsetTop + item.offsetHeight - el.clientHeight
  }, [active])

  const run = (item: CommandItem) => {
    item.onSelect?.()
    onSelect?.(item)
  }

  const onKeyDown = (event: React.KeyboardEvent) => {
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault()
      const delta = event.key === "ArrowDown" ? 1 : -1
      setActive((i) => (flat.length ? (i + delta + flat.length) % flat.length : 0))
    } else if (event.key === "Enter" && flat[active]) {
      event.preventDefault()
      run(flat[active])
    }
  }

  let index = -1
  return (
    <div
      onKeyDown={onKeyDown}
      className={cn("flex w-full max-w-lg flex-col overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg", className)}
      {...props}
    >
      <div className="flex items-center gap-2 border-b px-4">
        <Search className="size-4 shrink-0 text-muted-foreground" />
        <input
          ref={input}
          value={query}
          onChange={(event) => {
            setQuery(event.target.value)
            setActive(0)
          }}
          placeholder={placeholder}
          role="combobox"
          aria-expanded
          aria-controls={`${id}-list`}
          aria-activedescendant={flat[active] ? `${id}-${active}` : undefined}
          aria-label={placeholder}
          className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
        />
      </div>
      <div ref={list} id={`${id}-list`} role="listbox" className="relative max-h-80 overflow-y-auto p-2">
        {flat.length === 0 && <p className="py-10 text-center text-sm text-muted-foreground">{emptyText}</p>}
        {filtered.map((group) => (
          <div key={group.heading} role="group" aria-label={group.heading} className="pb-1">
            <p className="px-2 pt-2 pb-1.5 text-xs font-medium text-muted-foreground">{group.heading}</p>
            {group.items.map((item) => {
              index++
              const i = index
              const Icon = item.icon
              return (
                <div
                  key={item.id}
                  id={`${id}-${i}`}
                  data-index={i}
                  role="option"
                  aria-selected={i === active}
                  onPointerMove={() => setActive(i)}
                  onClick={() => run(item)}
                  className={cn(
                    "flex h-10 cursor-default items-center gap-3 rounded-lg px-2 text-sm",
                    i === active && "bg-accent text-accent-foreground"
                  )}
                >
                  {Icon && <Icon className="size-4 shrink-0 text-muted-foreground" />}
                  <span className="flex-1 truncate">{item.label}</span>
                  {item.shortcut && (
                    <span className="flex gap-1">
                      {item.shortcut.map((key) => (
                        <Kbd key={key}>{key}</Kbd>
                      ))}
                    </span>
                  )}
                </div>
              )
            })}
          </div>
        ))}
      </div>
      {showFooter && (
        <div className="flex items-center gap-4 border-t bg-muted/40 px-4 py-2 text-xs text-muted-foreground">
          <span className="flex items-center gap-1.5">
            <Kbd>↑</Kbd>
            <Kbd>↓</Kbd> navigate
          </span>
          <span className="flex items-center gap-1.5">
            <Kbd>
              <CornerDownLeft className="size-3" />
            </Kbd>
            select
          </span>
          <span className="ml-auto flex items-center gap-1.5">
            <Kbd>esc</Kbd> close
          </span>
        </div>
      )}
    </div>
  )
}

export interface CommandPaletteProps extends CommandMenuProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  /** Toggle with ⌘K / Ctrl+K */
  hotkey?: boolean
}

export function CommandPalette({ open, onOpenChange, hotkey = true, onSelect, ...props }: CommandPaletteProps) {
  const ref = React.useRef<HTMLDialogElement>(null)

  React.useEffect(() => {
    if (!hotkey) return
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
        event.preventDefault()
        onOpenChange(!open)
      }
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [hotkey, open, onOpenChange])

  React.useEffect(() => {
    const dialog = ref.current
    if (!dialog) return
    if (open && !dialog.open) dialog.showModal()
    else if (!open && dialog.open) dialog.close()
  }, [open])

  return (
    <dialog
      ref={ref}
      aria-label="Command palette"
      onCancel={(event) => {
        event.preventDefault()
        onOpenChange(false)
      }}
      onClick={(event) => event.target === event.currentTarget && onOpenChange(false)}
      className="mx-auto mt-[15vh] w-[calc(100%-2rem)] max-w-lg overflow-visible bg-transparent p-0 opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-150 open:opacity-100 starting:open:-translate-y-2 starting:open:opacity-0 backdrop:bg-black/40 backdrop:backdrop-blur-[2px]"
    >
      {/* Remount per opening, so the search starts empty */}
      {open && (
        <CommandMenu
          autoFocusSearch
          onSelect={(item) => {
            onSelect?.(item)
            onOpenChange(false)
          }}
          {...props}
        />
      )}
    </dialog>
  )
}
```
