Collection Pages and Merchandising Controls Advanced 15 min read

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:

  1. Go to Online Store → Navigation
  2. Click Collection and search filters
  3. Add filters (Product type, Vendor, Price, etc.)
  4. 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">&times;</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;
}
<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:

  1. Shows all available filters
  2. Uses checkboxes for list filters
  3. Has a price range with min/max inputs
  4. Displays active filters with remove buttons
  5. Includes a “Clear all” link
  6. Updates via AJAX without page reload

Key Takeaways

  1. Enable filters in admin under Navigation settings
  2. collection.filters provides all filter data
  3. Three filter types: list, price_range, boolean
  4. Use value.url_to_remove for remove links
  5. Preserve sort order when filtering
  6. Show active filters with clear options
  7. Consider layout: sidebar vs horizontal/drawer
  8. AJAX updates improve user experience
  9. 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...