
Building a Spotlight Search Feature
Matan Shaviro / June 15, 2025
In this post, we'll explore how to build a MacOS-style Spotlight Search feature in React, focusing on component architecture, state management, and React best practices. We'll see how to create a maintainable and scalable search implementation that can be easily integrated into any React application.
The Feature Overview
The Spotlight Search feature allows users to:
- Quick-search through navigation items
- Navigate using keyboard shortcuts
- Access both main routes and sub-routes
- Get instant visual feedback
Architecture and Component Structure
Our implementation follows a clear separation of concerns with the following structure:
spotlight-search/
├── components/
│ ├── search-dialog/
│ ├── empty-state/
│ ├── result-item/
│ └── result-group/
├── hooks/
│ ├── use-search.ts
│ ├── use-keyboard-navigation.ts
│ └── use-spotlight-search-dialog.ts
├── spotlight-search.tsx
└── spotlight-search.context.tsx
Key Implementation Patterns
1. Context-Based State Management
We use React Context to manage the search state and make it available throughout the component tree:
// spotlight-search.context.tsx
export const SpotlightSearchProvider = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const value = {
isOpen,
searchQuery,
selectedIndex,
openSpotlightSearch: useCallback(() => {
setIsOpen(true);
setSearchQuery('');
setSelectedIndex(0);
}, []),
closeSpotlightSearch: useCallback(() => {
setIsOpen(false);
setSearchQuery('');
setSelectedIndex(0);
}, []),
};
return <SpotlightSearchContext.Provider value={value}>{children}</SpotlightSearchContext.Provider>;
};
2. Custom Hooks for Logic Separation
We separate complex logic into custom hooks:
// use-search.ts
export const useSearch = (searchQuery: string) => {
const navigationItems = useTransformData()
return useMemo(() => {
if (!searchQuery.trim()) return []
const normalizedQuery = searchQuery.toLowerCase().trim()
return optimizedSearchableItems.reduce((acc, item) => {
if (
acc.length < MAX_SEARCH_RESULTS &&
item._searchText.includes(normalizedQuery)
) {
const { _searchText, ...cleanItem } = item
acc.push(cleanItem)
}
return acc
}, [])
}, [searchQuery, optimizedSearchableItems])
}
React Best Practices Demonstrated
-
Proper TypeScript Usage
- Using PropsWithChildren for better type safety
- Defining clear interfaces for component props
- Utilizing proper type inference
-
Performance Optimizations
- Using useMemo for expensive computations
- Implementing useCallback for stable function references
- Optimizing search with single-pass reduce operations
-
Component Composition
- Clear separation of concerns
- Reusable and focused components
- Proper prop drilling avoidance using context
-
State Management
- Centralized state using Context
- Controlled component patterns
- Predictable state updates
Key Learnings
-
Abstraction Level
- The SpotlightSearch component acts as a feature wrapper
- Context provides a clean API for state management
- Hooks separate business logic from presentation
-
Maintainability
- Clear file structure
- Focused component responsibilities
- Easy to test implementation
-
Scalability
- Extensible search implementation
- Modular component architecture
- Easy to customize and extend
Quick Start Example
Let's look at a minimal implementation using a simple data structure:
// types.ts
interface SearchItem {
id: string;
title: string;
path: string;
category?: string;
}
// sample-data.ts
const sampleData: SearchItem[] = [
{ id: '1', title: 'Dashboard', path: '/dashboard', category: 'Main' },
{ id: '2', title: 'User Settings', path: '/settings', category: 'Main' },
{ id: '3', title: 'Profile', path: '/settings/profile', category: 'Settings' },
{ id: '4', title: 'Notifications', path: '/settings/notifications', category: 'Settings' },
];
// simple-spotlight.tsx
import { useState, useCallback } from 'react';
export const SimpleSpotlight = () => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const filteredItems = sampleData.filter(item =>
item.title.toLowerCase().includes(query.toLowerCase())
);
const handleSelect = (item: SearchItem) => {
console.log(`Navigate to: ${item.path}`);
setIsOpen(false);
};
return (
<>
<button onClick={()=> setIsOpen(true)}>
Search (Press ⌘ + K)
</button>
{isOpen && (
<div className="spotlight-overlay">
<div className="spotlight-modal">
<input
value={query}
onChange={(e)=> setQuery(e.target.value)}
placeholder="Search..."
autoFocus
/>
<div className="results">
{filteredItems.map(item => (
<div
key={item.id}
className="result-item"
onClick={()=> handleSelect(item)}
>
<span>{item.title}</span>
<span className="category">{item.category}</span>
</div>
))}
</div>
</div>
</div>
)}
</>
);
};
// Basic styles to get started
const styles= `
.spotlight-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
padding-top: 100px;
}
.spotlight-modal {
background: white;
border-radius: 8px;
width: 500px;
max-height: 400px;
overflow: auto;
}
.spotlight-modal input {
width: 100%;
padding: 16px;
border: none;
border-bottom: 1px solid #eee;
font-size: 16px;
}
.result-item {
padding: 12px 16px;
display: flex;
justify-content: space-between;
cursor: pointer;
}
.result-item:hover {
background: #f5f5f5;
}
.category {
color: #666;
font-size: 14px;
}
`;
Conclusion
Building a Spotlight Search feature requires careful consideration of component architecture, state management, and performance. By following React best practices and maintaining a clear separation of concerns, we've created a maintainable and scalable implementation that provides a great user experience.
The complete implementation demonstrates how to:
- Structure complex features in React
- Manage state effectively
- Implement keyboard navigation
- Handle performance optimization
- Support feature toggling
This pattern can be adapted for various search implementations while maintaining code quality and user experience.