JAVASCRIPTshopifyadvanced

Shopify AJAX Collection Filters

Add dynamic filtering to collection pages without page reloads

#shopify#filters#ajax#collections#faceted-search
Share this snippet:

Code

javascript
1// Collection Filters Class
2class CollectionFilters {
3 constructor(options = {}) {
4 this.container = document.querySelector(options.container || '[data-collection-container]');
5 this.filtersForm = document.querySelector(options.filtersForm || '[data-filters-form]');
6 this.loadingEl = document.querySelector(options.loading || '[data-loading]');
7
8 if (!this.container || !this.filtersForm) {
9 console.error('Required elements not found');
10 return;
11 }
12
13 this.isLoading = false;
14 this.currentUrl = new URL(window.location.href);
15
16 this.init();
17 }
18
19 init() {
20 // Handle filter changes
21 this.filtersForm.addEventListener('change', (e) => {
22 if (e.target.matches('[data-filter-input]')) {
23 this.applyFilters();
24 }
25 });
26
27 // Handle price range
28 const priceInputs = this.filtersForm.querySelectorAll('[data-price-input]');
29 priceInputs.forEach(input => {
30 input.addEventListener('input', () => this.debounce(() => this.applyFilters(), 500));
31 });
32
33 // Handle sort change
34 const sortSelect = document.querySelector('[data-sort-select]');
35 if (sortSelect) {
36 sortSelect.addEventListener('change', () => this.applyFilters());
37 }
38
39 // Clear filters
40 const clearBtn = document.querySelector('[data-clear-filters]');
41 if (clearBtn) {
42 clearBtn.addEventListener('click', () => this.clearFilters());
43 }
44
45 // Initial active filters display
46 this.updateActiveFilters();
47 }
48
49 async applyFilters() {
50 if (this.isLoading) return;
51
52 this.isLoading = true;
53 this.showLoading();
54
55 // Build filter URL
56 const url = this.buildFilterUrl();
57
58 try {
59 const response = await fetch(url);
60 const html = await response.text();
61
62 const parser = new DOMParser();
63 const doc = parser.parseFromString(html, 'text/html');
64
65 // Update products
66 const newProducts = doc.querySelector('[data-collection-container]');
67 if (newProducts) {
68 this.container.innerHTML = newProducts.innerHTML;
69 }
70
71 // Update URL without reload
72 window.history.pushState({}, '', url);
73
74 // Update active filters display
75 this.updateActiveFilters();
76
77 // Update product count
78 this.updateProductCount(doc);
79
80 } catch (error) {
81 console.error('Filter error:', error);
82 } finally {
83 this.isLoading = false;
84 this.hideLoading();
85 }
86 }
87
88 buildFilterUrl() {
89 const url = new URL(this.currentUrl);
90 const formData = new FormData(this.filtersForm);
91
92 // Clear existing params
93 url.search = '';
94
95 // Add tags
96 const tags = [];
97 formData.getAll('tag').forEach(tag => {
98 if (tag) tags.push(tag);
99 });
100
101 if (tags.length > 0) {
102 url.searchParams.set('filter.v.tag', tags.join('+'));
103 }
104
105 // Add price range
106 const priceMin = formData.get('price_min');
107 const priceMax = formData.get('price_max');
108
109 if (priceMin) {
110 url.searchParams.set('filter.v.price.gte', parseInt(priceMin) * 100);
111 }
112
113 if (priceMax) {
114 url.searchParams.set('filter.v.price.lte', parseInt(priceMax) * 100);
115 }
116
117 // Add variant options (size, color, etc.)
118 const options = formData.getAll('option');
119 if (options.length > 0) {
120 url.searchParams.set('filter.v.option', options.join('+'));
121 }
122
123 // Add sort
124 const sort = document.querySelector('[data-sort-select]')?.value;
125 if (sort) {
126 url.searchParams.set('sort_by', sort);
127 }
128
129 return url.toString();
130 }
131
132 updateActiveFilters() {
133 const activeContainer = document.querySelector('[data-active-filters]');
134 if (!activeContainer) return;
135
136 const formData = new FormData(this.filtersForm);
137 const activeFilters = [];
138
139 // Collect active filters
140 formData.getAll('tag').forEach(tag => {
141 if (tag) {
142 const label = this.filtersForm.querySelector(`[value="${tag}"]`)?.dataset.label || tag;
143 activeFilters.push({ type: 'tag', value: tag, label });
144 }
145 });
146
147 const priceMin = formData.get('price_min');
148 const priceMax = formData.get('price_max');
149
150 if (priceMin || priceMax) {
151 const label = `Price: $${priceMin || '0'} - $${priceMax || '∞'}`;
152 activeFilters.push({ type: 'price', value: 'price', label });
153 }
154
155 // Render active filters
156 if (activeFilters.length > 0) {
157 activeContainer.innerHTML = `
158 <div class="active-filters">
159 <span class="active-filters-label">Active Filters:</span>
160 ${activeFilters.map(filter => `
161 <button
162 type="button"
163 class="active-filter-tag"
164 data-remove-filter="${filter.type}"
165 data-filter-value="${filter.value}">
166 ${filter.label}
167 <span>×</span>
168 </button>
169 `).join('')}
170 <button type="button" class="clear-all-filters" data-clear-filters>
171 Clear All
172 </button>
173 </div>
174 `;
175
176 // Bind remove events
177 activeContainer.querySelectorAll('[data-remove-filter]').forEach(btn => {
178 btn.addEventListener('click', (e) => {
179 this.removeFilter(e.target.dataset.removeFilter, e.target.dataset.filterValue);
180 });
181 });
182 } else {
183 activeContainer.innerHTML = '';
184 }
185 }
186
187 removeFilter(type, value) {
188 if (type === 'tag') {
189 const checkbox = this.filtersForm.querySelector(`[value="${value}"]`);
190 if (checkbox) {
191 checkbox.checked = false;
192 }
193 } else if (type === 'price') {
194 this.filtersForm.querySelector('[name="price_min"]').value = '';
195 this.filtersForm.querySelector('[name="price_max"]').value = '';
196 }
197
198 this.applyFilters();
199 }
200
201 clearFilters() {
202 // Reset all inputs
203 this.filtersForm.reset();
204
205 // Clear URL params
206 window.history.pushState({}, '', this.currentUrl.pathname);
207
208 // Reload products
209 this.applyFilters();
210 }
211
212 updateProductCount(doc) {
213 const countEl = document.querySelector('[data-product-count]');
214 if (!countEl) return;
215
216 const newCount = doc.querySelector('[data-product-count]');
217 if (newCount) {
218 countEl.textContent = newCount.textContent;
219 }
220 }
221
222 showLoading() {
223 if (this.loadingEl) {
224 this.loadingEl.style.display = 'block';
225 }
226
227 this.container.style.opacity = '0.5';
228 this.container.style.pointerEvents = 'none';
229 }
230
231 hideLoading() {
232 if (this.loadingEl) {
233 this.loadingEl.style.display = 'none';
234 }
235
236 this.container.style.opacity = '1';
237 this.container.style.pointerEvents = 'auto';
238 }
239
240 debounce(func, wait) {
241 clearTimeout(this.debounceTimer);
242 this.debounceTimer = setTimeout(func, wait);
243 }
244}
245
246// Initialize
247document.addEventListener('DOMContentLoaded', () => {
248 new CollectionFilters();
249});

