JAVASCRIPTshopifyintermediate

Shopify Search Autocomplete

Add predictive search with product suggestions and instant results

#shopify#search#autocomplete#ajax#predictive-search
Share this snippet:

Code

javascript
1// Search Autocomplete Class
2class SearchAutocomplete {
3 constructor(options = {}) {
4 this.searchInput = document.querySelector(options.input || '[data-search-input]');
5 this.resultsContainer = document.querySelector(options.results || '[data-search-results]');
6 this.minChars = options.minChars || 2;
7 this.debounceTime = options.debounceTime || 300;
8 this.limit = options.limit || 10;
9
10 this.debounceTimer = null;
11 this.currentQuery = '';
12
13 if (!this.searchInput || !this.resultsContainer) {
14 console.error('Search elements not found');
15 return;
16 }
17
18 this.init();
19 }
20
21 init() {
22 // Input event
23 this.searchInput.addEventListener('input', (e) => {
24 this.handleInput(e.target.value);
25 });
26
27 // Focus event
28 this.searchInput.addEventListener('focus', () => {
29 if (this.currentQuery.length >= this.minChars) {
30 this.show();
31 }
32 });
33
34 // Click outside to close
35 document.addEventListener('click', (e) => {
36 if (!e.target.closest('.search-container')) {
37 this.hide();
38 }
39 });
40
41 // Keyboard navigation
42 this.searchInput.addEventListener('keydown', (e) => {
43 this.handleKeyboard(e);
44 });
45 }
46
47 handleInput(value) {
48 clearTimeout(this.debounceTimer);
49
50 if (value.length < this.minChars) {
51 this.hide();
52 return;
53 }
54
55 this.debounceTimer = setTimeout(() => {
56 this.search(value);
57 }, this.debounceTime);
58 }
59
60 async search(query) {
61 this.currentQuery = query;
62 this.showLoading();
63
64 try {
65 const response = await fetch(`/search/suggest.json?q=${encodeURIComponent(query)}&resources[type]=product,page,article&resources[limit]=${this.limit}`);
66
67 const data = await response.json();
68 this.renderResults(data.resources.results);
69
70 } catch (error) {
71 console.error('Search error:', error);
72 this.showError();
73 }
74 }
75
76 renderResults(results) {
77 this.resultsContainer.innerHTML = '';
78
79 if (!results.products || results.products.length === 0) {
80 this.showNoResults();
81 return;
82 }
83
84 const html = `
85 <div class="search-results">
86 ${results.products ? this.renderProducts(results.products) : ''}
87 ${results.pages ? this.renderPages(results.pages) : ''}
88 ${results.articles ? this.renderArticles(results.articles) : ''}
89
90 <div class="search-footer">
91 <a href="/search?q=${encodeURIComponent(this.currentQuery)}" class="view-all-link">
92 View all results →
93 </a>
94 </div>
95 </div>
96 `;
97
98 this.resultsContainer.innerHTML = html;
99 this.show();
100 }
101
102 renderProducts(products) {
103 if (!products.length) return '';
104
105 return `
106 <div class="search-section">
107 <h4 class="search-section-title">Products</h4>
108 <div class="search-items">
109 ${products.map(product => `
110 <a href="${product.url}" class="search-item">
111 ${product.featured_image ? `
112 <div class="search-item-image">
113 <img src="${product.featured_image.url}" alt="${product.title}">
114 </div>
115 ` : ''}
116 <div class="search-item-details">
117 <h5 class="search-item-title">${this.highlight(product.title)}</h5>
118 <div class="search-item-price">
119 ${this.formatPrice(product.price)}
120 </div>
121 </div>
122 </a>
123 `).join('')}
124 </div>
125 </div>
126 `;
127 }
128
129 renderPages(pages) {
130 if (!pages.length) return '';
131
132 return `
133 <div class="search-section">
134 <h4 class="search-section-title">Pages</h4>
135 <div class="search-items">
136 ${pages.map(page => `
137 <a href="${page.url}" class="search-item search-item--text">
138 <div class="search-item-details">
139 <h5 class="search-item-title">${this.highlight(page.title)}</h5>
140 </div>
141 </a>
142 `).join('')}
143 </div>
144 </div>
145 `;
146 }
147
148 renderArticles(articles) {
149 if (!articles.length) return '';
150
151 return `
152 <div class="search-section">
153 <h4 class="search-section-title">Articles</h4>
154 <div class="search-items">
155 ${articles.map(article => `
156 <a href="${article.url}" class="search-item search-item--text">
157 <div class="search-item-details">
158 <h5 class="search-item-title">${this.highlight(article.title)}</h5>
159 </div>
160 </a>
161 `).join('')}
162 </div>
163 </div>
164 `;
165 }
166
167 highlight(text) {
168 if (!this.currentQuery) return text;
169
170 const regex = new RegExp(`(${this.currentQuery})`, 'gi');
171 return text.replace(regex, '<mark>$1</mark>');
172 }
173
174 formatPrice(cents) {
175 return '$' + (cents / 100).toFixed(2);
176 }
177
178 showLoading() {
179 this.resultsContainer.innerHTML = `
180 <div class="search-loading">
181 <div class="spinner"></div>
182 <p>Searching...</p>
183 </div>
184 `;
185 this.show();
186 }
187
188 showNoResults() {
189 this.resultsContainer.innerHTML = `
190 <div class="search-no-results">
191 <p>No results found for "${this.currentQuery}"</p>
192 <p class="search-suggestion">Try different keywords or browse our collections</p>
193 </div>
194 `;
195 this.show();
196 }
197
198 showError() {
199 this.resultsContainer.innerHTML = `
200 <div class="search-error">
201 <p>Something went wrong. Please try again.</p>
202 </div>
203 `;
204 }
205
206 show() {
207 this.resultsContainer.classList.add('is-visible');
208 }
209
210 hide() {
211 this.resultsContainer.classList.remove('is-visible');
212 }
213
214 handleKeyboard(e) {
215 const items = this.resultsContainer.querySelectorAll('.search-item');
216
217 if (e.key === 'ArrowDown') {
218 e.preventDefault();
219 // Navigate down
220 } else if (e.key === 'ArrowUp') {
221 e.preventDefault();
222 // Navigate up
223 } else if (e.key === 'Escape') {
224 this.hide();
225 }
226 }
227}
228
229// Initialize
230document.addEventListener('DOMContentLoaded', () => {
231 new SearchAutocomplete({
232 input: '[data-search-input]',
233 results: '[data-search-results]',
234 minChars: 2,
235 limit: 8
236 });
237});

