shadimage

Command Palette

A reusable command dialog wrapper for grouped actions, app navigation, and searchable command menus.

registry/corr/components/command-palette.tsx

"use client"

import * as React from "react"
import { SearchIcon } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { Button } from "@/components/ui/button"
import {
  Command,
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"

export type CommandPaletteItem = {
  id: string
  label: string
  description?: string
  icon?: React.ReactNode
  keywords?: string[]
  onSelect?: () => void
}

export type CommandPaletteGroup = {
  id: string
  label: string
  items: CommandPaletteItem[]
}

export function CommandPalette({
  open,
  onOpenChange,
  groups,
  placeholder = "Type a command or search...",
  emptyLabel = "No results found.",
  trigger,
}: {
  open?: boolean
  onOpenChange?: (open: boolean) => void
  groups: CommandPaletteGroup[]
  placeholder?: string
  emptyLabel?: string
  trigger?: React.ReactNode
}) {
  const [internalOpen, setInternalOpen] = React.useState(false)
  const isControlled = typeof open === "boolean"
  const currentOpen = isControlled ? open : internalOpen
  const reduceMotion = useReducedMotion()

  function setOpen(nextOpen: boolean) {
    if (!isControlled) setInternalOpen(nextOpen)
    onOpenChange?.(nextOpen)
  }

  return (
    <>
      {trigger ? (
        <span onClick={() => setOpen(true)}>{trigger}</span>
      ) : (
        <Button type="button" variant="outline" onClick={() => setOpen(true)}>
          <SearchIcon className="size-4" />
          Open command palette
        </Button>
      )}
      <CommandDialog open={currentOpen} onOpenChange={setOpen}>
        <Command>
          <CommandInput placeholder={placeholder} />
          <CommandList>
            <CommandEmpty>{emptyLabel}</CommandEmpty>
            {groups.map((group) => (
              <CommandGroup key={group.id} heading={group.label}>
                <AnimatePresence initial={false}>
                  {group.items.map((item, index) => (
                    <motion.div
                      key={item.id}
                      initial={{
                        opacity: 0,
                        y: reduceMotion ? 0 : 4,
                      }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0 }}
                      transition={{
                        duration: reduceMotion ? 0 : 0.14,
                        delay: reduceMotion ? 0 : index * 0.015,
                      }}
                    >
                      <CommandItem
                        value={[
                          item.label,
                          item.description,
                          ...(item.keywords ?? []),
                        ]
                          .filter(Boolean)
                          .join(" ")}
                        onSelect={() => {
                          item.onSelect?.()
                          setOpen(false)
                        }}
                      >
                        {item.icon}
                        <span className="min-w-0">
                          <span className="block truncate">{item.label}</span>
                          {item.description ? (
                            <span className="block truncate text-xs text-muted-foreground">
                              {item.description}
                            </span>
                          ) : null}
                        </span>
                      </CommandItem>
                    </motion.div>
                  ))}
                </AnimatePresence>
              </CommandGroup>
            ))}
          </CommandList>
        </Command>
      </CommandDialog>
    </>
  )
}
Interaction
modaldropdown

DependenciesBundle weight · medium

icons
lucide-react
animation
motion