📚 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.
| Selector | Role |
|---|---|
#pb-items | List 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-trigger | Sentinel 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:
<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
- Include the required scripts:
<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.
- Initialize the instance manually:
<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
| Option | Default | Description |
|---|---|---|
url | location.pathname | Address the AJAX request goes to. Defaults to the current page. |
page | 1 | Current page. Overridden by the URL parameter if present. |
total | 0 | Total number of items. Sent to the server and refreshed from the response. |
last_page | 1 | Last 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
| Option | Default | Description |
|---|---|---|
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
| Option | Default | Description |
|---|---|---|
infiniteScroll | 0 | Enables infinite scroll. |
infiniteOffsetScroll | 200 | How many pixels before the sentinel to start loading. |
scrollToTop | true | Scroll to the top of the list when moving to another page. |
scrollOffset | 0 | Top offset for that scroll — to account for a sticky header. |
URL
| Option | Default | Description |
|---|---|---|
updateUrl | true | Update 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. |
baseUrl | null | Base 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 intosortbyandsortdir— only if the value contains a hyphen; - the page parameter (
?page=3) becomes the current page; - every other parameter except
pageNameandsortNamegoes intofiltersand 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,sortorfilter. It determines which link template is applied.swap— how to insert the received markup:innerHTML(default),beforeend,afterbegin,beforebegin,afterend,outerHTML.
pbPagination.loadPage(2); // regular navigation
pbPagination.loadPage(2, 'page', 'beforeend'); // append to the end of the listThe 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.
pbPagination.next('beforeend');3️⃣ prev()
Loads the previous page, never going below the first one.
pbPagination.prev();4️⃣ sort(sortby, sortdir = '')
Applies sorting and returns the list to the first page.
pbPagination.sort('price', 'asc');
pbPagination.sort('cheap'); // alias from sortNamesIf 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.
// 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.
<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 valueprice=100-500. If only "from" is filled in, "to" is set equal to it. - Instant apply. The
pb-filter-changeattribute on the form makes the filter apply on every field change, without a button:
<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
<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:
new pbPagination({
sortNames: 'cheap==price-asc,expensive==price-desc,new==createdon-desc'
});<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"
<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
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.
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:
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:
{
"success": true,
"data": "<article>…</article>",
"links": "<ul class='pagination'>…</ul>",
"total": 137,
"current_page": 2,
"last_page": 14
}| Field | Where it goes |
|---|---|
data | Inserted into targetItems using the chosen method. |
links | Replaces the contents of every [pb-pagination]. |
total | Written into every [pb-total] with grouped digits: 1 234 567. |
last_page | Controls the "Load More" button and infinite scroll. |
current_page | Not 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:
| Template | Available 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=1never 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:
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.