Search Components Intermediate 10 min read
Search Results Display and Navigation
Build search result components for products, collections, and pages. Implement keyboard navigation and highlight matching text.
Search results need to be scannable, clickable, and keyboard-accessible. Let’s build individual result components that display product images, prices, and matching text highlights.
Theme Integration
These components are rendered inside the SearchModal:
snippets/search-modal.liquid (included in theme.liquid)└── <div id="search-modal-root"> └── SearchModal (React) └── SearchResults ├── ProductResult ← You are here ├── CollectionResult ← You are here └── PageResult ← You are hereSee Search Modal Architecture for the complete component structure.
Data Source
| Prop/State | Source | API Field |
|---|---|---|
product.id | Predictive Search API | resources.results.products[].id |
product.title | Predictive Search API | resources.results.products[].title |
product.url | Predictive Search API | resources.results.products[].url |
product.price | Predictive Search API | resources.results.products[].price |
product.compareAtPrice | Predictive Search API | resources.results.products[].compare_at_price |
product.featuredImage | Predictive Search API | resources.results.products[].featured_image |
product.vendor | Predictive Search API | resources.results.products[].vendor |
product.available | Predictive Search API | resources.results.products[].available |
collection.productsCount | Predictive Search API | resources.results.collections[].products_count |
suggestion.styledText | Predictive Search API | resources.results.queries[].styled_text (HTML with <mark>) |
ProductResult Component
import type { SearchProduct } from '@/types/search';import { formatMoney } from '@/utils/money';import styles from './ProductResult.module.css';
interface ProductResultProps { product: SearchProduct; onClick: () => void;}
/** * ProductResult displays a single product in search results. * Shows image, title, price, and availability status. */export function ProductResult({ product, onClick }: ProductResultProps) { const hasDiscount = product.compareAtPrice && product.compareAtPrice > product.price;
return ( <li> <a href={product.url} className={styles.result} onClick={onClick} > {/* Product image */} <div className={styles.imageWrapper}> {product.featuredImage ? ( <img src={product.featuredImage.url} alt={product.featuredImage.altText || product.title} className={styles.image} loading="lazy" /> ) : ( <div className={styles.imagePlaceholder}> <ImagePlaceholderIcon /> </div> )} </div>
{/* Product info */} <div className={styles.info}> <span className={styles.title}>{product.title}</span> <span className={styles.vendor}>{product.vendor}</span> </div>
{/* Price */} <div className={styles.pricing}> {hasDiscount && ( <span className={styles.comparePrice}> {formatMoney(product.compareAtPrice!)} </span> )} <span className={hasDiscount ? styles.salePrice : styles.price}> {formatMoney(product.price)} </span> </div>
{/* Availability badge */} {!product.available && ( <span className={styles.soldOut}>Sold out</span> )} </a> </li> );}
function ImagePlaceholderIcon() { return ( <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <rect x="3" y="3" width="18" height="18" rx="2" /> <circle cx="8.5" cy="8.5" r="1.5" /> <polyline points="21 15 16 10 5 21" /> </svg> );}.result { display: grid; grid-template-columns: 60px 1fr auto; gap: 0.75rem; align-items: center; padding: 0.75rem; text-decoration: none; color: var(--color-text); border-radius: var(--radius-sm); transition: background-color 0.15s ease;}
.result:hover { background-color: var(--color-surface);}
.result:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px;}
/* Image */.imageWrapper { width: 60px; height: 60px; flex-shrink: 0;}
.image { width: 100%; height: 100%; object-fit: cover; border-radius: var(--radius-sm); background-color: var(--color-surface);}
.imagePlaceholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background-color: var(--color-surface); border-radius: var(--radius-sm); color: var(--color-text-muted);}
/* Info */.info { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0;}
.title { font-weight: 500; font-size: 0.9375rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
.vendor { font-size: 0.8125rem; color: var(--color-text-muted);}
/* Pricing */.pricing { display: flex; flex-direction: column; align-items: flex-end; gap: 0.125rem;}
.price { font-weight: 500; font-size: 0.9375rem;}
.comparePrice { font-size: 0.8125rem; color: var(--color-text-muted); text-decoration: line-through;}
.salePrice { font-weight: 500; font-size: 0.9375rem; color: var(--color-sale);}
/* Sold out badge */.soldOut { grid-column: 2 / -1; font-size: 0.75rem; color: var(--color-text-muted); padding: 0.125rem 0.375rem; background-color: var(--color-surface); border-radius: var(--radius-sm); width: fit-content;}CollectionResult Component
import type { SearchCollection } from '@/types/search';import styles from './CollectionResult.module.css';
interface CollectionResultProps { collection: SearchCollection; onClick: () => void;}
/** * CollectionResult displays a collection in search results. * Shows collection image, title, and product count. */export function CollectionResult({ collection, onClick }: CollectionResultProps) { return ( <li> <a href={collection.url} className={styles.result} onClick={onClick} > {/* Collection icon or image */} <div className={styles.icon}> {collection.image ? ( <img src={collection.image.url} alt={collection.image.altText || collection.title} className={styles.image} /> ) : ( <CollectionIcon /> )} </div>
{/* Collection info */} <div className={styles.info}> <span className={styles.title}>{collection.title}</span> <span className={styles.count}> {collection.productsCount} products </span> </div>
{/* Arrow indicator */} <ArrowIcon className={styles.arrow} /> </a> </li> );}
function CollectionIcon() { return ( <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> </svg> );}
function ArrowIcon({ className }: { className?: string }) { return ( <svg className={className} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="m9 18 6-6-6-6" /> </svg> );}.result { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; text-decoration: none; color: var(--color-text); border-radius: var(--radius-sm); transition: background-color 0.15s ease;}
.result:hover { background-color: var(--color-surface);}
.result:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px;}
.icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background-color: var(--color-surface); border-radius: var(--radius-sm); color: var(--color-text-muted); flex-shrink: 0;}
.image { width: 100%; height: 100%; object-fit: cover; border-radius: var(--radius-sm);}
.info { flex: 1; display: flex; flex-direction: column; gap: 0.125rem; min-width: 0;}
.title { font-weight: 500; font-size: 0.9375rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
.count { font-size: 0.8125rem; color: var(--color-text-muted);}
.arrow { color: var(--color-text-muted); opacity: 0; transform: translateX(-4px); transition: opacity 0.15s ease, transform 0.15s ease;}
.result:hover .arrow { opacity: 1; transform: translateX(0);}PageResult Component
import type { SearchPage } from '@/types/search';import styles from './PageResult.module.css';
interface PageResultProps { page: SearchPage; onClick: () => void;}
/** * PageResult displays a page in search results. * Simple layout with icon and title. */export function PageResult({ page, onClick }: PageResultProps) { return ( <li> <a href={page.url} className={styles.result} onClick={onClick} > <PageIcon /> <span className={styles.title}>{page.title}</span> <ArrowIcon className={styles.arrow} /> </a> </li> );}
function PageIcon() { return ( <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> <polyline points="14 2 14 8 20 8" /> <line x1="16" y1="13" x2="8" y2="13" /> <line x1="16" y1="17" x2="8" y2="17" /> <polyline points="10 9 9 9 8 9" /> </svg> );}
function ArrowIcon({ className }: { className?: string }) { return ( <svg className={className} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="m9 18 6-6-6-6" /> </svg> );}.result { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; text-decoration: none; color: var(--color-text); border-radius: var(--radius-sm); transition: background-color 0.15s ease;}
.result:hover { background-color: var(--color-surface);}
.result:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px;}
.result svg:first-child { color: var(--color-text-muted); flex-shrink: 0;}
.title { flex: 1; font-size: 0.9375rem;}
.arrow { color: var(--color-text-muted); opacity: 0; transform: translateX(-4px); transition: opacity 0.15s ease, transform 0.15s ease;}
.result:hover .arrow { opacity: 1; transform: translateX(0);}SuggestionResult Component
import type { SearchSuggestion } from '@/types/search';import styles from './SuggestionResult.module.css';
interface SuggestionResultProps { suggestion: SearchSuggestion; onClick: () => void;}
/** * SuggestionResult displays a search suggestion with highlighted matching text. * Shopify provides pre-styled HTML with <mark> tags. */export function SuggestionResult({ suggestion, onClick }: SuggestionResultProps) { return ( <li> <a href={`/search?q=${encodeURIComponent(suggestion.text)}`} className={styles.result} onClick={onClick} > <SearchIcon /> {/* Use dangerouslySetInnerHTML to render the styled text with <mark> tags. */} <span className={styles.text} dangerouslySetInnerHTML={{ __html: suggestion.styledText }} /> </a> </li> );}
function SearchIcon() { return ( <svg width="16" height="16" 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> );}.result { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; text-decoration: none; color: var(--color-text); border-radius: var(--radius-sm); transition: background-color 0.15s ease;}
.result:hover { background-color: var(--color-surface);}
.result:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px;}
.result svg { color: var(--color-text-muted); flex-shrink: 0;}
.text { font-size: 0.9375rem;}
/* Style the <mark> tags from Shopify's styled_text. */.text :global(mark) { background-color: transparent; color: var(--color-primary); font-weight: 600;}Keyboard Navigation Hook
import { useEffect, useRef, useCallback } from 'react';
interface UseSearchKeyboardOptions { isOpen: boolean; onClose: () => void; containerRef: React.RefObject<HTMLElement>;}
/** * useSearchKeyboard handles keyboard navigation in search results. * - Arrow keys to navigate between results * - Enter to select * - Escape to close */export function useSearchKeyboard({ isOpen, onClose, containerRef,}: UseSearchKeyboardOptions) { const focusedIndexRef = useRef(-1);
// Get all focusable result elements. const getFocusableElements = useCallback(() => { if (!containerRef.current) return []; return Array.from( containerRef.current.querySelectorAll<HTMLElement>( 'a[href], button:not([disabled])' ) ); }, [containerRef]);
// Focus an element by index. const focusElement = useCallback( (index: number) => { const elements = getFocusableElements(); if (elements.length === 0) return;
// Wrap around. const newIndex = index < 0 ? elements.length - 1 : index >= elements.length ? 0 : index;
focusedIndexRef.current = newIndex; elements[newIndex]?.focus(); }, [getFocusableElements] );
useEffect(() => { if (!isOpen) { focusedIndexRef.current = -1; return; }
const handleKeyDown = (event: KeyboardEvent) => { const elements = getFocusableElements(); if (elements.length === 0) return;
switch (event.key) { case 'ArrowDown': event.preventDefault(); focusElement(focusedIndexRef.current + 1); break;
case 'ArrowUp': event.preventDefault(); focusElement(focusedIndexRef.current - 1); break;
case 'Home': event.preventDefault(); focusElement(0); break;
case 'End': event.preventDefault(); focusElement(elements.length - 1); break;
case 'Escape': event.preventDefault(); onClose(); break; } };
document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, onClose, focusElement, getFocusableElements]);
// Reset focus when results change. const resetFocus = useCallback(() => { focusedIndexRef.current = -1; }, []);
return { resetFocus };}Highlight Matching Text Utility
import React from 'react';
/** * Highlights matching text in a string by wrapping matches in <mark> tags. * Used for client-side text highlighting when Shopify doesn't provide styled_text. * * @param text - The text to search in * @param query - The search query to highlight * @returns React elements with highlighted matches */export function highlightMatch(text: string, query: string): React.ReactNode { if (!query.trim()) return text;
// Escape regex special characters in the query. const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Create a regex that matches the query (case-insensitive). const regex = new RegExp(`(${escapedQuery})`, 'gi');
// Split the text by the regex, keeping the matches. const parts = text.split(regex);
return ( <> {parts.map((part, index) => { // Check if this part matches the query (case-insensitive). const isMatch = part.toLowerCase() === query.toLowerCase();
return isMatch ? ( <mark key={index} style={{ backgroundColor: 'transparent', fontWeight: 600, color: 'var(--color-primary)' }}> {part} </mark> ) : ( <React.Fragment key={index}>{part}</React.Fragment> ); })} </> );}Accessible Result List
import { useRef, useEffect } from 'react';import { useSearchKeyboard } from '@/hooks/useSearchKeyboard';import type { PredictiveSearchResponse } from '@/types/search';import { ProductResult } from './ProductResult';import { CollectionResult } from './CollectionResult';import { PageResult } from './PageResult';import { SuggestionResult } from './SuggestionResult';import styles from './SearchResultsList.module.css';
interface SearchResultsListProps { results: PredictiveSearchResponse; query: string; isOpen: boolean; onClose: () => void; onResultClick: () => void;}
/** * SearchResultsList renders all search results with keyboard navigation. * Implements roving tabindex pattern for accessibility. */export function SearchResultsList({ results, query, isOpen, onClose, onResultClick,}: SearchResultsListProps) { const containerRef = useRef<HTMLDivElement>(null);
// Set up keyboard navigation. const { resetFocus } = useSearchKeyboard({ isOpen, onClose, containerRef, });
// Reset focus when results change. useEffect(() => { resetFocus(); }, [results, resetFocus]);
// Announce results to screen readers. const totalResults = results.products.length + results.collections.length + results.pages.length + results.queries.length;
return ( <div ref={containerRef} role="listbox" aria-label={`${totalResults} search results`} className={styles.container} > {/* Screen reader announcement. */} <div className="sr-only" aria-live="polite"> {totalResults > 0 ? `${totalResults} results found for "${query}"` : `No results found for "${query}"`} </div>
{/* Suggestions */} {results.queries.length > 0 && ( <section aria-labelledby="suggestions-heading"> <h3 id="suggestions-heading" className={styles.heading}> Suggestions </h3> <ul role="group" className={styles.list}> {results.queries.map((suggestion) => ( <SuggestionResult key={suggestion.text} suggestion={suggestion} onClick={onResultClick} /> ))} </ul> </section> )}
{/* Products */} {results.products.length > 0 && ( <section aria-labelledby="products-heading"> <h3 id="products-heading" className={styles.heading}> Products </h3> <ul role="group" className={styles.list}> {results.products.map((product) => ( <ProductResult key={product.id} product={product} onClick={onResultClick} /> ))} </ul> </section> )}
{/* Collections */} {results.collections.length > 0 && ( <section aria-labelledby="collections-heading"> <h3 id="collections-heading" className={styles.heading}> Collections </h3> <ul role="group" className={styles.list}> {results.collections.map((collection) => ( <CollectionResult key={collection.id} collection={collection} onClick={onResultClick} /> ))} </ul> </section> )}
{/* Pages */} {results.pages.length > 0 && ( <section aria-labelledby="pages-heading"> <h3 id="pages-heading" className={styles.heading}> Pages </h3> <ul role="group" className={styles.list}> {results.pages.map((page) => ( <PageResult key={page.id} page={page} onClick={onResultClick} /> ))} </ul> </section> )} </div> );}Key Takeaways
- Result components: Separate components for products, collections, pages, suggestions
- Keyboard navigation: Arrow keys, Home/End, Enter to select
- Text highlighting: Highlight matching query text in results
- Accessibility: Screen reader announcements, ARIA roles
- Visual feedback: Hover states, focus indicators
- Responsive: Truncate long titles, adapt layout for small screens
In the next lesson, we’ll integrate the search with a full search results page.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...