Pagination vs Load More Patterns
Implement traditional pagination and infinite scroll/load more patterns for navigating collection products.
When collections have many products, you need a way to navigate through them. Let’s explore traditional pagination and modern “load more” patterns, with their tradeoffs.
The Paginate Object
Shopify’s paginate tag creates a pagination context:
{%- paginate collection.products by 24 -%} {# Products for current page #} {%- for product in collection.products -%} {{ product.title }} {%- endfor -%}
{# Pagination data #} {{ paginate.current_page }} {# 1, 2, 3... #} {{ paginate.pages }} {# Total pages #} {{ paginate.items }} {# Total products #} {{ paginate.page_size }} {# Products per page (24) #} {{ paginate.current_offset }} {# Starting index #}
{# Navigation #} {{ paginate.previous.url }} {# Previous page URL #} {{ paginate.next.url }} {# Next page URL #} {{ paginate.parts }} {# Array of page links #}{%- endpaginate -%}Basic Numbered Pagination
{# snippets/pagination.liquid #}
{%- if paginate.pages > 1 -%} <nav class="pagination" aria-label="Pagination"> <ul class="pagination__list"> {# Previous #} <li class="pagination__item"> {%- if paginate.previous -%} <a href="{{ paginate.previous.url }}" class="pagination__link pagination__link--prev" aria-label="Previous page" > {% render 'icon-chevron-left' %} <span>Previous</span> </a> {%- else -%} <span class="pagination__link pagination__link--prev pagination__link--disabled" aria-disabled="true"> {% render 'icon-chevron-left' %} <span>Previous</span> </span> {%- endif -%} </li>
{# Page numbers #} {%- for part in paginate.parts -%} <li class="pagination__item"> {%- if part.is_link -%} <a href="{{ part.url }}" class="pagination__link pagination__link--number" > {{ part.title }} </a> {%- elsif part.title == paginate.current_page -%} <span class="pagination__link pagination__link--number pagination__link--current" aria-current="page" > {{ part.title }} </span> {%- else -%} {# Ellipsis #} <span class="pagination__ellipsis">{{ part.title }}</span> {%- endif -%} </li> {%- endfor -%}
{# Next #} <li class="pagination__item"> {%- if paginate.next -%} <a href="{{ paginate.next.url }}" class="pagination__link pagination__link--next" aria-label="Next page" > <span>Next</span> {% render 'icon-chevron-right' %} </a> {%- else -%} <span class="pagination__link pagination__link--next pagination__link--disabled" aria-disabled="true"> <span>Next</span> {% render 'icon-chevron-right' %} </span> {%- endif -%} </li> </ul> </nav>{%- endif -%}Pagination Styles
.pagination { display: flex; justify-content: center; padding: var(--spacing-xl) 0;}
.pagination__list { display: flex; align-items: center; gap: var(--spacing-xs); list-style: none; padding: 0; margin: 0;}
.pagination__link { display: inline-flex; align-items: center; justify-content: center; min-width: 40px; height: 40px; padding: 0 var(--spacing-sm); font-size: 0.875rem; text-decoration: none; color: inherit; border: 1px solid var(--color-border); border-radius: var(--border-radius); transition: all 0.2s;}
.pagination__link:hover:not(.pagination__link--disabled):not(.pagination__link--current) { border-color: var(--color-text);}
.pagination__link--current { background: var(--color-text); color: var(--color-background); border-color: var(--color-text);}
.pagination__link--disabled { opacity: 0.4; cursor: not-allowed;}
.pagination__link--prev,.pagination__link--next { gap: var(--spacing-xs);}
.pagination__link svg { width: 16px; height: 16px;}
.pagination__ellipsis { padding: 0 var(--spacing-xs); color: var(--color-text-light);}
/* Mobile: Simpler pagination */@media (max-width: 639px) { .pagination__link--number { display: none; }
.pagination__link--current { display: inline-flex; }
.pagination__ellipsis { display: none; }}Simple Prev/Next Pagination
For a cleaner look:
{%- if paginate.pages > 1 -%} <nav class="pagination pagination--simple" aria-label="Pagination"> <div class="pagination__prev"> {%- if paginate.previous -%} <a href="{{ paginate.previous.url }}" class="pagination__button"> {% render 'icon-arrow-left' %} Previous </a> {%- endif -%} </div>
<div class="pagination__info"> Page {{ paginate.current_page }} of {{ paginate.pages }} </div>
<div class="pagination__next"> {%- if paginate.next -%} <a href="{{ paginate.next.url }}" class="pagination__button"> Next {% render 'icon-arrow-right' %} </a> {%- endif -%} </div> </nav>{%- endif -%}Load More Button
A button that loads additional products without leaving the page:
{%- paginate collection.products by section.settings.products_per_page -%} <div class="product-grid-container" data-collection-grid> <ul class="product-grid"> {%- for product in collection.products -%} <li class="product-grid__item"> {% render 'product-card', product: product %} </li> {%- endfor -%} </ul>
{%- if paginate.next -%} <div class="load-more"> <button class="load-more__button" data-load-more data-next-url="{{ paginate.next.url }}" > Load More Products </button>
<p class="load-more__info"> Showing {{ paginate.current_offset | plus: collection.products.size }} of {{ paginate.items }} products </p> </div> {%- endif -%} </div>{%- endpaginate -%}Load More JavaScript
class LoadMore extends HTMLElement { connectedCallback() { this.button = this.querySelector('[data-load-more]'); this.grid = document.querySelector('.product-grid'); this.container = document.querySelector('[data-collection-grid]');
this.button.addEventListener('click', this.loadMore.bind(this)); }
async loadMore() { const nextUrl = this.button.dataset.nextUrl; if (!nextUrl) return;
this.button.disabled = true; this.button.textContent = 'Loading...';
try { const response = await fetch(nextUrl); const html = await response.text();
const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html');
// Get new products const newProducts = doc.querySelectorAll('.product-grid__item');
// Append to existing grid newProducts.forEach((product) => { this.grid.appendChild(product); });
// Update load more button const newLoadMore = doc.querySelector('[data-load-more]');
if (newLoadMore) { this.button.dataset.nextUrl = newLoadMore.dataset.nextUrl; this.button.disabled = false; this.button.textContent = 'Load More Products';
// Update count const newInfo = doc.querySelector('.load-more__info'); if (newInfo) { this.querySelector('.load-more__info').innerHTML = newInfo.innerHTML; } } else { // No more products this.remove(); }
// Update URL for back button history.replaceState({}, '', nextUrl); } catch (error) { console.error('Load more failed:', error); this.button.disabled = false; this.button.textContent = 'Try Again'; } }}
customElements.define('load-more', LoadMore);{# Wrap the load more section #}<load-more class="load-more"> <button ...>Load More</button> <p class="load-more__info">...</p></load-more>Infinite Scroll
Automatically load products when scrolling near the bottom:
class InfiniteScroll extends HTMLElement { connectedCallback() { this.grid = document.querySelector('.product-grid'); this.loading = false; this.nextUrl = this.dataset.nextUrl;
if (!this.nextUrl) return;
// Create intersection observer this.observer = new IntersectionObserver(this.onIntersect.bind(this), { rootMargin: '200px' });
this.observer.observe(this); }
disconnectedCallback() { this.observer?.disconnect(); }
async onIntersect(entries) { if (!entries[0].isIntersecting || this.loading || !this.nextUrl) return;
this.loading = true; this.showLoader();
try { const response = await fetch(this.nextUrl); const html = await response.text();
const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html');
// Get new products const newProducts = doc.querySelectorAll('.product-grid__item'); newProducts.forEach((product) => this.grid.appendChild(product));
// Get next URL const nextTrigger = doc.querySelector('infinite-scroll'); this.nextUrl = nextTrigger?.dataset.nextUrl;
if (!this.nextUrl) { this.showComplete(); this.observer.disconnect(); } } catch (error) { console.error('Infinite scroll failed:', error); this.showError(); } finally { this.loading = false; this.hideLoader(); } }
showLoader() { this.querySelector('.infinite-scroll__loader').hidden = false; }
hideLoader() { this.querySelector('.infinite-scroll__loader').hidden = true; }
showComplete() { this.querySelector('.infinite-scroll__complete').hidden = false; }
showError() { this.querySelector('.infinite-scroll__error').hidden = false; }}
customElements.define('infinite-scroll', InfiniteScroll);{%- if paginate.next -%} <infinite-scroll class="infinite-scroll" data-next-url="{{ paginate.next.url }}" > <div class="infinite-scroll__loader" hidden> {% render 'loading-spinner' %} <span>Loading more products...</span> </div>
<div class="infinite-scroll__complete" hidden> <p>You've seen all {{ paginate.items }} products</p> </div>
<div class="infinite-scroll__error" hidden> <p>Failed to load more products</p> <button onclick="location.reload()">Refresh</button> </div> </infinite-scroll>{%- endif -%}Comparing Approaches
| Feature | Numbered Pagination | Load More | Infinite Scroll |
|---|---|---|---|
| SEO | Excellent (pages indexed) | Good (initial page) | Poor |
| UX | Familiar | Intuitive | Seamless |
| Performance | Low memory | Medium | High memory |
| Accessibility | Good | Good | Requires care |
| Back button | Works naturally | Needs handling | Tricky |
| Progress | Clear (page X of Y) | Clear | Unclear |
Hybrid Approach
Combine pagination with load more for best of both:
{%- if paginate.pages > 1 -%} <div class="pagination-wrapper"> {# Load more button (JavaScript enhanced) #} {%- if paginate.next -%} <load-more class="load-more"> <button class="load-more__button" data-load-more data-next-url="{{ paginate.next.url }}"> Load More Products </button> </load-more> {%- endif -%}
{# Fallback pagination (works without JS) #} <noscript> {% render 'pagination', paginate: paginate %} </noscript>
{# Or show both #} <details class="pagination-toggle"> <summary>Or browse by page</summary> {% render 'pagination', paginate: paginate %} </details> </div>{%- endif -%}Products Per Page Setting
Let merchants configure products per page:
{ "type": "range", "id": "products_per_page", "label": "Products per page", "min": 8, "max": 48, "step": 4, "default": 24}{%- paginate collection.products by section.settings.products_per_page -%}Scroll Position Restoration
When using load more, help users return to their position:
// Save scroll position before navigationwindow.addEventListener('beforeunload', () => { sessionStorage.setItem('scrollPos', window.scrollY); sessionStorage.setItem('scrollUrl', window.location.href);});
// Restore on page loaddocument.addEventListener('DOMContentLoaded', () => { const savedUrl = sessionStorage.getItem('scrollUrl'); const savedPos = sessionStorage.getItem('scrollPos');
if (savedUrl === window.location.href && savedPos) { window.scrollTo(0, parseInt(savedPos)); }
sessionStorage.removeItem('scrollPos'); sessionStorage.removeItem('scrollUrl');});Practice Exercise
Implement pagination that:
- Shows numbered pages on desktop
- Shows simple prev/next on mobile
- Has a “Load More” button alternative
- Shows progress (X of Y products)
- Works without JavaScript
Test by:
- Navigating through pages
- Using the load more button
- Disabling JavaScript
- Using back button after loading more
Key Takeaways
- Use
paginatetag for all pagination paginate.partsprovides page numbers and ellipsis- Numbered pagination is best for SEO and findability
- Load more provides smoother browsing experience
- Infinite scroll can hurt accessibility and SEO
- Hybrid approaches combine benefits
- Always provide fallback for no-JS
- Consider memory usage with many loaded products
- Handle back button properly with history API
What’s Next?
The final collection lesson covers Empty States and No-Results UX for handling collections with no products.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...