Skip to content

📚 pbPagination Class: AJAX Pagination, Filtering & Sorting Controller

📝 Description

pbPagination is a universal JavaScript class for managing dynamic content collections on web pages.

It implements: ✅ Pagination (standard, "Load More", infinite scroll) ✅ Multi-field filtering (selects, checkboxes, ranges) ✅ Sorting of lists/tables with active state tracking ✅ URL updates without full page reloads

Ideal for product catalogs, news feeds, blogs, or any list with AJAX loading.

🧱 The Markup It Relies On

The class creates nothing on its own — it finds existing elements by their attributes. Only the list container is required; everything else is wired up as needed.

SelectorRole
#pb-itemsList items container. Changed with the targetItems option. Without it nothing initializes.
[pb-pagination]Container for page links. Its contents are fully re-rendered by the server.
[data-page]A clickable link/button inside [pb-pagination]; its value is the page number.
[pb-loadmore]The "Load More" button.
[pb-total]Element where the total count is written (digits grouped with spaces).
[pb-sort]Sorting container: a select or radio buttons named after sortName.
[pb-filter]The filter form.
[pb-filter-change]Attribute on the filter form: apply the filter as soon as a field changes.
.pb-scroll-triggerSentinel for infinite scroll. Created automatically if missing.

Handlers on [pb-pagination] are delegated, so re-rendering the links does not break them. But [pb-loadmore], [pb-sort] and [pb-filter] are bound directly at initialization: these elements must already be in the DOM when the object is created.

⚙️ Initialization with the pbList Snippet

The standard scenario is to render the list with pbList and hand the class three numbers from the container's data attributes. The snippet puts the configuration key data-pb-key on #pb-items, and it must be sent back to the server in the request parameters:

html
<script>
    document.addEventListener('DOMContentLoaded', () => {
        const pbList = document.querySelector('#pb-items');
        window.pbPagination = new pbPagination({
            total: +(pbList?.dataset.pbTotal ?? 0),
            last_page: +(pbList?.dataset.pbLastPage ?? 1),
            queryParams: pbList?.dataset.pbKey ? { pb_key: pbList.dataset.pbKey } : {}
        });
    })
</script>

pb_key is an opaque key under which the server stored the query conditions. The client neither sees nor can forge the conditions themselves: it sends the key, and PbList::json() rebuilds the same query for the requested page. Lose the key and the server returns an empty response.

⚙️ Manual Setup

  1. Include the required scripts:
html
<script src="/assets/components/pageblocks/js/web/pb.message.v300.js"></script>
<script src="/assets/components/pageblocks/js/web/pb.fetch.v300.js"></script>
<script src="/assets/components/pageblocks/js/web/pb.pagination.v300.js"></script>

pbFetch is required — every request goes through it.

  1. Initialize the instance manually:
html
<script>
 document.addEventListener('DOMContentLoaded', () => {
   window.pbPagination = new pbPagination({
     page: 1,
     targetItems: '#blog-items',
     infiniteScroll: true,
     updateUrl: true
   });
 });
</script>

The server sets the number of items per page

There is no such setting on the client: pbPagination sends the page number, not the page size. The limit lives in the limit parameter of the pbList snippet (or in your own controller).

🗂 Constructor Options

List data

OptionDefaultDescription
urllocation.pathnameAddress the AJAX request goes to. Defaults to the current page.
page1Current page. Overridden by the URL parameter if present.
total0Total number of items. Sent to the server and refreshed from the response.
last_page1Last page. "Load More" and infinite scroll depend on it.
targetItems'#pb-items'Selector of the items container.
queryParams''Object of extra request parameters (this is where pb_key goes).

Sorting and filters

OptionDefaultDescription
sortby'menuindex'Sort field.
sortdir'asc'Direction.
sortName'sort'Name of the sort parameter in the URL and of the name attribute on sort controls.
sortValue''Current combined value such as price-asc.
sortNames''Aliases: 'cheap==price-asc,new==createdon-desc'. Let you keep a human-readable ?sort=cheap in the URL.
filters{}Initial set of filters. Extended with values from the current URL.
filterSeparator';'Filter separator in the path-style link variant.

Pagination and loading

OptionDefaultDescription
infiniteScroll0Enables infinite scroll.
infiniteOffsetScroll200How many pixels before the sentinel to start loading.
scrollToToptrueScroll to the top of the list when moving to another page.
scrollOffset0Top offset for that scroll — to account for a sticky header.

URL