Shopify Search Autocomplete

Implement a predictive search feature that shows product suggestions, collections, and pages as users type in the search box.

// Search Autocomplete Class
class SearchAutocomplete {
    constructor(options = {}) {
        this.searchInput = document.querySelector(options.input || '[data-search-input]');
        this.resultsContainer = document.querySelector(options.results || '[data-search-results]');
        this.minChars = options.minChars || 2;
        this.debounceTime = options.debounceTime || 300;
        this.limit = options.limit || 10;

        this.debounceTimer = null;
        this.currentQuery = '';

        if (!this.searchInput || !this.resultsContainer) {
            console.error('Search elements not found');
            return;
        }

        this.init();
    }

    init() {
        // Input event
        this.searchInput.addEventListener('input', (e) => {
            this.handleInput(e.target.value);
        });

        // Focus event
        this.searchInput.addEventListener('focus', () => {
            if (this.currentQuery.length >= this.minChars) {
                this.show();
            }
        });

        // Click outside to close
        document.addEventListener('click', (e) => {
            if (!e.target.closest('.search-container')) {
                this.hide();
            }
        });

        // Keyboard navigation
        this.searchInput.addEventListener('keydown', (e) => {
            this.handleKeyboard(e);
        });
    }

    handleInput(value) {
        clearTimeout(this.debounceTimer);

        if (value.length < this.minChars) {
            this.hide();
            return;
        }

        this.debounceTimer = setTimeout(() => {
            this.search(value);
        }, this.debounceTime);
    }

    async search(query) {
        this.currentQuery = query;
        this.showLoading();

        try {
            const response = await fetch(`/search/suggest.json?q=${encodeURIComponent(query)}&resources[type]=product,page,article&resources[limit]=${this.limit}`);

            const data = await response.json();
            this.renderResults(data.resources.results);

        } catch (error) {
            console.error('Search error:', error);
            this.showError();
        }
    }

