mdxui

SearchBox

Modal search widget for global application search

SearchBox

The SearchBox widget provides a modal search interface for global application search. It's typically triggered by a keyboard shortcut (Cmd+K) and allows users to quickly find and navigate to content, commands, or settings.

Type Signature

interface SearchBoxProps {
  /** Search query */
  query?: string
  /** Query change handler */
  onQueryChange?: (query: string) => void
  /** Search results */
  results?: SearchResult[]
  /** Result selection handler */
  onSelect?: (result: SearchResult) => void
  /** Placeholder text */
  placeholder?: string
  /** Show recent searches */
  showRecent?: boolean
  /** Recent searches */
  recentSearches?: string[]
  /** Search categories */
  categories?: string[]
  /** Enable keyboard shortcuts */
  shortcuts?: boolean
  /** Loading state */
  loading?: boolean
}

interface SearchResult {
  id: string
  title: string
  description?: string
  category?: string
  icon?: string
  url?: string
  metadata?: any
}

Usage

<SearchBox
  placeholder="Search..."
  onQueryChange={(query) => setQuery(query)}
  results={searchResults}
  onSelect={(result) => {
    navigate(result.url)
  }}
  shortcuts={true}
  showRecent={true}
/>

Props

query

Current search query value.

<SearchBox
  query={query}
  onQueryChange={setQuery}
/>

onQueryChange

Callback when the search query changes.

<SearchBox
  onQueryChange={(query) => {
    setQuery(query)
    performSearch(query)
  }}
/>

results

Array of search results to display.

<SearchBox
  results={[
    {
      id: '1',
      title: 'User Settings',
      description: 'Manage your account settings',
      category: 'Settings',
      icon: 'Settings',
      url: '/settings',
    },
    {
      id: '2',
      title: 'API Keys',
      description: 'View and manage API keys',
      category: 'Developer',
      icon: 'Key',
      url: '/keys',
    },
  ]}
/>

onSelect

Callback when a search result is selected.

<SearchBox
  results={results}
  onSelect={(result) => {
    // Navigate to result
    navigate(result.url)
    // Or execute command
    result.action?.()
  }}
/>

placeholder

Placeholder text in the search input. Defaults to "Search...".

<SearchBox placeholder="Search or type a command..." />

showRecent

Display recent searches when query is empty. Defaults to true.

<SearchBox
  showRecent={true}
  recentSearches={['API Keys', 'Settings', 'Dashboard']}
/>

shortcuts

Enable keyboard shortcuts display. Defaults to true.

<SearchBox shortcuts={true} />

loading

Show loading state while searching.

<SearchBox
  loading={isSearching}
  results={results}
/>

Widget Context Usage

SearchBox is typically used as a modal overlay:

Full-screen modal search:

function App() {
  const [searchOpen, setSearchOpen] = useState(false)

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
        e.preventDefault()
        setSearchOpen(true)
      }
    }

    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [])

  return (
    <>
      <Shell>
        <DashboardView />
      </Shell>

      <Dialog open={searchOpen} onClose={() => setSearchOpen(false)}>
        <SearchBox
          onSelect={(result) => {
            navigate(result.url)
            setSearchOpen(false)
          }}
        />
      </Dialog>
    </>
  )
}

Command Palette Mode

SearchBox can also function as a command palette:

<SearchBox
  placeholder="Type a command or search..."
  results={[
    // Navigation items
    {
      id: 'nav-dashboard',
      title: 'Dashboard',
      category: 'Navigate',
      icon: 'LayoutDashboard',
      url: '/',
    },
    // Commands
    {
      id: 'cmd-new-key',
      title: 'Create New API Key',
      category: 'Commands',
      icon: 'Plus',
      action: () => createApiKey(),
    },
    {
      id: 'cmd-export',
      title: 'Export Data',
      category: 'Commands',
      icon: 'Download',
      action: () => exportData(),
    },
  ]}
/>

Search Categories

Organize results by category:

<SearchBox
  categories={['Pages', 'Settings', 'Commands', 'Help']}
  results={[
    // Pages
    {
      id: '1',
      title: 'Dashboard',
      category: 'Pages',
      url: '/',
    },
    {
      id: '2',
      title: 'API Keys',
      category: 'Pages',
      url: '/keys',
    },
    // Settings
    {
      id: '3',
      title: 'Account Settings',
      category: 'Settings',
      url: '/settings/account',
    },
    {
      id: '4',
      title: 'Security Settings',
      category: 'Settings',
      url: '/settings/security',
    },
    // Commands
    {
      id: '5',
      title: 'Create New Key',
      category: 'Commands',
      action: () => createKey(),
    },
  ]}
