Search Dialog

Add Cmd+K command-palette search to your app with Search Dialog, a React component with keyboard shortcuts and match highlighting. Install via the shadcn CLI.

Report a bug

What it is & when to use

Search Dialog is a command-palette search built on the Command component. A trigger button and a Cmd/Ctrl+K shortcut open a modal where users type to filter items, with matching text highlighted and results sorted by title. Supply an async docsSearch function to enable Components, Docs, and Everything scope tabs with loading and empty states, and onSelect callbacks handle navigation. Use it for site-wide search, docs lookup, or in-app navigation.

Browse all Learning

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.com/r/search-dialog.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { Search } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "../button/button"; import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "../command/command"; export type SearchItem = { description?: string; href?: string; id: string; keywords?: string; snippet?: string; title: string; }; type SearchScope = "components" | "docs" | "everything"; function useKeyboardShortcut(callback: () => void) { useEffect(() => { const down = (event: KeyboardEvent) => { if ( (event.key === "k" || event.key === "K") && (event.metaKey || event.ctrlKey) ) { const target = event.target as HTMLElement | null; if ( target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) ) { return; } event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); callback(); } }; window.addEventListener("keydown", down, { capture: true, passive: false }); return () => { window.removeEventListener("keydown", down, { capture: true }); }; }, [callback]); } type SearchDialogProps = { buttonText?: string; buttonTextMobile?: string; docsEmptyText?: string; docsGroupHeading?: string; docsSearch?: (query: string) => Promise<SearchItem[]>; emptyText?: string; enableKeyboardShortcut?: boolean; groupHeading?: string; items: SearchItem[]; minDocsSearchLength?: number; onDocsSelect?: (item: SearchItem) => void; onSelect: (item: SearchItem) => void; scopeLabels?: Partial<Record<SearchScope, string>>; searchPlaceholder?: string; }; const DEFAULT_SCOPE_LABELS: Record<SearchScope, string> = { components: "Components", docs: "Docs", everything: "Everything", }; function getItemValue(item: SearchItem) { return [ item.title, item.description, item.snippet, item.keywords, item.href, item.id, ] .filter(Boolean) .join(" "); } function HighlightedText({ query, text }: { query: string; text: string }) { const trimmedQuery = query.trim(); if (!trimmedQuery) { return text; } const index = text.toLowerCase().indexOf(trimmedQuery.toLowerCase()); if (index === -1) { return text; } return ( <> {text.slice(0, index)} <mark className="rounded bg-primary/15 px-0.5 text-foreground"> {text.slice(index, index + trimmedQuery.length)} </mark> {text.slice(index + trimmedQuery.length)} </> ); } function ScopeTabs({ getTabId, labels, onScopeChange, panelId, scope, }: { getTabId: (s: SearchScope) => string; labels: Record<SearchScope, string>; onScopeChange: (scope: SearchScope) => void; panelId: string; scope: SearchScope; }) { const scopes: SearchScope[] = ["components", "docs", "everything"]; return ( <div aria-label="Search scope" className="grid grid-cols-3 gap-1 border-b p-1" role="tablist" > {scopes.map((nextScope) => ( <button aria-controls={panelId} aria-selected={scope === nextScope} className={cn( "h-8 rounded-sm px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground", scope === nextScope && "bg-accent text-accent-foreground", )} id={getTabId(nextScope)} key={nextScope} onClick={() => { onScopeChange(nextScope); }} role="tab" type="button" > {labels[nextScope]} </button> ))} </div> ); } function SearchResultContent({ item, query, }: { item: SearchItem; query: string; }) { return ( <div className="flex min-w-0 flex-col"> <span className="truncate font-medium">{item.title}</span> {item.snippet ? ( <span className="line-clamp-2 text-xs text-muted-foreground"> <HighlightedText query={query} text={item.snippet} /> </span> ) : item.description ? ( <span className="line-clamp-2 text-xs text-muted-foreground"> {item.description} </span> ) : null} </div> ); } function SearchTriggerButton({ buttonText, buttonTextMobile, onOpen, }: { buttonText?: string; buttonTextMobile?: string; onOpen: () => void; }) { return ( <Button className={cn( "relative h-9 w-full justify-start text-sm text-muted-foreground sm:pr-12 md:w-40 lg:w-64", )} onClick={onOpen} variant="outline" > <Search className="mr-2 size-4" /> <span className="hidden lg:inline-flex">{buttonText ?? "Search..."}</span> <span className="inline-flex lg:hidden"> {buttonTextMobile ?? "Search..."} </span> <kbd className="pointer-events-none absolute right-1.5 top-1.5 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex"> <span className="text-xs"></span>K </kbd> </Button> ); } function ComponentResultsGroup({ groupHeading, hasDocumentationSearch, items, labels, onSelect, query, }: { groupHeading?: string; hasDocumentationSearch: boolean; items: SearchItem[]; labels: Record<SearchScope, string>; onSelect: (item: SearchItem) => void; query: string; }) { return ( <CommandGroup heading={ groupHeading ?? (hasDocumentationSearch ? labels.components : undefined) } > {items.map((item) => ( <CommandItem key={item.id} onSelect={() => { onSelect(item); }} value={getItemValue(item)} > <SearchResultContent item={item} query={query} /> </CommandItem> ))} </CommandGroup> ); } function DocumentationStatusItem({ documentationSearchLength, trimmedQuery, }: { documentationSearchLength: number; trimmedQuery: string; }) { return ( <CommandItem disabled value={`${trimmedQuery} search-docs-min-length`}> <span className="text-sm text-muted-foreground"> Type at least {documentationSearchLength} characters to search docs. </span> </CommandItem> ); } function DocumentationLoadingItem({ trimmedQuery }: { trimmedQuery: string }) { return ( <CommandItem disabled value={`${trimmedQuery} searching-docs`}> <span className="text-sm text-muted-foreground">Searching docs...</span> </CommandItem> ); } function DocumentationResultsGroup({ docsGroupHeading, documentationItems, documentationLoading, documentationSearchLength, onSelect, query, scope, trimmedQuery, }: { docsGroupHeading?: string; documentationItems: SearchItem[]; documentationLoading: boolean; documentationSearchLength: number; onSelect: (item: SearchItem) => void; query: string; scope: SearchScope; trimmedQuery: string; }) { const showMinimumLengthPrompt = scope === "docs" && trimmedQuery.length < documentationSearchLength; const showDocumentationItems = !documentationLoading && trimmedQuery.length >= documentationSearchLength; return ( <CommandGroup heading={docsGroupHeading ?? "Docs"}> {showMinimumLengthPrompt ? ( <DocumentationStatusItem documentationSearchLength={documentationSearchLength} trimmedQuery={trimmedQuery} /> ) : null} {documentationLoading ? ( <DocumentationLoadingItem trimmedQuery={trimmedQuery} /> ) : null} {showDocumentationItems ? documentationItems.map((item) => ( <CommandItem key={item.id} onSelect={() => { onSelect(item); }} value={getItemValue(item)} > <SearchResultContent item={item} query={query} /> </CommandItem> )) : null} </CommandGroup> ); } function SearchDialogList({ activeTabId, currentEmptyText, docsGroupHeading, documentationItems, documentationLoading, documentationSearchLength, groupHeading, hasDocumentationSearch, labels, onComponentSelect, onDocumentationSelect, panelId, query, scope, showComponents, showDocumentation, sortedItems, trimmedQuery, }: { activeTabId?: string; currentEmptyText: string; docsGroupHeading?: string; documentationItems: SearchItem[]; documentationLoading: boolean; documentationSearchLength: number; groupHeading?: string; hasDocumentationSearch: boolean; labels: Record<SearchScope, string>; onComponentSelect: (item: SearchItem) => void; onDocumentationSelect: (item: SearchItem) => void; panelId?: string; query: string; scope: SearchScope; showComponents: boolean; showDocumentation: boolean; sortedItems: SearchItem[]; trimmedQuery: string; }) { return ( <CommandList aria-labelledby={activeTabId} className="max-h-[420px]" id={panelId} role={activeTabId === undefined ? undefined : "tabpanel"} > <CommandEmpty>{currentEmptyText}</CommandEmpty> {showComponents ? ( <ComponentResultsGroup groupHeading={groupHeading} hasDocumentationSearch={hasDocumentationSearch} items={sortedItems} labels={labels} onSelect={onComponentSelect} query={query} /> ) : null} {showDocumentation ? ( <DocumentationResultsGroup docsGroupHeading={docsGroupHeading} documentationItems={documentationItems} documentationLoading={documentationLoading} documentationSearchLength={documentationSearchLength} onSelect={onDocumentationSelect} query={query} scope={scope} trimmedQuery={trimmedQuery} /> ) : null} </CommandList> ); } type DocumentationSearchOptions = { docsSearch?: (query: string) => Promise<SearchItem[]>; minDocsSearchLength?: number; }; function useDocumentationSearch({ docsSearch, minDocsSearchLength, }: DocumentationSearchOptions) { const [documentationItems, setDocumentationItems] = useState<SearchItem[]>( [], ); const [documentationLoading, setDocumentationLoading] = useState(false); const activeDocumentationRequest = useRef(0); const documentationSearchLength = minDocsSearchLength ?? 2; const runDocumentationSearch = useCallback( (nextQuery: string, nextScope: SearchScope) => { const nextTrimmedQuery = nextQuery.trim(); const nextRequest = activeDocumentationRequest.current + 1; activeDocumentationRequest.current = nextRequest; if ( !docsSearch || nextScope === "components" || nextTrimmedQuery.length < documentationSearchLength ) { setDocumentationItems([]); setDocumentationLoading(false); return; } setDocumentationLoading(true); docsSearch(nextTrimmedQuery) .then((results) => { if (activeDocumentationRequest.current === nextRequest) { setDocumentationItems(results); } }) .catch(() => { if (activeDocumentationRequest.current === nextRequest) { setDocumentationItems([]); } }) .finally(() => { if (activeDocumentationRequest.current === nextRequest) { setDocumentationLoading(false); } }); }, [docsSearch, documentationSearchLength], ); return { documentationItems, documentationLoading, documentationSearchLength, hasDocumentationSearch: docsSearch !== undefined, runDocumentationSearch, }; } type SearchDialogHandlersOptions = { enableKeyboardShortcut?: boolean; onDocsSelect?: (item: SearchItem) => void; onSelect: (item: SearchItem) => void; runDocumentationSearch: (query: string, scope: SearchScope) => void; }; function useSearchDialogHandlers({ enableKeyboardShortcut, onDocsSelect, onSelect, runDocumentationSearch, }: SearchDialogHandlersOptions) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [scope, setScope] = useState<SearchScope>("components"); const toggleOpen = useCallback(() => { if (enableKeyboardShortcut ?? true) { setOpen((previous) => !previous); } }, [enableKeyboardShortcut]); useKeyboardShortcut(toggleOpen); const handleQueryChange = useCallback( (nextQuery: string) => { setQuery(nextQuery); runDocumentationSearch(nextQuery, scope); }, [runDocumentationSearch, scope], ); const handleScopeChange = useCallback( (nextScope: SearchScope) => { setScope(nextScope); runDocumentationSearch(query, nextScope); }, [query, runDocumentationSearch], ); const handleOpenChange = useCallback( (nextOpen: boolean) => { setOpen(nextOpen); if (nextOpen) { runDocumentationSearch(query, scope); } }, [query, runDocumentationSearch, scope], ); const handleComponentSelect = useCallback( (item: SearchItem) => { setOpen(false); onSelect(item); }, [onSelect], ); const handleDocumentationSelect = useCallback( (item: SearchItem) => { setOpen(false); (onDocsSelect ?? onSelect)(item); }, [onDocsSelect, onSelect], ); return { handleComponentSelect, handleDocumentationSelect, handleOpenChange, handleQueryChange, handleScopeChange, open, query, scope, }; } function getCurrentEmptyText({ docsEmptyText, documentationSearchLength, emptyText, scope, trimmedQuery, }: { docsEmptyText?: string; documentationSearchLength: number; emptyText?: string; scope: SearchScope; trimmedQuery: string; }) { if (scope === "docs" && trimmedQuery.length < documentationSearchLength) { return `Type at least ${documentationSearchLength} characters to search docs.`; } if (scope === "docs") { return docsEmptyText ?? "No docs found."; } return emptyText ?? "No results found."; } type SearchDialogViewProps = Pick< SearchDialogProps, | "buttonText" | "buttonTextMobile" | "docsGroupHeading" | "groupHeading" | "searchPlaceholder" > & { currentEmptyText: string; documentationSearch: ReturnType<typeof useDocumentationSearch>; handlers: ReturnType<typeof useSearchDialogHandlers>; labels: Record<SearchScope, string>; showDocumentation: boolean; sortedItems: SearchItem[]; trimmedQuery: string; }; function SearchDialogView({ buttonText, buttonTextMobile, currentEmptyText, docsGroupHeading, documentationSearch, groupHeading, handlers, labels, searchPlaceholder, showDocumentation, sortedItems, trimmedQuery, }: SearchDialogViewProps) { const baseId = useId(); const getTabId = (s: SearchScope) => `${baseId}-tab-${s}`; const panelId = `${baseId}-panel`; return ( <> <SearchTriggerButton buttonText={buttonText} buttonTextMobile={buttonTextMobile} onOpen={() => { handlers.handleOpenChange(true); }} /> <CommandDialog onOpenChange={handlers.handleOpenChange} open={handlers.open} > <CommandInput onValueChange={handlers.handleQueryChange} placeholder={searchPlaceholder ?? "Search..."} value={handlers.query} /> {documentationSearch.hasDocumentationSearch ? ( <ScopeTabs getTabId={getTabId} labels={labels} onScopeChange={handlers.handleScopeChange} panelId={panelId} scope={handlers.scope} /> ) : null} <SearchDialogList activeTabId={ documentationSearch.hasDocumentationSearch ? getTabId(handlers.scope) : undefined } currentEmptyText={currentEmptyText} docsGroupHeading={docsGroupHeading} documentationItems={documentationSearch.documentationItems} documentationLoading={documentationSearch.documentationLoading} documentationSearchLength={ documentationSearch.documentationSearchLength } groupHeading={groupHeading} hasDocumentationSearch={documentationSearch.hasDocumentationSearch} labels={labels} onComponentSelect={handlers.handleComponentSelect} onDocumentationSelect={handlers.handleDocumentationSelect} panelId={ documentationSearch.hasDocumentationSearch ? panelId : undefined } query={handlers.query} scope={handlers.scope} showComponents={handlers.scope !== "docs"} showDocumentation={showDocumentation} sortedItems={sortedItems} trimmedQuery={trimmedQuery} /> </CommandDialog> </> ); } export function SearchDialog({ buttonText, buttonTextMobile, docsEmptyText, docsGroupHeading, docsSearch, emptyText, enableKeyboardShortcut, groupHeading, items, minDocsSearchLength, onDocsSelect, onSelect, scopeLabels, searchPlaceholder, }: SearchDialogProps) { const documentationSearch = useDocumentationSearch({ docsSearch, minDocsSearchLength, }); const handlers = useSearchDialogHandlers({ enableKeyboardShortcut, onDocsSelect, onSelect, runDocumentationSearch: documentationSearch.runDocumentationSearch, }); const labels = { ...DEFAULT_SCOPE_LABELS, ...scopeLabels }; const trimmedQuery = handlers.query.trim(); const sortedItems = useMemo( () => [...items].sort((a, b) => a.title.localeCompare(b.title)), [items], ); const currentEmptyText = getCurrentEmptyText({ docsEmptyText, documentationSearchLength: documentationSearch.documentationSearchLength, emptyText, scope: handlers.scope, trimmedQuery, }); const showDocumentation = documentationSearch.hasDocumentationSearch && handlers.scope !== "components"; return ( <SearchDialogView buttonText={buttonText} buttonTextMobile={buttonTextMobile} currentEmptyText={currentEmptyText} docsGroupHeading={docsGroupHeading} documentationSearch={documentationSearch} groupHeading={groupHeading} handlers={handlers} labels={labels} searchPlaceholder={searchPlaceholder} showDocumentation={showDocumentation} sortedItems={sortedItems} trimmedQuery={trimmedQuery} /> ); }

Dependencies

  • @vllnt/ui@^0.3.0

Frequently asked questions

What is Search Dialog?
Search Dialog is a React command-palette component from VLLNT UI. It opens a modal search over a list of items, highlights matches, and supports optional asynchronous documentation search across scoped tabs.
How do I add Search Dialog?
Run `pnpm dlx shadcn@latest add https://ui.vllnt.com/r/search-dialog.json` to install Search Dialog via the shadcn CLI. The component and its types are copied into your project so you can wire it to your data.
Does Search Dialog support a keyboard shortcut?
Yes. It listens for Cmd+K (or Ctrl+K) to toggle open and shows a shortcut badge on the trigger button. The shortcut is ignored while typing in inputs, and you can turn it off with the enableKeyboardShortcut prop.