    renderResults(results) {
        this.resultsContainer.innerHTML = '';

        if (!results.products || results.products.length === 0) {
            this.showNoResults();
            return;
        }

        const html = `
            <div class="search-results">
                ${results.products ? this.renderProducts(results.products) : ''}
                ${results.pages ? this.renderPages(results.pages) : ''}
                ${results.articles ? this.renderArticles(results.articles) : ''}

                <div class="search-footer">
                    <a href="/search?q=${encodeURIComponent(this.currentQuery)}" class="view-all-link">
                        View all results →
                    </a>
                </div>
            </div>
        `;

        this.resultsContainer.innerHTML = html;
        this.show();
    }

    renderProducts(products) {
        if (!products.length) return '';

        return `
            <div class="search-section">
                <h4 class="search-section-title">Products</h4>
                <div class="search-items">
                    ${products.map(product => `
                        <a href="${product.url}" class="search-item">
                            ${product.featured_image ? `
                                <div class="search-item-image">
                                    <img src="${product.featured_image.url}" alt="${product.title}">
                                </div>
                            ` : ''}
                            <div class="search-item-details">
                                <h5 class="search-item-title">${this.highlight(product.title)}</h5>
                                <div class="search-item-price">
                                    ${this.formatPrice(product.price)}
                                </div>
                            </div>
                        </a>
                    `).join('')}
                </div>
            </div>
        `;
    }

    renderPages(pages) {
        if (!pages.length) return '';

        return `
            <div class="search-section">
                <h4 class="search-section-title">Pages</h4>
                <div class="search-items">
                    ${pages.map(page => `
                        <a href="${page.url}" class="search-item search-item--text">
                            <div class="search-item-details">
                                <h5 class="search-item-title">${this.highlight(page.title)}</h5>
                            </div>
                        </a>
                    `).join('')}
                </div>
            </div>
        `;
    }

    renderArticles(articles) {
        if (!articles.length) return '';

        return `
            <div class="search-section">
                <h4 class="search-section-title">Articles</h4>
                <div class="search-items">
                    ${articles.map(article => `
                        <a href="${article.url}" class="search-item search-item--text">
                            <div class="search-item-details">
                                <h5 class="search-item-title">${this.highlight(article.title)}</h5>
                            </div>
                        </a>
                    `).join('')}
                </div>
            </div>
        `;
    }

    highlight(text) {
        if (!this.currentQuery) return text;

        const regex = new RegExp(`(${this.currentQuery})`, 'gi');
        return text.replace(regex, '<mark>$1</mark>');
    }

    formatPrice(cents) {
        return '$' + (cents / 100).toFixed(2);
    }

    showLoading() {
        this.resultsContainer.innerHTML = `
            <div class="search-loading">
                <div class="spinner"></div>
                <p>Searching...</p>
            </div>
        `;
        this.show();
    }

    showNoResults() {
        this.resultsContainer.innerHTML = `
            <div class="search-no-results">
                <p>No results found for "${this.currentQuery}"</p>
                <p class="search-suggestion">Try different keywords or browse our collections</p>
            </div>
        `;
        this.show();
    }

    showError() {
        this.resultsContainer.innerHTML = `
            <div class="search-error">
                <p>Something went wrong. Please try again.</p>
            </div>
        `;
    }

    show() {
        this.resultsContainer.classList.add('is-visible');
    }

    hide() {
        this.resultsContainer.classList.remove('is-visible');
    }

    handleKeyboard(e) {
        const items = this.resultsContainer.querySelectorAll('.search-item');

        if (e.key === 'ArrowDown') {
            e.preventDefault();
            // Navigate down
        } else if (e.key === 'ArrowUp') {
            e.preventDefault();
            // Navigate up
        } else if (e.key === 'Escape') {
            this.hide();
        }
    }
}

// Initialize
document.addEventListener('DOMContentLoaded', () => {
    new SearchAutocomplete({
        input: '[data-search-input]',
        results: '[data-search-results]',
        minChars: 2,
        limit: 8
    });
});

Liquid Template

<!-- Search Container -->
<div class="search-container">
    <form action="/search" method="get" class="search-form">
        <input
            type="text"
            name="q"
            placeholder="Search products..."
            autocomplete="off"
            data-search-input
            class="search-input">

        <button type="submit" class="search-submit">
            <svg width="20" height="20" viewBox="0 0 20 20">
                <path d="M8 0a8 8 0 015.293 14.707l5.853 5.854-1.414 1.414-5.854-5.853A8 8 0 118 0zm0 2a6 6 0 100 12A6 6 0 008 2z"/>
            </svg>
        </button>
    </form>

    <div class="search-results-container" data-search-results></div>