/>

Common Patterns

function GlobalSearch() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  useEffect(() => {
    if (query.length === 0) {
      setResults([])
      return
    }

    const searchResults = [
      // Search pages
      ...pages.filter((p) =>
        p.title.toLowerCase().includes(query.toLowerCase())
      ),
      // Search settings
      ...settings.filter((s) =>
        s.title.toLowerCase().includes(query.toLowerCase())
      ),
      // Search docs
      ...docs.filter((d) =>
        d.title.toLowerCase().includes(query.toLowerCase())
      ),
    ]

    setResults(searchResults)
  }, [query])

  return (
    <SearchBox
      query={query}
      onQueryChange={setQuery}
      results={results}
      placeholder="Search pages, settings, and docs..."
    />
  )
}
function APISearch() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)

  const performSearch = useMemo(
    () =>
      debounce(async (q: string) => {
        if (q.length < 2) return

        setLoading(true)
        try {
          const data = await fetch(`/api/search?q=${q}`).then((r) => r.json())
          setResults(data.results)
        } finally {
          setLoading(false)
        }
      }, 300),
    []
  )

  useEffect(() => {
    performSearch(query)
  }, [query, performSearch])

  return (
    <SearchBox
      query={query}
      onQueryChange={setQuery}
      results={results}
      loading={loading}
      placeholder="Search..."
    />
  )
}
<SearchBox
  results={[
    // Users
    {
      id: 'user-1',
      title: 'John Doe',
      description: 'john@example.com',
      category: 'Users',
      icon: 'User',
      url: '/users/1',
    },
    // Projects
    {
      id: 'project-1',
      title: 'Acme Project',
      description: 'Internal tool development',
      category: 'Projects',
      icon: 'Folder',
      url: '/projects/1',
    },
    // Documents
    {
      id: 'doc-1',
      title: 'API Documentation',
      description: 'REST API reference',
      category: 'Documents',
      icon: 'FileText',
      url: '/docs/api',
    },
  ]}
/>

Command Palette

<SearchBox
  placeholder="Type a command..."
  results={[
    {
      id: 'new-project',
      title: 'New Project',
      description: 'Create a new project',
      category: 'Create',
      icon: 'Plus',
      action: () => createProject(),
    },
    {
      id: 'invite-user',
      title: 'Invite Team Member',
      description: 'Send an invitation',
      category: 'Team',
      icon: 'UserPlus',
      action: () => inviteUser(),
    },
    {
      id: 'export-data',
      title: 'Export Data',
      description: 'Download your data',
      category: 'Data',
      icon: 'Download',
      action: () => exportData(),
    },
  ]}
/>

Recent Searches

Show recent searches when query is empty:

function SearchWithRecent() {
  const [query, setQuery] = useState('')
  const [recentSearches, setRecentSearches] = useState([
    'API Keys',
    'Settings',
    'Dashboard',
  ])

  const addRecentSearch = (q: string) => {
    setRecentSearches((prev) => {
      const filtered = prev.filter((s) => s !== q)
      return [q, ...filtered].slice(0, 5)
    })
  }

  return (
    <SearchBox
      query={query}
      onQueryChange={setQuery}
      showRecent={query.length === 0}
      recentSearches={recentSearches}
      onSelect={(result) => {
        addRecentSearch(query)
        navigate(result.url)
      }}
    />
  )
}

Keyboard Navigation

SearchBox supports full keyboard navigation:

KeyAction
Cmd/Ctrl + KOpen search
EscapeClose search
↑ / ↓Navigate results
EnterSelect result
TabNext category
Shift + TabPrevious category
<SearchBox
  shortcuts={true}
  onKeyDown={(e) => {
    if (e.key === 'Enter') {
      selectResult(selectedResult)
    } else if (e.key === 'Escape') {
      closeSearch()
    }
  }}
/>

Search Highlighting

Highlight matching text in results:

function HighlightedSearchBox() {
  const [query, setQuery] = useState('')

  const highlightMatch = (text: string, query: string) => {
    if (!query) return text

    const regex = new RegExp(`(${query})`, 'gi')
    return text.replace(regex, '<mark>$1</mark>')
  }

  return (
    <SearchBox
      query={query}
      onQueryChange={setQuery}
      renderResult={(result) => (
        <div>
          <div
            dangerouslySetInnerHTML={{
              __html: highlightMatch(result.title, query),
            }}
          />
          <div className="text-sm text-muted-foreground">
            {result.description}
          </div>
        </div>
      )}
    />
  )
}