OptionDefaultDescription
updateUrltrueUpdate the address bar via history.pushState.
pageName'page'Name of the page parameter.
pageLink'?{pageName}={page}'Page link template.
sortLink'?{sortName}={sort}'Sort link template.
filterLink'?{name}={value}'Filter link template.
fragment''Anchor appended to the request address.
baseUrlnullBase address instead of window.location.href.

The options offset, perPage and filterFields are present in the config but are not used by the code — they are a leftover from v2, do not rely on them.

🚦 Reading State from the URL

On creation the object parses the current address and adapts to it:

  • the sort parameter (?sort=price-asc) is split into sortby and sortdironly if the value contains a hyphen;
  • the page parameter (?page=3) becomes the current page;
  • every other parameter except pageName and sortName goes into filters and is highlighted in the filter form — but only if a [pb-filter] form exists on the page. Without it, URL parameters do not become filters.

That is why a page opened from a direct link or via the Back button shows the same selected values as before the reload.

🧩 Public Methods

1️⃣ loadPage(pageNumber, type = 'page', swap = 'innerHTML')

Loads a specific page and updates the DOM.

  • type — what to write into the address bar: page, sort or filter. It determines which link template is applied.
  • swap — how to insert the received markup: innerHTML (default), beforeend, afterbegin, beforebegin, afterend, outerHTML.
js
pbPagination.loadPage(2);                    // regular navigation
pbPagination.loadPage(2, 'page', 'beforeend'); // append to the end of the list

The method does not return a promise: it fires the request and finishes. Everything that happens after the response lives in the success callback.

2️⃣ next(swap = 'innerHTML')

Loads the next page. Does nothing on the last page.

js
pbPagination.next('beforeend');

3️⃣ prev()

Loads the previous page, never going below the first one.

js
pbPagination.prev();

4️⃣ sort(sortby, sortdir = '')

Applies sorting and returns the list to the first page.

js
pbPagination.sort('price', 'asc');
pbPagination.sort('cheap');   // alias from sortNames

If sortdir is neither asc nor desc, the first argument is looked up among sortNames. If nothing is found, the direction becomes asc.

5️⃣ filter(name = '', value = '')

Applies filters and returns the list to the first page.

js
// A single filter
pbPagination.filter('category', 'books');

// Multiple values of one filter
pbPagination.filter('brand', ['bosch', 'makita']);   // sent as bosch,makita

// A range
pbPagination.filter('price', '100-500');

// Apply whatever has already accumulated in config.filters
pbPagination.filter();

Empty values are dropped from the set — that is how a filter is cleared.

🔍 Filters

It is enough to mark the form with the pb-filter attribute; the rest is handled by the form's own events: submit applies the filters, reset clears them and reloads the list.

html
<form pb-filter>
  <select name="category">
    <option value="">All</option>
    <option value="books">Books</option>
  </select>

  <label><input type="checkbox" name="brand[]" value="bosch"> Bosch</label>
  <label><input type="checkbox" name="brand[]" value="makita"> Makita</label>

  <input type="number" name="price[min]" placeholder="from">
  <input type="number" name="price[max]" placeholder="to">

  <button type="submit">Show</button>
  <button type="reset">Reset</button>
</form>
  • Checkboxes. The name is written with []; when collected, the brackets are stripped and the checked values are joined with commas: brand=bosch,makita.
  • Ranges. A pair of [min] and [max] fields collapses into a single value price=100-500. If only "from" is filled in, "to" is set equal to it.
  • Instant apply. The pb-filter-change attribute on the form makes the filter apply on every field change, without a button:
html
<form pb-filter pb-filter-change> … </form>

The form is submitted together with the AJAX request, so the server also receives the fields the class does not parse separately.

↕️ Sorting

html
<div pb-sort>
  <select name="sort">
    <option value="menuindex-asc">Default order</option>
    <option value="price-asc">Cheapest first</option>
    <option value="createdon-desc">Newest first</option>
  </select>
</div>

The value is a combined field-direction pair; on change it is split into sortby and sortdir. Radio buttons with the same name work too. The active option is set automatically on page load, based on the address.

With sortNames aliases the URL keeps the short name:

js
new pbPagination({
  sortNames: 'cheap==price-asc,expensive==price-desc,new==createdon-desc'
});
html
<option value="cheap">Cheapest first</option>

➡ The address becomes ?sort=cheap, while sortby=price and sortdir=asc go to the server.

♾️ Three Loading Modes

Standard pagination