</div>

CSS Styling

.search-container {
    position: relative;
    width: 100%;
    max-width: 600px;
}

.search-form {
    position: relative;
    display: flex;
    align-items: center;
}

.search-input {
    width: 100%;
    padding: 12px 50px 12px 20px;
    border: 2px solid #ddd;
    border-radius: 25px;
    font-size: 16px;
    transition: border-color 0.3s;
}

.search-input:focus {
    outline: none;
    border-color: #000;
}

.search-submit {
    position: absolute;
    right: 15px;
    background: none;
    border: none;
    cursor: pointer;
    padding: 5px;
}

/* Results Container */
.search-results-container {
    position: absolute;
    top: calc(100% + 10px);
    left: 0;
    right: 0;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 4px 20px rgba(0,0,0,0.1);
    max-height: 500px;
    overflow-y: auto;
    z-index: 1000;
    opacity: 0;
    visibility: hidden;
    transform: translateY(-10px);
    transition: all 0.3s ease;
}

.search-results-container.is-visible {
    opacity: 1;
    visibility: visible;
    transform: translateY(0);
}

/* Search Sections */
.search-section {
    padding: 15px;
    border-bottom: 1px solid #eee;
}

.search-section:last-child {
    border-bottom: none;
}

.search-section-title {
    font-size: 12px;
    text-transform: uppercase;
    color: #666;
    margin-bottom: 10px;
    font-weight: 600;
}

/* Search Items */
.search-item {
    display: flex;
    align-items: center;
    gap: 15px;
    padding: 10px;
    border-radius: 6px;
    text-decoration: none;
    color: #333;
    transition: background 0.2s;
}

.search-item:hover {
    background: #f5f5f5;
}

.search-item-image {
    flex-shrink: 0;
    width: 60px;
    height: 60px;
    border-radius: 4px;
    overflow: hidden;
}

.search-item-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.search-item-details {
    flex: 1;
}

.search-item-title {
    font-size: 14px;
    margin-bottom: 5px;
    font-weight: 500;
}

.search-item-title mark {
    background: #ffeb3b;
    padding: 2px 4px;
    border-radius: 2px;
}

.search-item-price {
    font-size: 14px;
    font-weight: bold;
    color: #000;
}

/* Loading State */
.search-loading {
    padding: 40px;
    text-align: center;
}

.spinner {
    border: 3px solid #f3f3f3;
    border-top: 3px solid #333;
    border-radius: 50%;
    width: 40px;
    height: 40px;
    animation: spin 1s linear infinite;
    margin: 0 auto 15px;
}

@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

/* No Results */
.search-no-results {
    padding: 40px 20px;
    text-align: center;
    color: #666;
}

.search-suggestion {
    font-size: 14px;
    margin-top: 10px;
}

/* Footer */
.search-footer {
    padding: 15px;
    background: #f9f9f9;
    border-top: 1px solid #eee;
    text-align: center;
}

.view-all-link {
    color: #0066cc;
    text-decoration: none;
    font-weight: 500;
}

.view-all-link:hover {
    text-decoration: underline;
}

Advanced: With Collections

// Include collections in search
async search(query) {
    this.currentQuery = query;
    this.showLoading();

    try {
        // Get products
        const productsResponse = await fetch(
            `/search/suggest.json?q=${encodeURIComponent(query)}&resources[type]=product&resources[limit]=5`
        );
        const productsData = await productsResponse.json();

        // Get collections
        const collectionsResponse = await fetch(
            `/search/suggest.json?q=${encodeURIComponent(query)}&resources[type]=collection&resources[limit]=3`
        );
        const collectionsData = await collectionsResponse.json();

        const results = {
            products: productsData.resources.results.products,
            collections: collectionsData.resources.results.collections
        };

        this.renderResults(results);

    } catch (error) {
        console.error('Search error:', error);
        this.showError();
    }
}

Features

  • Real-Time: Instant search results as you type
  • Debouncing: Optimized API calls
  • Multi-Resource: Search products, pages, articles, collections
  • Highlighting: Query terms highlighted in results
  • Keyboard Navigation: Arrow keys and ESC support
  • Responsive: Mobile-friendly design
  • Loading States: Visual feedback during search
  • Error Handling: Graceful failure messages

Related Snippets