Filtering Concepts: Native Filters and Tags
Understand Shopify's native storefront filtering system, including filter types, active filter states, and building filter UI components.
Filtering lets customers narrow down products by attributes like size, color, price, and availability. Shopify’s Storefront Filtering provides a powerful, built-in system for faceted navigation.
Enabling Storefront Filtering
Before using filters, they must be enabled in the Shopify admin:
- Go to Online Store → Navigation
- Click Collection and search filters
- Add filters (Product type, Vendor, Price, etc.)
- Arrange the order
Available filter sources:
- Product availability
- Price range
- Product type
- Vendor
- Product tags
- Product options (Size, Color, etc.)
- Metafields
The collection.filters Object
When filtering is enabled, collection.filters contains all available filters:
{%- for filter in collection.filters -%} {{ filter.label }} {# "Size", "Color", "Price" #} {{ filter.type }} {# "list", "price_range", "boolean" #} {{ filter.param_name }} {# "filter.v.option.size" #} {{ filter.values }} {# Array of filter values #} {{ filter.active_values }} {# Currently selected values #}{%- endfor -%}Filter Types
List Filters
For discrete values (size, color, vendor):
{%- for filter in collection.filters -%} {%- if filter.type == 'list' -%} <div class="filter-group"> <h3 class="filter-group__title">{{ filter.label }}</h3>
<ul class="filter-group__list"> {%- for value in filter.values -%} <li class="filter-group__item"> <label class="filter-checkbox"> <input type="checkbox" name="{{ value.param_name }}" value="{{ value.value }}" {% if value.active %}checked{% endif %} {% if value.count == 0 and value.active == false %}disabled{% endif %} onchange="this.form.submit()" > <span class="filter-checkbox__label"> {{ value.label }} <span class="filter-checkbox__count">({{ value.count }})</span> </span> </label> </li> {%- endfor -%} </ul> </div> {%- endif -%}{%- endfor -%}Price Range Filters
{%- for filter in collection.filters -%} {%- if filter.type == 'price_range' -%} <div class="filter-group filter-group--price"> <h3 class="filter-group__title">{{ filter.label }}</h3>
<div class="price-range"> <div class="price-range__inputs"> <div class="price-range__field"> <label for="price-min">{{ 'Min' }}</label> <span class="price-range__currency">{{ cart.currency.symbol }}</span> <input type="number" id="price-min" name="{{ filter.min_value.param_name }}" value="{{ filter.min_value.value | divided_by: 100 }}" min="0" max="{{ filter.range_max | divided_by: 100 }}" placeholder="0" > </div>
<span class="price-range__separator">to</span>
<div class="price-range__field"> <label for="price-max">{{ 'Max' }}</label> <span class="price-range__currency">{{ cart.currency.symbol }}</span> <input type="number" id="price-max" name="{{ filter.max_value.param_name }}" value="{{ filter.max_value.value | divided_by: 100 }}" min="0" max="{{ filter.range_max | divided_by: 100 }}" placeholder="{{ filter.range_max | divided_by: 100 }}" > </div> </div>
<button type="submit" class="price-range__apply"> Apply </button> </div> </div> {%- endif -%}{%- endfor -%}Boolean Filters
For true/false values like availability:
{%- if filter.type == 'boolean' -%} <div class="filter-group filter-group--boolean"> <label class="filter-toggle"> <input type="checkbox" name="{{ filter.param_name }}" value="1" {% if filter.active_values.size > 0 %}checked{% endif %} onchange="this.form.submit()" > <span class="filter-toggle__label">{{ filter.label }}</span> </label> </div>{%- endif -%}Complete Filter Form
Wrap filters in a form:
{# snippets/collection-filters.liquid #}
<form class="collection-filters" method="get" action="{{ collection.url }}"> {# Preserve sort order #} {%- if collection.sort_by != collection.default_sort_by -%} <input type="hidden" name="sort_by" value="{{ collection.sort_by }}"> {%- endif -%}
{%- for filter in collection.filters -%} <div class="filter-group" data-filter-type="{{ filter.type }}"> <details class="filter-details" {% if filter.active_values.size > 0 %}open{% endif %}> <summary class="filter-summary"> <span class="filter-summary__label">{{ filter.label }}</span> {%- if filter.active_values.size > 0 -%} <span class="filter-summary__count">{{ filter.active_values.size }}</span> {%- endif -%} <span class="filter-summary__icon">{% render 'icon-chevron-down' %}</span> </summary>
<div class="filter-content"> {%- case filter.type -%} {%- when 'list' -%} {%- render 'filter-list', filter: filter -%} {%- when 'price_range' -%} {%- render 'filter-price-range', filter: filter -%} {%- when 'boolean' -%} {%- render 'filter-boolean', filter: filter -%} {%- endcase -%} </div> </details> </div> {%- endfor -%}
<noscript> <button type="submit" class="filter-apply">Apply Filters</button> </noscript></form>Active Filters Display
Show currently applied filters with remove buttons:
{# snippets/active-filters.liquid #}
{%- liquid assign active_filters_count = 0 for filter in collection.filters assign active_filters_count = active_filters_count | plus: filter.active_values.size endfor-%}
{%- if active_filters_count > 0 -%} <div class="active-filters"> <span class="active-filters__label">Active filters:</span>
<ul class="active-filters__list"> {%- for filter in collection.filters -%} {%- for value in filter.active_values -%} <li class="active-filters__item"> <a href="{{ value.url_to_remove }}" class="active-filter" > <span class="active-filter__label"> {{ filter.label }}: {{ value.label }} </span> <span class="active-filter__remove" aria-hidden="true">×</span> <span class="visually-hidden">Remove filter</span> </a> </li> {%- endfor -%} {%- endfor -%} </ul>
<a href="{{ collection.url }}" class="active-filters__clear"> Clear all </a> </div>{%- endif -%}Filter Styles
/* Filter group */.filter-group { border-bottom: 1px solid var(--color-border);}
.filter-details { padding: var(--spacing-md) 0;}
.filter-summary { display: flex; align-items: center; gap: var(--spacing-sm); cursor: pointer; list-style: none;}
.filter-summary::-webkit-details-marker { display: none;}
.filter-summary__label { font-weight: 600; font-size: 0.875rem;}
.filter-summary__count { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; font-size: 0.75rem; background: var(--color-primary); color: white; border-radius: 10px;}
.filter-summary__icon { margin-left: auto; transition: transform 0.2s;}
.filter-details[open] .filter-summary__icon { transform: rotate(180deg);}
.filter-content { padding-top: var(--spacing-sm);}
/* Checkbox filters */.filter-group__list { list-style: none; padding: 0; margin: 0; max-height: 250px; overflow-y: auto;}
.filter-checkbox { display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-xs) 0; cursor: pointer;}
.filter-checkbox input[type='checkbox'] { width: 18px; height: 18px; accent-color: var(--color-primary);}
.filter-checkbox__count { color: var(--color-text-light); font-size: 0.8125rem;}
.filter-checkbox input:disabled + .filter-checkbox__label { opacity: 0.5;}
/* Price range */.price-range__inputs { display: flex; align-items: center; gap: var(--spacing-sm);}
.price-range__field { position: relative; flex: 1;}
.price-range__field label { display: block; font-size: 0.75rem; color: var(--color-text-light); margin-bottom: var(--spacing-xs);}
.price-range__field input { width: 100%; padding: var(--spacing-sm); padding-left: var(--spacing-lg); border: 1px solid var(--color-border); border-radius: var(--border-radius);}
.price-range__currency { position: absolute; left: var(--spacing-sm); bottom: 10px; color: var(--color-text-light);}
.price-range__apply { margin-top: var(--spacing-sm); width: 100%;}
/* Active filters */.active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-md) 0;}
.active-filters__label { font-size: 0.875rem; color: var(--color-text-light);}
.active-filters__list { display: flex; flex-wrap: wrap; gap: var(--spacing-xs); list-style: none; padding: 0; margin: 0;}
.active-filter { display: inline-flex; align-items: center; gap: var(--spacing-xs); padding: var(--spacing-xs) var(--spacing-sm); font-size: 0.8125rem; background: var(--color-background-secondary); border-radius: var(--border-radius); text-decoration: none; color: inherit;}
.active-filter:hover { background: var(--color-border);}
.active-filter__remove { font-size: 1rem; line-height: 1;}
.active-filters__clear { font-size: 0.8125rem; color: var(--color-text-light); text-decoration: underline;}Sidebar vs Horizontal Filters
Sidebar Layout
<div class="collection-layout"> <aside class="collection-sidebar"> {% render 'collection-filters' %} </aside>
<main class="collection-main"> {% render 'active-filters' %} {% render 'product-grid' %} </main></div>Horizontal/Drawer Layout
<div class="collection-layout collection-layout--horizontal"> <div class="collection-toolbar"> <button class="filter-toggle-button" data-filter-toggle aria-expanded="false" aria-controls="filter-drawer" > {% render 'icon-filter' %} Filters {%- if active_filters_count > 0 -%} <span class="filter-toggle-button__count">{{ active_filters_count }}</span> {%- endif -%} </button>
{% render 'collection-sorting' %} </div>
{% render 'active-filters' %}
<div class="filter-drawer" id="filter-drawer" hidden> {% render 'collection-filters' %} </div>
{% render 'product-grid' %}</div>AJAX Filtering
Update products without page reload:
class CollectionFilters extends HTMLElement { connectedCallback() { this.form = this.querySelector('form'); this.form.addEventListener('change', this.onFilterChange.bind(this)); this.form.addEventListener('submit', this.onSubmit.bind(this)); }
onFilterChange(event) { // Debounce for price range inputs if (event.target.type === 'number') { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => this.applyFilters(), 500); } else { this.applyFilters(); } }
onSubmit(event) { event.preventDefault(); this.applyFilters(); }
async applyFilters() { const formData = new FormData(this.form); const params = new URLSearchParams(formData); const url = `${this.form.action}?${params}`;
// Update URL history.pushState({}, '', url);
// Show loading this.showLoading();
try { const response = await fetch(url); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html');
// Update product grid this.updateElement('.product-grid', doc);
// Update product count this.updateElement('.collection-toolbar__count', doc);
// Update active filters this.updateElement('.active-filters', doc);
// Update pagination this.updateElement('.pagination', doc);
// Update filter counts this.updateFilterCounts(doc); } catch (error) { console.error('Filter failed:', error); window.location.href = url; } finally { this.hideLoading(); } }
updateElement(selector, doc) { const newElement = doc.querySelector(selector); const currentElement = document.querySelector(selector);
if (currentElement && newElement) { currentElement.innerHTML = newElement.innerHTML; } else if (currentElement && !newElement) { currentElement.innerHTML = ''; } }
updateFilterCounts(doc) { doc.querySelectorAll('.filter-checkbox__count').forEach((newCount, index) => { const current = document.querySelectorAll('.filter-checkbox__count')[index]; if (current) current.textContent = newCount.textContent; }); }
showLoading() { document.querySelector('.product-grid')?.classList.add('is-loading'); }
hideLoading() { document.querySelector('.product-grid')?.classList.remove('is-loading'); }}
customElements.define('collection-filters', CollectionFilters);Tag-Based Filtering (Legacy)
Before native filtering, themes used product tags:
{# Legacy tag filtering #}{%- for tag in collection.all_tags -%} {%- if current_tags contains tag -%} <a href="{{ collection.url }}/{{ current_tags | remove: tag | join: '+' }}"> {{ tag }} ✕ </a> {%- else -%} <a href="{{ collection.url }}/{{ current_tags | join: '+' }}+{{ tag | handle }}"> {{ tag }} </a> {%- endif -%}{%- endfor -%}Native filtering is preferred, but tag filtering still works for simpler needs.
Practice Exercise
Build a filter sidebar that:
- Shows all available filters
- Uses checkboxes for list filters
- Has a price range with min/max inputs
- Displays active filters with remove buttons
- Includes a “Clear all” link
- Updates via AJAX without page reload
Key Takeaways
- Enable filters in admin under Navigation settings
collection.filtersprovides all filter data- Three filter types: list, price_range, boolean
- Use
value.url_to_removefor remove links - Preserve sort order when filtering
- Show active filters with clear options
- Consider layout: sidebar vs horizontal/drawer
- AJAX updates improve user experience
- Native filtering preferred over tag-based
What’s Next?
With filtering complete, the next lesson covers Pagination vs Load More Patterns for navigating through product results.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...