How to Add Infinite Scroll Pagination to Shopify Collection Pages

Quick Summary
- Horizon, Shopify's free theme, has infinite scroll built in: its collection section's Auto load products on scroll setting is on by default.
- In Dawn and other Online Store 2.0 themes, replace the pagination snippet with a next-page link and add a small script that fetches the next page with the Section Rendering API (section_id).
- The link still works without JavaScript; vintage themes can use the same script or the Ajaxinate library, with render instead of the deprecated include tag.
Infinite scroll loads the next page of products as visitors scroll down a Shopify collection page, instead of making them click through page numbers. If your store uses Horizon, Shopify’s free theme, it’s built in: its collection section has an “Auto load products on scroll” setting, which is on by default. In Dawn and other Online Store 2.0 themes, you can add it with a short script and no library: keep the theme’s paginate block and fetch each next page’s product grid through Shopify’s Section Rendering API.
How does it work in an Online Store 2.0 theme?
In Online Store 2.0 themes, templates/collection.json lists the sections of the collection page. In Dawn, the products come from the Product grid section, sections/main-collection-product-grid.liquid. Inside it:
- The product loop sits inside
{% paginate collection.products by section.settings.products_per_page %}, so the section’s “Products per page” setting decides how many products each page, and each load, has. - Each product card is a snippet, output with
{% render 'card-product', card_product: product, ... %}. Shopify has deprecated the include tag in favor ofrender, which only sees the variables you pass to the snippet. - The page numbers come from the
paginationsnippet, which reads the paginate object.paginate.nextonly exists when there is a next page, andpaginate.next.urlis that page’s address, with the current sort order and filters kept.
The Section Rendering API does the rest. Add section_id to any storefront URL, and Shopify returns only that section’s HTML, rendered for that URL and its page parameter. Shopify’s documentation gives paginating without a full page reload as an example use. So the script only has to request the next page’s product grid and move its product cards into the grid on the page.
Step 1: replace the page numbers with a next-page link
In your Shopify admin, go to Online Store, duplicate your theme as a backup, then open the theme’s menu and click Edit code. Shopify’s help notes that code changes that are incompatible with a theme update are removed from the updated copy, so keep a note of these edits.
Open sections/main-collection-product-grid.liquid and find the page numbers:
{%- if paginate.pages > 1 -%}
{% render 'pagination', paginate: paginate, anchor: '' %}
{%- endif -%}Replace them with a link to the next page:
{%- if paginate.next -%}
<infinite-scroll data-section-id="{{ section.id }}">
<a href="{{ paginate.next.url }}" class="button">Load more products</a>
</infinite-scroll>
{%- endif -%}Then load the script. At the top of the same file, next to the other stylesheet and script lines, add:
<script src="{{ 'infinite-scroll.js' | asset_url }}" defer="defer"></script>- On the last page
paginate.nextis empty, so nothing is output. section.idis the section’s ID. In a JSON template it looks liketemplate--15968257671257__product-grid, and the script needs it for the Section Rendering API.- The link is a normal link. Without JavaScript, or if the script fails, it still opens the next page, so visitors and search engines can reach every product.
class="button"gives it Dawn’s button style. asset_urlreturns the file’s address on Shopify’s CDN, anddeferruns the script after the page has been parsed, the way Dawn loads its own scripts.
Step 2: add the script
In the assets folder, create a file named infinite-scroll.js with this code:
if (!customElements.get('infinite-scroll')) {
customElements.define(
'infinite-scroll',
class InfiniteScroll extends HTMLElement {
connectedCallback() {
this.link = this.querySelector('a');
// Load the next page when the link comes within 600px of the screen.
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) this.loadNextPage();
},
{ rootMargin: '0px 0px 600px 0px' }
);
this.observer.observe(this);
}
disconnectedCallback() {
this.observer.disconnect();
}
async loadNextPage() {
if (this.loading) return;
this.loading = true;
// Ask Shopify for this section only, rendered for the next page.
const url = new URL(this.link.href);
url.searchParams.set('section_id', this.dataset.sectionId);
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const html = new DOMParser().parseFromString(await response.text(), 'text/html');
const products = html.getElementById('product-grid').children;
document.getElementById('product-grid').append(...products);
const nextLink = html.querySelector('infinite-scroll a');
if (!nextLink) {
this.remove(); // That was the last page.
return;
}
this.link.href = nextLink.getAttribute('href');
this.loading = false;
// Observe again, so the next page also loads if the link is still in view.
this.observer.unobserve(this);
this.observer.observe(this);
} catch (error) {
console.error('Infinite scroll:', error);
this.observer.disconnect(); // The link stays, so visitors can still open the next page.
}
}
}
);
}- The script defines a custom element,
<infinite-scroll>, the same pattern Dawn uses for its own components. The browser runsconnectedCallback()whenever such an element appears on the page, including after Dawn’s filters or sorting replace the product grid, so infinite scroll keeps working after a filter change. - An
IntersectionObservercallsloadNextPage()when the link comes within 600px of the bottom of the screen, so the next products usually arrive before the visitor reaches the end. loadNextPage()addssection_idto the link’s URL and fetches it.DOMParserturns the returned HTML into a document, and the script moves its product cards into the grid on the page and takes the next page’s link from it. When there is no further link, the element removes itself.- The
loadingflag stops a second request while one is running. After each page the element is observed again, so on a tall screen, where the link stays in view, the next page loads without waiting for a scroll. - If a request fails, for example with a 404 because the section ID is wrong, the script logs the error in the browser console and stops. The link stays, so visitors can still click through.
In a theme other than Dawn, use the section that holds the collection’s paginate block, put the link where its pagination was, and replace product-grid in the script with the ID of the element that directly contains the product cards.
Does it work?
Tested in headless Chrome 153 on Shopify’s Dawn demo store (Dawn 15.0.0), with the markup from step 1 added to the collection page and the script loaded: scrolling to the bottom sent one request, /collections/all?page=2&sort_by=price-ascending§ion_id=template--15968257671257__product-grid, and the grid grew from 16 to 32 products, all different. Page 2 was the last page, so the link then removed itself. Against a local three-page copy of the markup, scrolling loaded pages 2 and 3 one at a time, a very tall window loaded both without scrolling, replacing the section’s HTML the way a filter change does started infinite scroll again, and a wrong section ID left the link in place with “HTTP 404” in the console.
Shopify’s Theme Check (version 3.29.1) reports no problems in Dawn 16.0.0’s product grid section with these edits.
Older themes: the Ajaxinate library
Vintage themes, built before Online Store 2.0 with Liquid templates such as templates/collection.liquid, can use the same script, or Ajaxinate, the library an earlier version of this post used. Ajaxinate’s repository hasn’t been updated since December 2022, but it still works. See it in the demo store, and read the main article on HuraTips for the full walkthrough.
Download ajaxinate.min.js from the dist folder of the v2 branch (its documentation says to use that branch when you add the file without a package manager such as npm) and add it to your theme’s assets folder. Then, in the collection template, replace the product loop and pagination with this markup, inside the template’s existing paginate block:
<script src="{{ 'ajaxinate.min.js' | asset_url }}" defer="defer"></script>
<div id="collection-loop">
{% for product in collection.products %}
{% render 'product-grid-item', product: product %}
{% endfor %}
</div>
<div id="collection-pagination">
{% if paginate.next %}
<a href="{{ paginate.next.url }}">Loading More</a>
{% endif %}
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
new Ajaxinate({
container: '#collection-loop',
pagination: '#collection-pagination'
});
});
</script>- Use the name of your theme’s product card snippet instead of
product-grid-item. Becauserenderonly sees the variables you pass, pass the product, and any other variable the snippet reads, as inproduct: product. The old{% include 'product-grid-item' %}still works, but Theme Check reports “Use the ‘render’ tag instead of ‘include'”. - The
containerandpaginationselectors must match the two IDs in the markup. Without these options, Ajaxinate looks for#AjaxinateContainerand#AjaxinatePagination. - Deferred scripts run before the
DOMContentLoadedevent, soAjaxinateexists when the last lines start it. Theme Check flags thescript_tagfilter, which older guides use, as parser-blocking.
In the same headless Chrome, this markup loaded three pages of a local copy one by one as the page scrolled.
Collection pages with infinite scroll and custom product cards are the kind of theme work covered by my Shopify development services.
Related articles
- converting an old Shopify theme to Online Store 2.0: how JSON templates and sections replace Liquid templates
- refreshing part of a page with jQuery .load(): the same fetch-and-insert idea, done by hand
