Search Modal and Input Architecture
Build an accessible search modal with React. Learn how to structure the search UI, handle focus management, and integrate with Shopify predictive search.
Search is critical for e-commerce—customers who search convert at higher rates. Let’s build a search modal that’s fast, accessible, and provides instant results.
Theme Integration
The search modal is mounted globally so it can be opened from anywhere (header, footer, keyboard shortcut):
layout/theme.liquid└── {% render 'search-modal' %} └── <div id="search-modal-root"> └── SearchModal (React) ← You are here ├── SearchInput └── SearchResults (or RecentSearches){% comment %} snippets/search-modal.liquid {% endcomment %}{% comment %} Include this snippet in theme.liquid before </body> to enable the global search modal.{% endcomment %}<div id="search-modal-root"></div>The search modal uses createPortal to render to document.body, ensuring proper z-index stacking. The trigger button is typically in the header (see Header Architecture).
Data Source
| Prop/State | Source | Origin |
|---|---|---|
isSearchOpen | Zustand UI store | - (controlled by openModal('search')) |
query | Local state | - (user input) |
searches (recent) | localStorage | recent-searches key |
| Search results | Shopify API | GET /search/suggest.json (see Predictive Search) |
Note: The search modal has no initial Liquid data. It’s mounted globally and fetches results dynamically via the Predictive Search API.
Search Modal Layout
┌────────────────────────────────────────────────────────────────────────────┐│ OVERLAY (semi-transparent backdrop) ││ ││ ┌──────────────────────────────────────────────────────────────────────┐ ││ │ SEARCH MODAL │ ││ │ │ ││ │ ┌────────────────────────────────────────────────────────────────┐ │ ││ │ │ 🔍 Search products... X │ │ ││ │ └────────────────────────────────────────────────────────────────┘ │ ││ │ │ ││ │ ┌────────────────────────────────────────────────────────────────┐ │ ││ │ │ SEARCH RESULTS │ │ ││ │ │ │ │ ││ │ │ Products Collections │ │ ││ │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ ││ │ │ │ Product 1 │ │ Collection 1 │ │ │ ││ │ │ │ Product 2 │ │ Collection 2 │ │ │ ││ │ │ │ Product 3 │ │ │ │ │ ││ │ │ └──────────────────┘ └──────────────────┘ │ │ ││ │ │ │ │ ││ │ │ Pages Suggestions │ │ ││ │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ ││ │ │ │ About Us │ │ "red shoes" │ │ │ ││ │ │ │ Contact │ │ "summer dress" │ │ │ ││ │ │ └──────────────────┘ └──────────────────┘ │ │ ││ │ └────────────────────────────────────────────────────────────────┘ │ ││ └──────────────────────────────────────────────────────────────────────┘ │└────────────────────────────────────────────────────────────────────────────┘SearchModal Component
import { useEffect, useRef, useState, useCallback } from 'react';import { createPortal } from 'react-dom';import { useUI } from '@/stores/ui';import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';import { useFocusTrap } from '@/hooks/useFocusTrap';import { SearchInput } from './SearchInput';import { SearchResults } from './SearchResults';import { RecentSearches } from './RecentSearches';import styles from './SearchModal.module.css';
/** * SearchModal is the main search overlay component. * Features: * - Full-screen overlay on mobile, centered modal on desktop * - Focus trap for accessibility * - Keyboard navigation (Escape to close, arrow keys for results) * - Recent searches when query is empty */export function SearchModal() { const modalRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// UI state from global store. const { isSearchOpen, closeSearch } = useUI((state) => ({ isSearchOpen: state.activeModal === 'search', closeSearch: () => state.closeModal(), }));
// Local search state. const [query, setQuery] = useState('');
// Lock body scroll when modal is open. useBodyScrollLock(isSearchOpen);
// Trap focus inside modal. useFocusTrap(modalRef, isSearchOpen);
// Focus input when modal opens. useEffect(() => { if (isSearchOpen && inputRef.current) { // Small delay to ensure modal is rendered. requestAnimationFrame(() => { inputRef.current?.focus(); }); } }, [isSearchOpen]);
// Handle Escape key. useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && isSearchOpen) { closeSearch(); } };
document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isSearchOpen, closeSearch]);
// Clear query when modal closes. useEffect(() => { if (!isSearchOpen) { setQuery(''); } }, [isSearchOpen]);
// Handle query change. const handleQueryChange = useCallback((value: string) => { setQuery(value); }, []);
// Handle clear button. const handleClear = useCallback(() => { setQuery(''); inputRef.current?.focus(); }, []);
// Don't render if modal is closed. if (!isSearchOpen) return null;
return createPortal( <div className={styles.overlay} onClick={closeSearch} role="presentation" > <div ref={modalRef} className={styles.modal} role="dialog" aria-modal="true" aria-label="Search" onClick={(e) => e.stopPropagation()} > {/* Search input */} <SearchInput ref={inputRef} value={query} onChange={handleQueryChange} onClear={handleClear} onClose={closeSearch} />
{/* Results or recent searches */} <div className={styles.content}> {query.trim().length > 0 ? ( <SearchResults query={query} onClose={closeSearch} /> ) : ( <RecentSearches onSelect={setQuery} onClose={closeSearch} /> )} </div> </div> </div>, document.body );}SearchModal Styles
/* Overlay covers entire screen. */.overlay { position: fixed; inset: 0; z-index: var(--z-modal-overlay, 200); background-color: rgba(0, 0, 0, 0.6); animation: fadeIn 0.15s ease-out; /* Center modal on desktop. */ display: flex; align-items: flex-start; justify-content: center; padding-top: 10vh;}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; }}
/* Modal container. */.modal { width: 100%; max-width: 640px; max-height: 80vh; background-color: var(--color-background); border-radius: var(--radius-lg); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); animation: slideDown 0.2s ease-out; display: flex; flex-direction: column; overflow: hidden;}
@keyframes slideDown { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); }}
/* Scrollable content area. */.content { flex: 1; overflow-y: auto; padding: 1rem;}
/* Mobile: full-screen modal. */@media (max-width: 640px) { .overlay { padding-top: 0; align-items: stretch; }
.modal { max-width: 100%; max-height: 100%; border-radius: 0; }}SearchInput Component
import { forwardRef } from 'react';import styles from './SearchInput.module.css';
interface SearchInputProps { value: string; onChange: (value: string) => void; onClear: () => void; onClose: () => void;}
/** * SearchInput is the search text input with icon and clear button. * Uses forwardRef to allow parent to focus the input. */export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>( function SearchInput({ value, onChange, onClear, onClose }, ref) { return ( <div className={styles.container}> {/* Search icon */} <SearchIcon className={styles.searchIcon} />
{/* Input field */} <input ref={ref} type="search" className={styles.input} placeholder="Search products, collections..." value={value} onChange={(e) => onChange(e.target.value)} autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck="false" aria-label="Search" />
{/* Clear button - only show when there's text */} {value.length > 0 && ( <button type="button" className={styles.clearButton} onClick={onClear} aria-label="Clear search" > <ClearIcon /> </button> )}
{/* Close button */} <button type="button" className={styles.closeButton} onClick={onClose} aria-label="Close search" > <CloseIcon /> </button> </div> ); });
function SearchIcon({ className }: { className?: string }) { return ( <svg className={className} width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <circle cx="11" cy="11" r="8" /> <path d="m21 21-4.35-4.35" /> </svg> );}
function ClearIcon() { return ( <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <circle cx="12" cy="12" r="10" /> <path d="m15 9-6 6M9 9l6 6" /> </svg> );}
function CloseIcon() { return ( <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M18 6 6 18M6 6l12 12" /> </svg> );}.container { display: flex; align-items: center; gap: 0.75rem; padding: 1rem 1.25rem; border-bottom: 1px solid var(--color-border); background-color: var(--color-background);}
.searchIcon { flex-shrink: 0; color: var(--color-text-muted);}
.input { flex: 1; border: none; background: transparent; font-size: 1.125rem; color: var(--color-text); outline: none;}
.input::placeholder { color: var(--color-text-muted);}
/* Remove default search input styling. */.input::-webkit-search-cancel-button,.input::-webkit-search-decoration { -webkit-appearance: none; appearance: none;}
.clearButton,.closeButton { display: flex; align-items: center; justify-content: center; padding: 0.5rem; border: none; background: transparent; color: var(--color-text-muted); cursor: pointer; border-radius: var(--radius-full); transition: background-color 0.15s ease, color 0.15s ease;}
.clearButton:hover,.closeButton:hover { background-color: var(--color-surface); color: var(--color-text);}
.closeButton { margin-left: 0.5rem;}RecentSearches Component
import { useState, useEffect } from 'react';import styles from './RecentSearches.module.css';
const STORAGE_KEY = 'recent-searches';const MAX_RECENT = 5;
interface RecentSearchesProps { onSelect: (query: string) => void; onClose: () => void;}
/** * RecentSearches displays the user's recent search queries. * Stored in localStorage and limited to last 5 searches. */export function RecentSearches({ onSelect, onClose }: RecentSearchesProps) { const [searches, setSearches] = useState<string[]>([]);
// Load recent searches from localStorage. useEffect(() => { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { setSearches(JSON.parse(stored)); } } catch (error) { console.error('Failed to load recent searches:', error); } }, []);
// Remove a search from history. const handleRemove = (query: string) => { const updated = searches.filter((s) => s !== query); setSearches(updated); localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); };
// Clear all recent searches. const handleClearAll = () => { setSearches([]); localStorage.removeItem(STORAGE_KEY); };
if (searches.length === 0) { return ( <div className={styles.empty}> <p className={styles.emptyText}>Start typing to search...</p> <p className={styles.hint}> Search for products, collections, or pages </p> </div> ); }
return ( <div className={styles.container}> <div className={styles.header}> <h3 className={styles.title}>Recent Searches</h3> <button type="button" className={styles.clearAll} onClick={handleClearAll} > Clear all </button> </div>
<ul className={styles.list}> {searches.map((query) => ( <li key={query} className={styles.item}> <button type="button" className={styles.searchButton} onClick={() => onSelect(query)} > <ClockIcon /> <span>{query}</span> </button> <button type="button" className={styles.removeButton} onClick={() => handleRemove(query)} aria-label={`Remove "${query}" from recent searches`} > <RemoveIcon /> </button> </li> ))} </ul> </div> );}
/** * Save a search query to recent searches. * Call this when a search is performed. */export function saveRecentSearch(query: string) { if (!query.trim()) return;
try { const stored = localStorage.getItem(STORAGE_KEY); const searches: string[] = stored ? JSON.parse(stored) : [];
// Remove if already exists (will add to front). const filtered = searches.filter((s) => s !== query);
// Add to front and limit to max. const updated = [query, ...filtered].slice(0, MAX_RECENT);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); } catch (error) { console.error('Failed to save recent search:', error); }}
function ClockIcon() { return ( <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <circle cx="12" cy="12" r="10" /> <polyline points="12 6 12 12 16 14" /> </svg> );}
function RemoveIcon() { return ( <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M18 6 6 18M6 6l12 12" /> </svg> );}.container { padding: 0.5rem 0;}
.header { display: flex; align-items: center; justify-content: space-between; padding: 0 0.5rem 0.75rem;}
.title { margin: 0; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted);}
.clearAll { padding: 0.25rem 0.5rem; border: none; background: transparent; font-size: 0.75rem; color: var(--color-text-muted); cursor: pointer; border-radius: var(--radius-sm);}
.clearAll:hover { color: var(--color-text); background-color: var(--color-surface);}
.list { list-style: none; margin: 0; padding: 0;}
.item { display: flex; align-items: center; gap: 0.5rem;}
.searchButton { flex: 1; display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: none; background: transparent; color: var(--color-text); font-size: 0.9375rem; text-align: left; cursor: pointer; border-radius: var(--radius-sm); transition: background-color 0.15s ease;}
.searchButton:hover { background-color: var(--color-surface);}
.searchButton svg { color: var(--color-text-muted); flex-shrink: 0;}
.removeButton { padding: 0.5rem; border: none; background: transparent; color: var(--color-text-muted); cursor: pointer; border-radius: var(--radius-full); opacity: 0; transition: opacity 0.15s ease, background-color 0.15s ease;}
.item:hover .removeButton { opacity: 1;}
.removeButton:hover { background-color: var(--color-surface); color: var(--color-text);}
/* Empty state */.empty { text-align: center; padding: 3rem 1rem;}
.emptyText { margin: 0 0 0.5rem; font-size: 1rem; color: var(--color-text);}
.hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted);}UI Store Integration
// src/stores/ui.ts - Search modal stateimport { create } from 'zustand';
type ModalType = 'search' | 'quickView' | null;type DrawerType = 'cart' | 'menu' | 'filter' | null;
interface UIState { activeModal: ModalType; activeDrawer: DrawerType;
// Modal actions openModal: (modal: ModalType) => void; closeModal: () => void;
// Drawer actions openDrawer: (drawer: DrawerType) => void; closeDrawer: () => void;
// Convenience methods openSearch: () => void; closeSearch: () => void;}
export const useUI = create<UIState>((set) => ({ activeModal: null, activeDrawer: null,
// Open modal, closing any drawer first. openModal: (modal) => set({ activeModal: modal, activeDrawer: null }), closeModal: () => set({ activeModal: null }),
// Open drawer, closing any modal first. openDrawer: (drawer) => set({ activeDrawer: drawer, activeModal: null }), closeDrawer: () => set({ activeDrawer: null }),
// Search shortcuts. openSearch: () => set({ activeModal: 'search', activeDrawer: null }), closeSearch: () => set({ activeModal: null }),}));Search Trigger Button
import { useUI } from '@/stores/ui';import styles from './SearchButton.module.css';
/** * SearchButton opens the search modal. * Typically placed in the header. */export function SearchButton() { const openSearch = useUI((state) => state.openSearch);
return ( <button type="button" className={styles.button} onClick={openSearch} aria-label="Open search" > <SearchIcon /> <span className={styles.text}>Search</span> </button> );}
function SearchIcon() { return ( <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <circle cx="11" cy="11" r="8" /> <path d="m21 21-4.35-4.35" /> </svg> );}.button { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border: 1px solid var(--color-border); background-color: var(--color-surface); color: var(--color-text-muted); font-size: 0.875rem; cursor: pointer; border-radius: var(--radius-full); transition: border-color 0.15s ease, color 0.15s ease;}
.button:hover { border-color: var(--color-text-muted); color: var(--color-text);}
.text { /* Hide on mobile */}
@media (max-width: 768px) { .button { padding: 0.5rem; border: none; background: transparent; }
.text { display: none; }}Keyboard Shortcut Hook
import { useEffect } from 'react';import { useUI } from '@/stores/ui';
/** * Opens the search modal when user presses Cmd+K or Ctrl+K. * Common pattern for quick search access. */export function useSearchShortcut() { const { openSearch, activeModal } = useUI();
useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Check for Cmd+K (Mac) or Ctrl+K (Windows/Linux). if ((event.metaKey || event.ctrlKey) && event.key === 'k') { event.preventDefault(); openSearch(); } };
document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [openSearch]);}Key Takeaways
- Portal rendering: Render modal at body level for proper stacking
- Focus management: Auto-focus input and trap focus inside modal
- Keyboard support: Escape to close, Cmd/Ctrl+K to open
- Recent searches: Store and display user’s search history
- Responsive design: Full-screen on mobile, centered modal on desktop
- Accessibility: Proper ARIA attributes and screen reader support
In the next lesson, we’ll implement the predictive search functionality with debouncing.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...