Nothing needs to be enabled. A click on [data-page] inside [pb-pagination] loads the page and replaces the list contents.

"Load More"

html
<button type="button" pb-loadmore>Load more</button>

The button calls next('beforeend') — new items are appended to the end. While the request is in flight the button gets a loader class. When there are no more pages, the button receives the pb-hide attribute and display: none.

Infinite scroll

js
new pbPagination({ infiniteScroll: 1, infiniteOffsetScroll: 300 });

An invisible .pb-scroll-trigger sentinel is added inside the container and watched by an IntersectionObserver. When the sentinel comes within infiniteOffsetScroll pixels, the next page is loaded. On the last page loading does not start, and there is a 200 ms lock between loads so that a single intersection does not fire two requests.

After each insertion the sentinel is moved to the end of the list — for this the class listens to the pb:after event from pbFetch and matches detail.target against its own targetItems.

⬆️ Scrolling to the Top of the List

With regular page navigation the whole list is replaced, and the visitor stays exactly where they clicked the button — that is, at the very bottom, which for the new list is already its end. So after the insertion the page is pulled up to the top of the list.

js
new pbPagination({
  scrollToTop: true,   // enabled by default
  scrollOffset: 80     // sticky header height
});

Fine points:

  • scrolling goes upwards only — if the top of the list is already visible, nothing moves;
  • for a table the target is the <table> itself, not the <tbody>, otherwise the header slides off the top of the screen;
  • with beforeend (that is, with "Load More" and infinite scroll) there is no scrolling — the reading position must not be disturbed there.

A working example that calculates the offset for a sticky menu:

js
const stickyBar = document.querySelector('.submenu');
const scrollOffset = stickyBar && getComputedStyle(stickyBar).position === 'sticky'
    ? stickyBar.offsetHeight + 16
    : 0;

new pbPagination({ scrollOffset });

🔗 Request and Response

The request goes through pbFetch.get() with expect: 'json' to the url address with the assembled query string. It includes: the current address parameters, queryParams, the page number, total, all filters, plus sortby and sortdir — the latter two only if the address does not already contain a sortName parameter. Empty values are dropped.

The expected response looks like this:

json
{
  "success": true,
  "data": "<article>…</article>",
  "links": "<ul class='pagination'>…</ul>",
  "total": 137,
  "current_page": 2,
  "last_page": 14
}
FieldWhere it goes
dataInserted into targetItems using the chosen method.
linksReplaces the contents of every [pb-pagination].
totalWritten into every [pb-total] with grouped digits: 1 234 567.
last_pageControls the "Load More" button and infinite scroll.
current_pageNot used by the client — it already knows the page number.

A failed request is logged to the console as pbPagination: loadPage failed; the markup stays as it was.

🧭 The Address Bar

With updateUrl: true, after every load the address is rewritten via history.pushState using the template that matches the action type (pageLink, sortLink, filterLink).

Template placeholders:

TemplateAvailable substitutions
pageLink{pageName}, {page}
sortLink{sortName}, {sort}, {sortby}, {sortdir}
filterLink{name}, {value}

Rules applied on top of the templates:

  • the first page is removed from the address — ?page=1 never appears;
  • changing sorting or filters resets the page number;
  • the sort parameter is always moved to the end of the query string, so that addresses are predictable;
  • a filter with an empty value is removed from the address.

The template can be path-based rather than a query string:

js
new pbPagination({
  pageLink: '/page/{page}',
  sortLink: '/sort/{sort}',
  filterLink: '/{name}-{value}'
});

➡ Page and sorting are appended to the current path: /catalog/page/3, /catalog/sort/price-asc. Old page and sort segments are cut out in the process, and double slashes are collapsed.

A path-based filterLink replaces the whole path

In this mode filters are assembled into the address /filter/brand-bosch;price-100-500 — from the root, not from the current section: /catalog disappears from the path. This works when a dedicated route is reserved for filtering; for filters inside a section keep filterLink as a query string.

The Back button does not restore state

pushState writes the address, but the class has no popstate handler. Going back changes the address bar while the list stays as it was — until the page is reloaded. If you need honest history navigation, attach popstate yourself and call loadPage().

✅ Key Benefits

🚀 No page reloads 🔍 SEO-friendly URLs ⚙️ Flexible configuration 🔧 Easy to extend

🏁 Conclusion

pbPagination is a turnkey solution for dynamic content management with clean integration and a set of convenient control methods.

© PageBlocks 2019-present