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-commandBrings 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>
</>
)
}