Shopify AJAX Collection Filters

Implement dynamic product filtering on collection pages with real-time updates, allowing customers to filter by tags, price ranges, and product options without page reloads.

// Collection Filters Class
class CollectionFilters {
    constructor(options = {}) {
        this.container = document.querySelector(options.container || '[data-collection-container]');
        this.filtersForm = document.querySelector(options.filtersForm || '[data-filters-form]');
        this.loadingEl = document.querySelector(options.loading || '[data-loading]');

        if (!this.container || !this.filtersForm) {
            console.error('Required elements not found');
            return;
        }

        this.isLoading = false;
        this.currentUrl = new URL(window.location.href);

        this.init();
    }

    init() {
        // Handle filter changes
        this.filtersForm.addEventListener('change', (e) => {
            if (e.target.matches('[data-filter-input]')) {
                this.applyFilters();
            }
        });

        // Handle price range
        const priceInputs = this.filtersForm.querySelectorAll('[data-price-input]');
        priceInputs.forEach(input => {
            input.addEventListener('input', () => this.debounce(() => this.applyFilters(), 500));
        });

        // Handle sort change
        const sortSelect = document.querySelector('[data-sort-select]');
        if (sortSelect) {
            sortSelect.addEventListener('change', () => this.applyFilters());
        }

        // Clear filters
        const clearBtn = document.querySelector('[data-clear-filters]');
        if (clearBtn) {
            clearBtn.addEventListener('click', () => this.clearFilters());
        }

        // Initial active filters display
        this.updateActiveFilters();
    }

