Shopify Builds>LUS Brands>House pagination component with result counter for blog and collection grids

House Pagination Component With Result Counter for Blog and Collection Grids

A self-contained de_pagination snippet: pill page buttons, prev and next arrows that become non-focusable spans at the ends of the range, and a computed result-range line, rendered by the blog index and the collection grid from one file.

Shopify's paginate object tells a theme which page it's on and how many items exist, but not which items the shopper is looking at. de_pagination.liquid computes that range from the offset and page size, clamps it on the last page, and renders pill page controls where disabled arrows are non-focusable spans, the current page carries aria-current, and ellipses are hidden from assistive tech — one snippet dropped into both the blog and the collection grid.

The Problem

Dawn's pagination is a thin strip of arrows and numbers. It tells a shopper which page they're on and lets them move, and that is all it tells them. On a collection with a few dozen products across two pages, or a blog carrying nearly a hundred posts across ten, nothing on the page says how deep the list goes or where in it the shopper is standing — page three of what?

LUS wanted a clearer page control and an explicit count: how many results exist, and which slice of them is on screen right now. Both the blog index and the collection grid needed it, and they needed the same one, so the two listings a shopper pages through on the storefront behave identically.

The Constraint

Shopify paginates server-side, and Liquid's paginate object is the whole interface. It exposes parts (the page links, with ellipsis entries already inserted), current_page, pages, items, page_size and current_offset. What it does not expose is the range a shopper is looking at — the first and last item on this page — so that has to be computed, and computed carefully on the last page, where the naive offset-plus-page-size overshoots the total.

The component also had to work as a snippet, which means no schema and no settings of its own, and it had to drop into two sections with different paging: main-blog.liquid paginates at a fixed nine articles, main-collection-product-grid.liquid at a products_per_page setting. On the collection page there's a further constraint — the pagination sits inside #ProductGridContainer, the region the facet pipeline replaces on every filter change, so anything the snippet rendered had to arrive complete in the markup, with no script waiting to be rebound.

What We Built

de_pagination.liquid renders nothing at all when paginate.pages is one or fewer, so a short list never carries an empty control. Above one page it renders a <nav> whose aria-label comes from the theme's general.pagination.label locale string, holding a role="list" list of three kinds of item.

The arrows come first and last. When paginate.previous or paginate.next exists, the arrow is an <a> with an aria-label drawn from general.pagination.previous or general.pagination.next and an inline SVG chevron inside an aria-hidden span. When it doesn't exist — the first or last page — the same slot is rendered as a <span aria-disabled="true"> with the disabled class. It looks like a dimmed arrow and it is not a control: no href, no tab stop, nothing for a keyboard user to land on and discover does nothing.

Between them, paginate.parts is walked once. A part that is_link becomes a pill <a>. The part whose title equals paginate.current_page becomes a <span> carrying aria-current="page" and an aria-label built from general.pagination.page with the number substituted in, so assistive tech has a page name to announce rather than a bare digit. Anything else is an ellipsis part, rendered as a span with aria-hidden="true" so the dots are visual only.

The counter sits under the list. start is paginate.current_offset plus one; end is current_offset plus page_size, then clamped to paginate.items when it overshoots. On the live blog index that reads as items one to nine of eighty-seven; on the all-products collection, one to sixteen of twenty-nine. The snippet also accepts an anchor argument and appends it to every href it emits, so a caller can land the next page at a fragment rather than at the top of the document; both current call sites pass it empty.

There is no JavaScript in the component. On the collection page that matters: when the facets module swaps #ProductGridContainer through the Section Rendering API, the new pagination arrives inside the new markup, already correct for the filtered result count, with nothing to re-initialize.

Why This Way

Rendering a disabled arrow as a span rather than a disabled link was the decision that shaped the rest. A link with aria-disabled is still in the tab order. A span is out of the sequence entirely, so a keyboard user paging forward reaches the last page and finds the next control gone from the tab order, which is the honest state. The same reasoning makes the current page a span: there is no reason to offer a link to where the shopper already is.

