Collection Pages and Merchandising Controls Intermediate 12 min read

Sorting UI and Wiring to Shopify

Build a sorting interface that connects to Shopify's collection sorting, handling URL parameters and dynamic updates.

Sorting helps customers find products that match their priorities. Shopify provides built-in sorting options that you can expose through your theme’s UI.

Shopify’s Sort Options

Collections support these sort options:

ValueDisplay Name
manualFeatured
best-sellingBest Selling
title-ascendingAlphabetically, A-Z
title-descendingAlphabetically, Z-A
price-ascendingPrice, Low to High
price-descendingPrice, High to Low
created-ascendingDate, Old to New
created-descendingDate, New to Old

How Sorting Works

Sorting uses URL parameters. When a customer selects a sort option, the page reloads with:

/collections/all?sort_by=price-ascending

Access the current sort in Liquid:

{{ collection.sort_by }} {# "price-ascending" #}
{{ collection.default_sort_by }} {# "manual" (or collection default) #}

Basic Sort Dropdown

{# snippets/collection-sorting.liquid #}
<div class="collection-sorting">
<label for="sort-by" class="collection-sorting__label">
Sort by
</label>
<select
id="sort-by"
class="collection-sorting__select"
onchange="window.location.href = this.value"
>
{%- assign sort_options = 'manual,best-selling,title-ascending,title-descending,price-ascending,price-descending,created-descending' | split: ',' -%}
{%- for option in sort_options -%}
{%- assign option_url = collection.url | append: '?sort_by=' | append: option -%}
<option
value="{{ option_url }}"
{% if collection.sort_by == option %}selected{% endif %}
>
{%- case option -%}
{%- when 'manual' -%}
Featured
{%- when 'best-selling' -%}
Best Selling
{%- when 'title-ascending' -%}
Alphabetically, A-Z
{%- when 'title-descending' -%}
Alphabetically, Z-A
{%- when 'price-ascending' -%}
Price, Low to High
{%- when 'price-descending' -%}
Price, High to Low
{%- when 'created-descending' -%}
Newest
{%- endcase -%}
</option>
{%- endfor -%}
</select>
</div>

Preserving Filter Parameters

When sorting, preserve any active filters:

{%- liquid
assign current_url = collection.url
# Check if filters are active
if collection.filters.size > 0
assign filter_params = ''
for filter in collection.filters
for value in filter.active_values
assign filter_params = filter_params | append: '&' | append: filter.param_name | append: '=' | append: value.param_name | url_encode
endfor
endfor
endif
-%}
<select onchange="window.location.href = this.value">
{%- for option in sort_options -%}
{%- assign option_url = current_url | append: '?sort_by=' | append: option | append: filter_params -%}
<option value="{{ option_url }}" {% if collection.sort_by == option %}selected{% endif %}>
...
</option>
{%- endfor -%}
</select>

Using collection.sort_options

Shopify provides a sort_options object:

<select id="sort-by" onchange="window.location.href = this.value">
{%- for option in collection.sort_options -%}
<option
value="{{ option.url }}"
{% if option.value == collection.sort_by %}selected{% endif %}
>
{{ option.name }}
</option>
{%- endfor -%}
</select>

This automatically includes the correct URLs with any existing parameters.

Styled Sort Component

{# snippets/collection-sorting.liquid #}
<div class="collection-sorting">
<div class="collection-sorting__wrapper">
<label for="sort-by" class="collection-sorting__label">
Sort by:
</label>
<div class="collection-sorting__select-wrapper">
<select id="sort-by" class="collection-sorting__select" data-sort-select>
{%- for option in collection.sort_options -%}
<option
value="{{ option.url }}"
{% if option.value == collection.sort_by %}selected{% endif %}
>
{{ option.name }}
</option>
{%- endfor -%}
</select>
<span class="collection-sorting__icon">
{% render 'icon-chevron-down' %}
</span>
</div>
</div>
</div>
.collection-sorting {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.collection-sorting__wrapper {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.collection-sorting__label {
font-size: 0.875rem;
color: var(--color-text-light);
white-space: nowrap;
}
.collection-sorting__select-wrapper {
position: relative;
}
.collection-sorting__select {
appearance: none;
padding: var(--spacing-sm) var(--spacing-xl) var(--spacing-sm) var(--spacing-sm);
font-size: 0.875rem;
font-weight: 500;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
cursor: pointer;
min-width: 180px;
}
.collection-sorting__select:focus {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.collection-sorting__icon {
position: absolute;
right: var(--spacing-sm);
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--color-text-light);
}
.collection-sorting__icon svg {
width: 16px;
height: 16px;
}

Button-Style Sort Selector

An alternative UI pattern:

<div class="sort-buttons" role="radiogroup" aria-label="Sort products">
{%- for option in collection.sort_options -%}
<a
href="{{ option.url }}"
class="sort-button {% if option.value == collection.sort_by %}sort-button--active{% endif %}"
role="radio"
aria-checked="{{ option.value == collection.sort_by }}"
>
{{ option.name }}
</a>
{%- endfor -%}
</div>
.sort-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.sort-button {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 0.8125rem;
color: var(--color-text-light);
text-decoration: none;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
transition: all 0.2s;
}
.sort-button:hover {
border-color: var(--color-text);
color: var(--color-text);
}
.sort-button--active {
background: var(--color-text);
color: var(--color-background);
border-color: var(--color-text);
}

AJAX Sorting (No Page Reload)

For a smoother experience, update the grid without a full page reload:

class CollectionSorting extends HTMLElement {
connectedCallback() {
this.select = this.querySelector('[data-sort-select]');
this.select.addEventListener('change', this.onSort.bind(this));
}
async onSort(event) {
const url = event.target.value;
// Update URL without reload
history.pushState({}, '', url);
// Show loading state
this.showLoading();
try {
// Fetch the new page content
const response = await fetch(url);
const html = await response.text();
// Parse and update the product grid
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newGrid = doc.querySelector('.product-grid');
const currentGrid = document.querySelector('.product-grid');
if (newGrid && currentGrid) {
currentGrid.innerHTML = newGrid.innerHTML;
}
// Update pagination
const newPagination = doc.querySelector('.pagination');
const currentPagination = document.querySelector('.pagination');
if (currentPagination) {
if (newPagination) {
currentPagination.innerHTML = newPagination.innerHTML;
} else {
currentPagination.remove();
}
}
} catch (error) {
console.error('Sort failed:', error);
// Fall back to page reload
window.location.href = url;
} finally {
this.hideLoading();
}
}
showLoading() {
document.querySelector('.product-grid')?.classList.add('is-loading');
}
hideLoading() {
document.querySelector('.product-grid')?.classList.remove('is-loading');
}
}
customElements.define('collection-sorting', CollectionSorting);
.product-grid.is-loading {
opacity: 0.5;
pointer-events: none;
}

Collection Toolbar

Combine sorting with product count and view options:

{# snippets/collection-toolbar.liquid #}
<div class="collection-toolbar">
<div class="collection-toolbar__left">
<p class="collection-toolbar__count">
{{ collection.products_count }}
{{ collection.products_count | pluralize: 'product', 'products' }}
</p>
</div>
<div class="collection-toolbar__right">
{# View toggle (grid/list) #}
{%- if section.settings.show_view_toggle -%}
<div class="collection-toolbar__view">
<button
class="view-toggle view-toggle--grid is-active"
aria-label="Grid view"
data-view="grid"
>
{% render 'icon-grid' %}
</button>
<button
class="view-toggle view-toggle--list"
aria-label="List view"
data-view="list"
>
{% render 'icon-list' %}
</button>
</div>
{%- endif -%}
{# Sort dropdown #}
{%- if section.settings.show_sort -%}
{% render 'collection-sorting' %}
{%- endif -%}
</div>
</div>
.collection-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
padding: var(--spacing-md) 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--spacing-lg);
}
.collection-toolbar__left {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.collection-toolbar__right {
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
.collection-toolbar__count {
font-size: 0.875rem;
color: var(--color-text-light);
margin: 0;
}
.collection-toolbar__view {
display: flex;
gap: var(--spacing-xs);
}
.view-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
cursor: pointer;
color: var(--color-text-light);
transition: all 0.2s;
}
.view-toggle:hover,
.view-toggle.is-active {
color: var(--color-text);
border-color: var(--color-text);
}
.view-toggle svg {
width: 18px;
height: 18px;
}
/* Mobile: Stack toolbar */
@media (max-width: 767px) {
.collection-toolbar {
flex-direction: column;
align-items: stretch;
}
.collection-toolbar__right {
justify-content: space-between;
}
}

Storing Sort Preference

Remember the user’s sort preference:

class CollectionSorting extends HTMLElement {
connectedCallback() {
this.select = this.querySelector('[data-sort-select]');
// Restore saved preference
this.restorePreference();
this.select.addEventListener('change', (e) => {
this.savePreference(e.target.value);
window.location.href = e.target.value;
});
}
savePreference(url) {
const sortBy = new URL(url, window.location.origin).searchParams.get('sort_by');
if (sortBy) {
localStorage.setItem('preferred_sort', sortBy);
}
}
restorePreference() {
// Only apply if no sort is specified in URL
if (window.location.search.includes('sort_by')) return;
const preferred = localStorage.getItem('preferred_sort');
if (preferred) {
const option = Array.from(this.select.options).find((opt) =>
opt.value.includes(`sort_by=${preferred}`)
);
if (option && !option.selected) {
window.location.href = option.value;
}
}
}
}

Practice Exercise

Build a collection toolbar that:

  1. Shows product count
  2. Has a sort dropdown with all options
  3. Preserves filters when sorting
  4. Shows a loading state during sort
  5. Works without JavaScript (fallback)

Test by:

  • Sorting different ways
  • Checking URL updates correctly
  • Combining with filters (if available)

Key Takeaways

  1. Use collection.sort_options for automatic URL handling
  2. Current sort: collection.sort_by
  3. Preserve existing parameters when changing sort
  4. Style the select for better UX
  5. Consider AJAX updates for smoother experience
  6. Combine in a toolbar with count and view options
  7. Store preferences for returning visitors
  8. Always work without JS as a fallback

What’s Next?

With sorting in place, the next lesson covers Filtering Concepts: Native Filters and Tags for more advanced product filtering.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...