    async applyFilters() {
        if (this.isLoading) return;

        this.isLoading = true;
        this.showLoading();

        // Build filter URL
        const url = this.buildFilterUrl();

        try {
            const response = await fetch(url);
            const html = await response.text();

            const parser = new DOMParser();
            const doc = parser.parseFromString(html, 'text/html');

            // Update products
            const newProducts = doc.querySelector('[data-collection-container]');
            if (newProducts) {
                this.container.innerHTML = newProducts.innerHTML;
            }

            // Update URL without reload
            window.history.pushState({}, '', url);

            // Update active filters display
            this.updateActiveFilters();

            // Update product count
            this.updateProductCount(doc);

        } catch (error) {
            console.error('Filter error:', error);
        } finally {
            this.isLoading = false;
            this.hideLoading();
        }
    }

    buildFilterUrl() {
        const url = new URL(this.currentUrl);
        const formData = new FormData(this.filtersForm);

        // Clear existing params
        url.search = '';

        // Add tags
        const tags = [];
        formData.getAll('tag').forEach(tag => {
            if (tag) tags.push(tag);
        });

        if (tags.length > 0) {
            url.searchParams.set('filter.v.tag', tags.join('+'));
        }

        // Add price range
        const priceMin = formData.get('price_min');
        const priceMax = formData.get('price_max');

        if (priceMin) {
            url.searchParams.set('filter.v.price.gte', parseInt(priceMin) * 100);
        }

        if (priceMax) {
            url.searchParams.set('filter.v.price.lte', parseInt(priceMax) * 100);
        }

        // Add variant options (size, color, etc.)
        const options = formData.getAll('option');
        if (options.length > 0) {
            url.searchParams.set('filter.v.option', options.join('+'));
        }

        // Add sort
        const sort = document.querySelector('[data-sort-select]')?.value;
        if (sort) {
            url.searchParams.set('sort_by', sort);
        }

        return url.toString();
    }

