Search

Jump to any page.

Navigation

Search Command

Search field that opens a ⌘K palette for jumping to any page.

Search

Jump to any page.

pnpm dlx shadcn@latest add @paragon/search-command
Brings in@paragon/button@paragon/command@paragon/kbdlucide-reactcn
registry/paragon/blocks/search-command/search-command.tsx
"use client"

import * as React from "react"
import { useRouter } from "next/navigation"
import { CornerDownLeft, FileText, Search } from "lucide-react"
import { cn } from "cn"

import { Button } from "@/registry/paragon/ui/button"
import {
  Command,
  CommandDialog,
  CommandEmpty,
  CommandFooter,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/registry/paragon/ui/command"
import { Kbd, KbdGroup } from "@/registry/paragon/ui/kbd"

export type SearchCommandItem = {
  title: string
  href: string
  group: string
  description?: string
  icon?: React.ComponentType<{ className?: string }>
  keywords?: string[]
}

function isTyping(target: EventTarget | null) {
  return (
    target instanceof HTMLElement &&
    (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))
  )
}

/**
 * A search field that opens a command palette. ⌘K or Ctrl+K from anywhere,
 * or / when not typing. Choosing an item navigates to its href.
 */
export function SearchCommand({
  items,
  placeholder = "Search…",
  className,
}: {
  items: SearchCommandItem[]
  placeholder?: string
  className?: string
}) {
  const router = useRouter()
  const [open, setOpen] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const groups = React.useMemo(() => [...new Set(items.map((item) => item.group))], [items])

  React.useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      const shortcut = event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)
      const slash = event.key === "/" && !isTyping(event.target)
      if (shortcut || slash) {
        event.preventDefault()
        setOpen((value) => !value)
      }
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [])

  return (
    <>
      <Button
        variant="outline"
        size="sm"
        onClick={() => setOpen(true)}
        className={cn(
          "w-9 justify-start gap-2 bg-card/60 px-0 font-normal text-muted-foreground hover:text-foreground sm:w-64 sm:px-3 [&>[data-slot=button-label]]:w-full [&>[data-slot=button-label]]:justify-center sm:[&>[data-slot=button-label]]:justify-start",
          className
        )}
      >
        <Search />
        <span className="sr-only sm:not-sr-only sm:flex-1 sm:text-start">{placeholder}</span>
        <KbdGroup className="hidden sm:inline-flex">
          <Kbd>⌘</Kbd>
          <Kbd>K</Kbd>
        </KbdGroup>
      </Button>
      <CommandDialog
        open={open}
        onOpenChange={(value) => {
          setOpen(value)
          if (!value) setQuery("")
        }}
        title="Search"
        description="Jump to any page."
      >
        <Command loop>
          <CommandInput placeholder={placeholder} value={query} onValueChange={setQuery} />
          <CommandList>
            <CommandEmpty>Nothing matches “{query}”.</CommandEmpty>
            {groups.map((group) => (
              <CommandGroup key={group} heading={group}>
                {items
                  .filter((item) => item.group === group)
                  .map((item) => {
                    const Icon = item.icon ?? FileText
                    return (
                      <CommandItem
                        key={item.href}
                        value={`${item.title} ${item.group}`}
                        keywords={item.keywords}
                        onSelect={() => {
                          setOpen(false)
                          setQuery("")
                          router.push(item.href)
                        }}
                      >
                        <Icon />
                        <span className="shrink-0">{item.title}</span>
                        {item.description ? (
                          <span className="min-w-0 truncate text-[0.8125rem] text-muted-foreground">
                            {item.description}
                          </span>
                        ) : null}
                      </CommandItem>
                    )
                  })}
              </CommandGroup>
            ))}
          </CommandList>
          <CommandFooter>
            <span>
              <KbdGroup>
                <Kbd>↑</Kbd>
                <Kbd>↓</Kbd>
              </KbdGroup>
              Navigate
            </span>
            <span>
              <Kbd>
                <CornerDownLeft />
              </Kbd>
              Open
            </span>
            <span className="ms-auto">
              <Kbd>esc</Kbd>
              Close
            </span>
          </CommandFooter>
        </Command>
      </CommandDialog>
    </>
  )
}