Empty States

Handle different empty states:

<SearchBox
  query={query}
  results={results}
  emptyState={
    query.length === 0 ? (
      <div className="text-center py-8">
        <p className="text-muted-foreground">
          Start typing to search...
        </p>
      </div>
    ) : results.length === 0 ? (
      <div className="text-center py-8">
        <p className="text-muted-foreground">
          No results found for "{query}"
        </p>
      </div>
    ) : null
  }
/>

Search Scopes

Allow scoping search to specific areas:

function ScopedSearch() {
  const [scope, setScope] = useState('all')
  const [query, setQuery] = useState('')

  const scopes = {
    all: 'All',
    pages: 'Pages',
    settings: 'Settings',
    docs: 'Documentation',
  }

  return (
    <>
      <Select value={scope} onValueChange={setScope}>
        {Object.entries(scopes).map(([key, label]) => (
          <option key={key} value={key}>
            {label}
          </option>
        ))}
      </Select>

      <SearchBox
        query={query}
        onQueryChange={setQuery}
        results={results.filter(
          (r) => scope === 'all' || r.category === scope
        )}
        placeholder={`Search ${scopes[scope].toLowerCase()}...`}
      />
    </>
  )
}

Integration with Payload CMS

Search across Payload collections:

function PayloadSearch() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  useEffect(() => {
    if (query.length < 2) return

    const searchCollections = async () => {
      const [posts, pages, users] = await Promise.all([
        payload.find({
          collection: 'posts',
          where: { title: { contains: query } },
        }),
        payload.find({
          collection: 'pages',
          where: { title: { contains: query } },
        }),
        payload.find({
          collection: 'users',
          where: { name: { contains: query } },
        }),
      ])

      setResults([
        ...posts.docs.map((p) => ({
          id: p.id,
          title: p.title,
          category: 'Posts',
          url: `/posts/${p.id}`,
        })),
        ...pages.docs.map((p) => ({
          id: p.id,
          title: p.title,
          category: 'Pages',
          url: `/pages/${p.id}`,
        })),
        ...users.docs.map((u) => ({
          id: u.id,
          title: u.name,
          category: 'Users',
          url: `/users/${u.id}`,
        })),
      ])
    }

    searchCollections()
  }, [query])

  return (
    <SearchBox
      query={query}
      onQueryChange={setQuery}
      results={results}
    />
  )
}

Accessibility

SearchBox implements accessibility best practices:

  • ARIA labels - Descriptive labels for screen readers
  • Keyboard navigation - Full keyboard support
  • Focus management - Proper focus handling
  • Live regions - Result updates announced to screen readers
<div role="combobox" aria-expanded="true" aria-haspopup="listbox">
  <input
    type="search"
    role="searchbox"
    aria-label="Search"
    aria-controls="search-results"
  />
  <ul id="search-results" role="listbox">
    <li role="option" aria-selected="true">
      Dashboard
    </li>
    <li role="option">
      Settings
    </li>
  </ul>
</div>

Best Practices

Debounce API Searches

Avoid excessive API calls:

const performSearch = useMemo(
  () =>
    debounce(async (query: string) => {
      const results = await searchAPI(query)
      setResults(results)
    }, 300),
  []
)

Show Relevant Results First

Prioritize results by relevance:

const sortResults = (results: SearchResult[], query: string) => {
  return results.sort((a, b) => {
    // Exact matches first
    const aExact = a.title.toLowerCase() === query.toLowerCase()
    const bExact = b.title.toLowerCase() === query.toLowerCase()
    if (aExact && !bExact) return -1
    if (!aExact && bExact) return 1

    // Starts with query
    const aStarts = a.title.toLowerCase().startsWith(query.toLowerCase())
    const bStarts = b.title.toLowerCase().startsWith(query.toLowerCase())
    if (aStarts && !bStarts) return -1
    if (!aStarts && bStarts) return 1

    // Alphabetical
    return a.title.localeCompare(b.title)
  })
}

Limit Results

Show a reasonable number of results:

<SearchBox
  results={results.slice(0, 10)}
/>

Provide Clear Categories

Group results for easier scanning:

<SearchBox
  results={results}
  categories={['Pages', 'Settings', 'Commands']}
/>

On this page