    updateActiveFilters() {
        const activeContainer = document.querySelector('[data-active-filters]');
        if (!activeContainer) return;

        const formData = new FormData(this.filtersForm);
        const activeFilters = [];

        // Collect active filters
        formData.getAll('tag').forEach(tag => {
            if (tag) {
                const label = this.filtersForm.querySelector(`[value="${tag}"]`)?.dataset.label || tag;
                activeFilters.push({ type: 'tag', value: tag, label });
            }
        });

        const priceMin = formData.get('price_min');
        const priceMax = formData.get('price_max');

        if (priceMin || priceMax) {
            const label = `Price: $${priceMin || '0'} - $${priceMax || '∞'}`;
            activeFilters.push({ type: 'price', value: 'price', label });
        }

        // Render active filters
        if (activeFilters.length > 0) {
            activeContainer.innerHTML = `
                <div class="active-filters">
                    <span class="active-filters-label">Active Filters:</span>
                    ${activeFilters.map(filter => `
                        <button
                            type="button"
                            class="active-filter-tag"
                            data-remove-filter="${filter.type}"
                            data-filter-value="${filter.value}">
                            ${filter.label}
                            <span>×</span>
                        </button>
                    `).join('')}
                    <button type="button" class="clear-all-filters" data-clear-filters>
                        Clear All
                    </button>
                </div>
            `;

            // Bind remove events
            activeContainer.querySelectorAll('[data-remove-filter]').forEach(btn => {
                btn.addEventListener('click', (e) => {
                    this.removeFilter(e.target.dataset.removeFilter, e.target.dataset.filterValue);
                });
            });
        } else {
            activeContainer.innerHTML = '';
        }
    }

    removeFilter(type, value) {
        if (type === 'tag') {
            const checkbox = this.filtersForm.querySelector(`[value="${value}"]`);
            if (checkbox) {
                checkbox.checked = false;
            }
        } else if (type === 'price') {
            this.filtersForm.querySelector('[name="price_min"]').value = '';
            this.filtersForm.querySelector('[name="price_max"]').value = '';
        }

        this.applyFilters();
    }

    clearFilters() {
        // Reset all inputs
        this.filtersForm.reset();

        // Clear URL params
        window.history.pushState({}, '', this.currentUrl.pathname);

        // Reload products
        this.applyFilters();
    }

    updateProductCount(doc) {
        const countEl = document.querySelector('[data-product-count]');
        if (!countEl) return;

        const newCount = doc.querySelector('[data-product-count]');
        if (newCount) {
            countEl.textContent = newCount.textContent;
        }
    }

    showLoading() {
        if (this.loadingEl) {
            this.loadingEl.style.display = 'block';
        }

        this.container.style.opacity = '0.5';
        this.container.style.pointerEvents = 'none';
    }

    hideLoading() {
        if (this.loadingEl) {
            this.loadingEl.style.display = 'none';
        }

        this.container.style.opacity = '1';
        this.container.style.pointerEvents = 'auto';
    }

    debounce(func, wait) {
        clearTimeout(this.debounceTimer);
        this.debounceTimer = setTimeout(func, wait);
    }
}

// Initialize
document.addEventListener('DOMContentLoaded', () => {
    new CollectionFilters();
});

Liquid Template - Filters Sidebar

<!-- Filters Form -->
<form class="collection-filters" data-filters-form>
    <!-- Tags Filter -->
    <div class="filter-group">
        <h3 class="filter-title">Category</h3>

        {% for tag in collection.all_tags %}
            <label class="filter-option">
                <input
                    type="checkbox"
                    name="tag"
                    value="{{ tag | handle }}"
                    data-label="{{ tag }}"
                    data-filter-input
                    {% if current_tags contains tag %}checked{% endif %}>
                <span>{{ tag }}</span>
                <span class="filter-count">({{ collection.all_products_count }})</span>
            </label>
        {% endfor %}
    </div>

    <!-- Price Filter -->
    <div class="filter-group">
        <h3 class="filter-title">Price Range</h3>

        <div class="price-range">
            <input
                type="number"
                name="price_min"
                placeholder="Min"
                min="0"
                data-price-input
                class="price-input">

            <span>-</span>

            <input
                type="number"
                name="price_max"
                placeholder="Max"
                min="0"
                data-price-input
                class="price-input">
        </div>
    </div>

    <!-- Color Filter (for products with Color option) -->
    <div class="filter-group">
        <h3 class="filter-title">Color</h3>

        <div class="color-swatches">
            {% assign color_values = '' %}

            {% for product in collection.products %}
                {% for option in product.options_with_values %}
                    {% if option.name == 'Color' %}
                        {% for value in option.values %}
                            {% unless color_values contains value %}
                                {% assign color_values = color_values | append: value | append: ',' %}

                                <label class="color-swatch">
                                    <input
                                        type="checkbox"
                                        name="option"
                                        value="{{ value | handle }}"
                                        data-label="{{ value }}"
                                        data-filter-input>
                                    <span class="color-swatch-inner" style="background: {{ value | downcase }}">
                                        {{ value }}
                                    </span>
                                </label>
                            {% endunless %}
                        {% endfor %}
                    {% endif %}
                {% endfor %}
            {% endfor %}
        </div>
    </div>
