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 here

See Search Modal Architecture for the complete component structure.

Data Source

Prop/StateSourceAPI Field
product.idPredictive Search APIresources.results.products[].id
product.titlePredictive Search APIresources.results.products[].title
product.urlPredictive Search APIresources.results.products[].url
product.pricePredictive Search APIresources.results.products[].price
product.compareAtPricePredictive Search APIresources.results.products[].compare_at_price
product.featuredImagePredictive Search APIresources.results.products[].featured_image
product.vendorPredictive Search APIresources.results.products[].vendor
product.availablePredictive Search APIresources.results.products[].available
collection.productsCountPredictive Search APIresources.results.collections[].products_count
suggestion.styledTextPredictive Search APIresources.results.queries[].styled_text (HTML with <mark>)

ProductResult Component

src/components/search/SearchModal/ProductResult.tsx
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>
);
}
src/components/search/SearchModal/ProductResult.module.css
.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

src/components/search/SearchModal/CollectionResult.tsx
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>
);
}
src/components/search/SearchModal/CollectionResult.module.css
.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

src/components/search/SearchModal/PageResult.tsx
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>
);
}
src/components/search/SearchModal/PageResult.module.css
.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

src/components/search/SearchModal/SuggestionResult.tsx
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>
);
}
src/components/search/SearchModal/SuggestionResult.module.css
.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

src/hooks/useSearchKeyboard.ts
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

src/utils/highlightMatch.tsx
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

src/components/search/SearchModal/SearchResultsList.tsx
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

  1. Result components: Separate components for products, collections, pages, suggestions
  2. Keyboard navigation: Arrow keys, Home/End, Enter to select
  3. Text highlighting: Highlight matching query text in results
  4. Accessibility: Screen reader announcements, ARIA roles
  5. Visual feedback: Hover states, focus indicators
  6. 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...