This is something that will turn browsing paginated lists of pages into the seamless user experience. No need for any plugins like Element API - it's purely Twig based. To see dynamic pagination in action, you can check out its Demo page.
Note: this solution requires Craft 3.1.30 to function properly and will not work with earlier versions.
Dynamic pagination functionality #
Let's summarize how dynamic pagination works:
- Initial paginated list of entries is rendered using Twig.
- After clicking one of the pagination links, javascript AJAX request is sent to Twig based endpoint. Endpoint returns next bath of paginated entries that are injected into website using javascript, along with updated pagination links.
- Endpoint returns paginated list of pages and updated pagination links in HTML format. It uses same TWIG component for rendering additional subpages of paginated entries that is used for rendering initial list of entries.
- This pagination solution is a perfect example of progressive enhancement - when javascript is disabled, it works just like regular link based pagination.
- You can use browser back and forward button while browsing paginated subpages thanks to use of history API.
- If user clicks multiple pagination links faster than it takes for AJAX query to return data from server, all not yet completed AJAX requests are canceled, except last one. This of course does not include AJAX requests not related to pagination and made by other parts of your website.
- After the start of each request,
is-loading
CSS class is added to paginated list of entries. This class is then removed after request is completed. - After the start of each request, page is scrolled to top of list of pages using smooth animation. If user himself starts scrolling while the animation is running, it will be stopped to avoid "fighting" with animation movement.
- Pagination also works with search results or filtering based on
GET
parameters.
Setting up dynamic pagination #
Setting up dynamic pagination is actually pretty simple. You will need three files.
dynamic_pagination.twig #
This file renders initial content displayed on page load and contains JS code that handles things like AJAX requests and history API. It uses jQuery. Javascript is placed within Twig {% js %}
tag to make passing TWIG variables to javascript easier and encapsulate all pagination-related code in one file (same approach as with VUE single file components).
{% if pagination_list is defined %}
{% js %}
// AJAX REQUEST DATA
{% set current_url = craft.request.getRequestUri()|split(craft.request.getPath())[0]~craft.request.getPath() %}
{% set ajax_data = {
current_url: current_url,
pagination_list: pagination_list|hash,
pagination_parameters: pagination_parameters ?? null,
} %}
// JS SETTINGS
var loading_class = 'is-loading'
var pagination_list_class = 'js-pages-list'
var pagination_wrapper_class = 'js-pages-wrapper'
var animation_speed = 1000
// TWIG TO JS
var endpoint_url = '{{url(pagination_endpoint ?? 'pagination_endpoint')}}'
var url_params = '{{craft.app.request.getQueryStringWithoutPath() is not empty ? '?' ~ craft.app.request.getQueryStringWithoutPath()}}';
var current_url = '{{current_url}}'
var page_trigger = '{{craft.app.config.general.pageTrigger}}'
// AJAX REQUEST
var current_request = null;
function change_page(page_number, done){
current_request = $.ajax({
url: endpoint_url+'/'+page_trigger+page_number+url_params,
method: 'GET',
data: {{ajax_data|json_encode|raw}},
beforeSend: function(){
if(current_request != null) {
current_request.abort();
}
$('.'+pagination_list_class).addClass(loading_class);
var page = $("html, body");
page.on("scroll mousedown wheel DOMMouseScroll mousewheel keyup touchmove", function(){
page.stop();
});
page.animate({ scrollTop:
$('.'+pagination_wrapper_class).position().top }, animation_speed, function(){
page.off("scroll mousedown wheel DOMMouseScroll mousewheel keyup touchmove");
});
}
}).always(function(){
$('.'+pagination_list_class).removeClass(loading_class);
}).done(function(data) {
$('.'+pagination_wrapper_class).html(data);
if(done){
done();
}
});
}
// BACK/FORWARD BUTTON
var initial_page = '{{craft.request.getPageNum()}}'
window.addEventListener('popstate', function(e) {
if(e.state){
change_page(e.state);
}else{
change_page(initial_page);
}
});
// PAGINATION CLICK EVENT
$('.'+pagination_wrapper_class).on('click', '[data-number]', function(e){
e.preventDefault();
var selected_page = $(this).attr('data-number');
change_page(selected_page, function(){
history.pushState(selected_page, null, current_url+'/'+page_trigger+selected_page+url_params);
});
});
{% endjs %}
<div class="js-pages-wrapper">
{% include pagination_list with {
pagination_parameters: pagination_parameters ?? null,
} %}
</div>
{% endif %}
Include this file in a place where you want paginated list of entries displayed. You need to pass pagination_list
variable into it - it should contain path of pagination_pages_list
file. Here is how dynamic_pagination
file should be included - using with
keyword to pass variable into it.
{% include '_components/dynamic_pagination' with {
pagination_list: '_components/pagination_pages_list',
} %}
Things to remember:
- At the start of each request,
is-loading
class is added to entries list. Don't forget to set it in your CSS to something that indicates that content is loading, for exampleopacity: 0.5
. - You can also add some loading indicator that will be hidden by default and temporarily show up when
is-loading
class is added. If you use font awesome, such indicator can be simply created by using<i class="fas fa-spinner fa-pulse"></i>
- it will create spinning "preloader" icon. - If you have some JS events attached to elements on paginated list, remember to use event delegation - after changing pagination subpage, new DOM elements are added to the page.
pagination_endpoint.twig #
This file needs to be placed directly in templates
folder or in the directory which filename doesn't start with an underscore. Thanks to that, it will be available to AJAX requests - any file without underscore at the beginning of filename can be directly accessed by requesting URL corresponding to its filename. You can learn more about it in Craft docs about routing.
{% if craft.app.request.segments|last == _self and craft.app.request.isAjax %}
{% set pages_list_path_hashed = craft.request.getParam('pagination_list') %}
{% if craft.app.security.validateData(pages_list_path_hashed) is not same as(false) %}
{% include craft.app.security.validateData(pages_list_path_hashed) with {
pagination_parameters: craft.request.getParam('pagination_parameters')
} %}
{% endif %}
{% else %}
{% redirect currentSite.baseUrl 301 %}
{% endif %}
Here's summary of endpoint file functionality:
- If it is accessed through web browser instead of through AJAX request, it redirects request to homepage. Endpoint is not supposed to be viewed directly.
- Endpoint file includes
pagination_pages_list
file to render requested additional content - same file that is used bydynamic_pagination
file to render initial content. - As there may be multiple lists,
dynamic_pagination
file passes list filename aspagination_list
GET parameter topagination_endpoint
file. To make sure that filename is not tampered with (what if anyone would be able to make our site include any Twig file he or she wants?) this parameter is hashed using hash filter before AJAX request is sent. Later in endpoint file, hashed value is validated (and decoded) usingcraft.app.security.validateData
function. pagination_pages_list
file is included with additionalpagination_parameters
variable. Its contents are taken from another GET request parameter. It can be used to pass additional parameter to an element query defined there. By default it's empty.
pagination_pages_list.twig #
This file's name is actually default name and can be set to anything you like by passing proper variable to dynamic_pagination
file as explained earlier.
pagination_pages_list
is file that is used both by dynamic_pagination
and by pagination_endpoint
. In dynamic_pagination
it renders initial content, in pagination_endpoint
it renders subsequent content requested by AJAX. By content - I mean both paginated list of entries and pagination links.
{# example element query - adjust it to your website #}
{% paginate craft.entries.section('articles').limit(3) as pageInfo, pageEntries %}
{% if craft.request.isAjax() %}
{% do pageInfo.setBasePath(craft.request.getParam('current_url')) %}
{% endif %}
{# replace code below with your entries list and pagination component #}
{% include '_components/pagination' %}
<div class="js-pages-list">
{% for entry in pageEntries %}
<a href="{{entry.url}}">{{entry.title}}</a>
{% endfor %}
</div>
{% include '_components/pagination' %}
In this file, you will need to set up your element query that will display list of entries (or other elements, like categories). Right now it has example query that will fetch entries from section with handle articles
.
Things to remember:
- For pagination links, you can use my Ellipsis pagination component for Craft CMS or any other pagination - just make sure that each pagination link has
data-number
attribute representing number of subpage it links to - just like links ellipsis pagination have. - List of entries needs to have
js-pages-list
class.
Multiple paginated lists #
You can have multiple pagination lists that display content from different sources - and all of them can use the same dynamic_pagination
and pagination_endpoint
file. These files are constructed to be universal - that's why you pass variables into dynamic_pagination
instead changing them inside it or endpoint. For multiple pagination lists you will need only multiple "pages list" files.
List of entries related to category #
Example element query placed in pagination_pages_list
will fetch all entries belonging to specific section. What if we are using pagination on the category page and we need to fetch entries related to this category?
In such situation we need to be able to have access to category ID in pagination_pages_list
file, to use it in relatedTo
element query method. To achieve this, we need to pass this variable to dynamic_pagination
file as pagination_parameters
.
{% include '_components/dynamic_pagination' with {
pagination_list_filename: '_components/pagination_pages_list',
pagination_parameters: {
category_id: category.id
}
} %}
dynamic_pagination
file is constructed in such way that pagination_parameters
variable will be passed both to pagination_pages_list
used on initial content render and to AJAX query that loads additional content.
Now that pagination_parameters.category_id
variable is avaible to pagination_pages_list
, we can use it in element query.
{% paginate craft.entries.section('articles').relatedTo(pagination_parameters.category_id).limit(3) as pageInfo, pageEntries %}
setBasePath function #
Here's little explanation of what setBasePath used in pagination_pages_list
file does.
By default, pagination links are based on URL of the page that pagination is used on. For example, for some-page
, pagination link would look like this some-page/p2
. However that would also mean that when pagination_endpoint.twig
renders pagination links, these would use endpoint URL and look like this: pagination_endpoint/p2
.
This is why setBasePath
function is used in pagination_pages_list
file. Each request to endpoint contains current_url
parameter that is later used to set base URL of pagination links. setBasePath
function is used only when pagination_pages_list
is used to render additional subpages of paginated entries which is determined by craft.request.isAjax()
.
Originally, I planned to achieve the same effect using more cumbersome solution - replacing URL of endpoint in links returned from said endpoint to proper URL using a regular expression. At the same time I submitted qithub request for adding the ability to set base path of pagination links manually... and after 24 hours it was done.