Computing the range in Liquid, at render time, keeps the count and the controls in one server-rendered pass. Nothing has to count items in the browser, and a filtered collection reports the filtered total because the whole snippet is re-rendered with the grid.

The bill is a second pagination component in the theme. Dawn's stock pagination.liquid still serves the search results page, the collections list, article comments and the quick-order list, so two markups now express the same idea and a change to one has to be considered against the other. Dawn's snippet is untouched, and a theme update that replaces it does not reach this one.

Implementation Notes

  • Rendered from main-blog.liquid and main-collection-product-grid.liquid, both guarded by their own paginate.pages > 1 check on top of the snippet's, so the component can't render into a one-page listing from either caller.
  • The end of the range is clamped to paginate.items, so a last page holding three of a sixteen-per-page grid reads as the true final item rather than sixteen past the count.
  • The accessible labels — the nav label, previous, next, and the "Page N" pattern — all come from the theme's general.pagination.* locale keys.
  • The chevron SVGs sit inside aria-hidden spans, so an arrow link's accessible name is its aria-label and nothing else — the icon path never leaks into what assistive tech reads out.
  • Dawn's stock pagination.liquid remains in the theme unchanged apart from its stylesheet reference, and still serves the templates this snippet was not dropped into.
  • The anchor argument is appended to previous, next and every numbered link alike, so a caller that sets it gets consistent landing behavior across all three kinds of control.

Edge Cases

  • One page or fewer, and the whole component renders nothing — no empty nav, no counter reading one to nine of nine.
  • Ellipsis parts are aria-hidden and are not focusable, so a screen reader moving through the list hears page numbers and nothing between them.
  • At either end of the range the arrow degrades to an aria-disabled span that keeps the slot's width, so the row of pills doesn't shift between the first page and the second.
  • A last page that isn't full reports its true extent, because the range end is clamped to the item count before it's printed.

Platform Primitives Used

  • Liquid paginationpaginate.parts, current_page, current_offset, page_size and items are the entire data source; the range is arithmetic over them.
  • Liquid section schema and settings — the collection caller's products_per_page setting decides the page size the counter reports; the snippet itself carries no settings.

Where It Runs

Two surfaces from one file: the blog index template, paging articles nine at a time, and the collection template's product grid. On the collection page the snippet lives inside the container the facet pipeline replaces, so it is rendered again with every filter and sort change and reports the filtered total each time.

What This Demonstrates

How We Know

Two snippets read from the theme: the house component, at a little over two hundred lines, and Dawn's stock one alongside it, which differs from Dawn 15.4.1 only in a stylesheet reference and was read to confirm which two templates the house component took over and which the stock one still serves. The rendered output was checked on the live blog index and the all-products collection, where the counter reads as the arithmetic predicts. Documented from the code; no client-side record of the decision survives.

Related Builds

The Buy-vs-Build Question

Pagination is not a thing anyone buys an app for, and that's the point: the native paginate object is complete enough that the only question is how much a theme tells the reader with it. The wider case for building a blog on Shopify's own objects is in the editorial hub and blog at scale build-or-buy page.

Provenance & Evidence

  • Client: LUS Brands — loveurcurls.com
  • Surfaces: Blog, product listing page
  • Templates served: two — the blog index and the collection template
  • Complexity: Low
  • Scale: a little over two hundred lines in one snippet
  • Attribution: Deploi-authored. The snippet is ours; it runs inside a theme built on Dawn 15.4.1, and Dawn's own pagination snippet remains in place for the templates this one doesn't serve.
  • Status: Live, verified 2026-09-07
  • Evidence: two theme snippets read from the theme, and the rendered output checked on the live blog index and collection page
  • Confidence: Moderate — documented from the code; no client-side record of the decision survives
  • Primary capability: Editorial blog and content hub

Ready for Paging That Tells Readers Where They Are?

If your blog's page control says "next" and nothing else, your readers are guessing how much is behind it. Contact us today and we'll look at what your listings are leaving unsaid.

More builds