Search and Predictive Search Intermediate 12 min read

Predictive Search Concepts and UI Patterns

Learn how predictive search works in Shopify, common UI patterns for search dropdowns, and best practices for search-as-you-type experiences.

Predictive search shows results as users type, helping them find what they need faster. Shopify provides a dedicated API for building these instant search experiences.

Predictive search (also called autocomplete or instant search):

  • Shows results as the user types
  • Doesn’t require form submission
  • Returns products, collections, articles, and pages
  • Uses Shopify’s search algorithm

The Predictive Search API

Shopify provides a JSON endpoint:

/search/suggest.json?q={query}&resources[type]=product,collection,article,page&resources[limit]=4

Parameters:

  • q: The search query
  • resources[type]: Comma-separated resource types
  • resources[limit]: Max results per type (1-10)
  • resources[options][unavailable_products]: hide, show, or last
  • resources[options][fields]: product fields to search

Section Rendering Approach

For HTML responses, use Section Rendering:

/search/suggest?q={query}&section_id=predictive-search

This returns the rendered HTML from a section, making it easy to display results.

Basic Predictive Search Structure

{# sections/predictive-search.liquid #}
{%- if predictive_search.performed -%}
<div class="predictive-search__results">
{%- if predictive_search.resources.products.size > 0 -%}
<div class="predictive-search__group">
<h3 class="predictive-search__heading">Products</h3>
<ul class="predictive-search__list">
{%- for product in predictive_search.resources.products -%}
<li class="predictive-search__item">
{% render 'predictive-search-product', product: product %}
</li>
{%- endfor -%}
</ul>
</div>
{%- endif -%}
{%- if predictive_search.resources.collections.size > 0 -%}
<div class="predictive-search__group">
<h3 class="predictive-search__heading">Collections</h3>
<ul class="predictive-search__list">
{%- for collection in predictive_search.resources.collections -%}
<li class="predictive-search__item">
<a href="{{ collection.url }}">{{ collection.title }}</a>
</li>
{%- endfor -%}
</ul>
</div>
{%- endif -%}
{%- if predictive_search.resources.articles.size > 0 -%}
<div class="predictive-search__group">
<h3 class="predictive-search__heading">Articles</h3>
<ul class="predictive-search__list">
{%- for article in predictive_search.resources.articles -%}
<li class="predictive-search__item">
<a href="{{ article.url }}">{{ article.title }}</a>
</li>
{%- endfor -%}
</ul>
</div>
{%- endif -%}
</div>
{%- endif -%}

UI Patterns

1. Dropdown Below Input

The most common pattern:

<div class="search-modal" data-predictive-search>
<form action="{{ routes.search_url }}" method="get">
<input
type="search"
name="q"
placeholder="Search..."
autocomplete="off"
data-search-input
>
</form>
<div class="search-modal__results" data-search-results hidden>
{# Results populated dynamically #}
</div>
</div>

2. Full-Screen Modal

For mobile or prominent search:

<div class="search-overlay" id="search-overlay" hidden>
<div class="search-overlay__content">
<form action="{{ routes.search_url }}" method="get" class="search-overlay__form">
<input
type="search"
name="q"
placeholder="What are you looking for?"
autocomplete="off"
data-search-input
>
<button type="button" data-close-search aria-label="Close search">
{% render 'icon-close' %}
</button>
</form>
<div class="search-overlay__results" data-search-results>
{# Results populated dynamically #}
</div>
</div>
</div>

3. Search Drawer

Slide-in from side:

<search-drawer class="search-drawer" id="search-drawer">
<div class="search-drawer__overlay" data-close-search></div>
<div class="search-drawer__panel">
<div class="search-drawer__header">
<form action="{{ routes.search_url }}" method="get">
<input type="search" name="q" placeholder="Search..." data-search-input>
</form>
<button data-close-search aria-label="Close">×</button>
</div>
<div class="search-drawer__body" data-search-results>
{# Results #}
</div>
</div>
</search-drawer>

Predictive Search Component

{# snippets/predictive-search.liquid #}
<predictive-search
class="predictive-search"
data-loading-text="Searching..."
>
<form
action="{{ routes.search_url }}"
method="get"
role="search"
class="predictive-search__form"
>
<label for="predictive-search-input" class="visually-hidden">
Search
</label>
<input
type="search"
id="predictive-search-input"
name="q"
class="predictive-search__input"
placeholder="Search products..."
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
aria-controls="predictive-search-results"
aria-expanded="false"
aria-haspopup="listbox"
>
<button type="submit" class="predictive-search__submit" aria-label="Search">
{% render 'icon-search' %}
</button>
<button
type="button"
class="predictive-search__clear"
hidden
aria-label="Clear search"
>
{% render 'icon-close' %}
</button>
<div class="predictive-search__loading" hidden>
{% render 'loading-spinner' %}
</div>
</form>
<div
id="predictive-search-results"
class="predictive-search__results"
role="listbox"
hidden
>
{# Results populated via JavaScript #}
</div>
</predictive-search>

JavaScript Structure

class PredictiveSearch extends HTMLElement {
constructor() {
super();
this.input = this.querySelector('input[type="search"]');
this.resultsContainer = this.querySelector('[role="listbox"]');
this.clearButton = this.querySelector('.predictive-search__clear');
this.loadingIndicator = this.querySelector('.predictive-search__loading');
this.abortController = null;
this.debounceTimer = null;
}
connectedCallback() {
this.input.addEventListener('input', this.onInput.bind(this));
this.input.addEventListener('focus', this.onFocus.bind(this));
this.clearButton?.addEventListener('click', this.onClear.bind(this));
// Close on click outside
document.addEventListener('click', (e) => {
if (!this.contains(e.target)) {
this.close();
}
});
// Keyboard navigation
this.addEventListener('keydown', this.onKeydown.bind(this));
}
onInput() {
const query = this.input.value.trim();
// Show/hide clear button
this.clearButton.hidden = query.length === 0;
// Debounce the search
clearTimeout(this.debounceTimer);
if (query.length < 2) {
this.close();
return;
}
this.debounceTimer = setTimeout(() => {
this.search(query);
}, 300);
}
async search(query) {
// Cancel previous request
this.abortController?.abort();
this.abortController = new AbortController();
this.showLoading();
try {
const response = await fetch(
`/search/suggest?q=${encodeURIComponent(query)}&section_id=predictive-search`,
{ signal: this.abortController.signal }
);
if (!response.ok) throw new Error('Search failed');
const html = await response.text();
this.renderResults(html);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Search error:', error);
this.showError();
}
} finally {
this.hideLoading();
}
}
renderResults(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const results = doc.querySelector('.predictive-search__results');
if (results && results.innerHTML.trim()) {
this.resultsContainer.innerHTML = results.innerHTML;
this.open();
} else {
this.showNoResults();
}
}
open() {
this.resultsContainer.hidden = false;
this.input.setAttribute('aria-expanded', 'true');
}
close() {
this.resultsContainer.hidden = true;
this.input.setAttribute('aria-expanded', 'false');
}
onClear() {
this.input.value = '';
this.input.focus();
this.close();
this.clearButton.hidden = true;
}
showLoading() {
this.loadingIndicator.hidden = false;
}
hideLoading() {
this.loadingIndicator.hidden = true;
}
showNoResults() {
this.resultsContainer.innerHTML = `
<div class="predictive-search__no-results">
No results found
</div>
`;
this.open();
}
showError() {
this.resultsContainer.innerHTML = `
<div class="predictive-search__error">
Something went wrong. Please try again.
</div>
`;
this.open();
}
onKeydown(e) {
// Handle arrow navigation, escape, enter
}
}
customElements.define('predictive-search', PredictiveSearch);

Accessibility Considerations

  1. ARIA attributes:

    • role="search" on form
    • role="listbox" on results
    • aria-expanded on input
    • aria-controls linking input to results
  2. Keyboard navigation:

    • Arrow keys to navigate results
    • Enter to select
    • Escape to close
  3. Screen reader announcements:

    • Announce number of results
    • Announce when loading
announceResults(count) {
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.className = 'visually-hidden';
announcement.textContent = `${count} results found`;
this.appendChild(announcement);
setTimeout(() => announcement.remove(), 1000);
}

Practice Exercise

Build a predictive search component that:

  1. Shows a search input in the header
  2. Fetches results after 2+ characters
  3. Debounces input (300ms delay)
  4. Shows loading indicator
  5. Displays products and collections
  6. Supports keyboard navigation

Key Takeaways

  1. Use /search/suggest for predictive search
  2. Section Rendering returns formatted HTML
  3. Debounce input to avoid excessive requests
  4. Cancel previous requests with AbortController
  5. Multiple UI patterns: dropdown, modal, drawer
  6. Accessibility is critical for search
  7. Handle loading, error, and empty states
  8. Keyboard navigation for power users

What’s Next?

The next lesson covers Rendering Predictive Results in detail with templates and styling.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...