</form>

<!-- Sort Dropdown -->
<select data-sort-select class="sort-select">
    <option value="manual">Featured</option>
    <option value="best-selling">Best Selling</option>
    <option value="title-ascending">A-Z</option>
    <option value="title-descending">Z-A</option>
    <option value="price-ascending">Price: Low to High</option>
    <option value="price-descending">Price: High to Low</option>
    <option value="created-descending">Newest</option>
</select>

<!-- Active Filters -->
<div data-active-filters></div>

<!-- Product Count -->
<p><span data-product-count>{{ collection.products_count }}</span> Products</p>

<!-- Products Grid -->
<div class="products-grid" data-collection-container>
    {% for product in collection.products %}
        <div class="product-card">
            <!-- Product card content -->
        </div>
    {% endfor %}
</div>

<!-- Loading Indicator -->
<div class="filters-loading" data-loading style="display: none;">
    <div class="spinner"></div>
</div>

CSS Styling

.collection-filters {
    padding: 20px;
    background: #f9f9f9;
    border-radius: 8px;
}

.filter-group {
    margin-bottom: 30px;
    padding-bottom: 20px;
    border-bottom: 1px solid #e0e0e0;
}

.filter-group:last-child {
    border-bottom: none;
}

.filter-title {
    font-size: 16px;
    font-weight: 600;
    margin-bottom: 15px;
}

.filter-option {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 8px 0;
    cursor: pointer;
}

.filter-option input[type="checkbox"] {
    cursor: pointer;
}

.filter-count {
    margin-left: auto;
    color: #666;
    font-size: 14px;
}

/* Price Range */
.price-range {
    display: flex;
    align-items: center;
    gap: 10px;
}

.price-input {
    width: 100%;
    padding: 8px;
    border: 1px solid #ddd;
    border-radius: 4px;
}

/* Color Swatches */
.color-swatches {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
}

.color-swatch input {
    display: none;
}

.color-swatch-inner {
    display: block;
    width: 40px;
    height: 40px;
    border-radius: 50%;
    border: 2px solid #ddd;
    cursor: pointer;
    transition: border-color 0.2s;
    text-indent: -9999px;
}

.color-swatch input:checked + .color-swatch-inner {
    border-color: #000;
    box-shadow: 0 0 0 2px #fff, 0 0 0 4px #000;
}

/* Active Filters */
.active-filters {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    align-items: center;
    padding: 15px;
    background: #f0f0f0;
    border-radius: 6px;
    margin-bottom: 20px;
}

.active-filters-label {
    font-weight: 600;
    margin-right: 5px;
}

.active-filter-tag {
    padding: 5px 10px;
    background: #fff;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 14px;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 5px;
}

.clear-all-filters {
    padding: 5px 15px;
    background: #000;
    color: #fff;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 14px;
}

/* Loading */
.filters-loading {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 1000;
}

Features

  • Real-Time Filtering: Instant results without page reload
  • Multiple Filter Types: Tags, price, variants, custom options
  • Active Filters Display: Shows selected filters with remove option
  • Sort Integration: Combine filtering with sorting
  • URL Updates: Updates URL for sharing and back button support
  • Product Count: Shows number of matching products
  • Responsive: Mobile-friendly design
  • Debounced Inputs: Optimized for price range inputs

Related Snippets