I’ve come up with a solution for pagination inside of Etch that uses JavaScript only. This is currently a temporary pagination solution for Etch loops until PHP authoring becomes available in the future.
The biggest downside to this method is that you have to repeat some of your query args inside the JavaScript so it can calculate the pagination correctly. This is necessary to get the total number of posts to calculate the pagination correctly. After it does that, it outputs the pagination markup, and handles the query param pagination. That query param is then passed through the URL to the loop args to output the correct posts.
If you want to do pagination without this code, you can check out my post in Etch Circle on how to use WP Grid Builder pagination in Etch.
How it works
For the pagination to work correctly, you need to ensure your Etch loop, JavaScript configuration, and any PHP query arguments all work together seamlessly:
Setup Process
- Create your Etch loop with the “pagination URL parameter” and “posts per page” custom args (detailed below)
- Add the
pagination-gridclass to your loop’s container - (Optional) Add a
divto your page where you want pagination to appear with the class ofpagination-nav. If you don’t add it, the JavaScript will create it for you, but this may cause a slight page jump when it does - Configure the JavaScript with matching settings for post type, posts per page, and page parameter.
- If you have any other
query_argsthat will effect which posts show up in your loop, make sure thequeryParamsmatch yourquery_args
Matching Config Requirements
- Your JavaScript
contentType: 'project'must match your Etch loop post type - The
postsPerPage: 3setting must match your loop’s$posts_per_page: 3parameter - Your
pageParam: 'pagination'must match the URL parameter in your loop (url.parameter.pagination) - Any
queryParamsfor filters must include the same taxonomy filters, meta queries, or other parameters from your query to get accurate total counts. Here’s how to translate common PHP query args to REST API parameters:- Taxonomy queries:
'project_type' => 'web'becomes'project_type=web' - Multiple taxonomy terms:
'project_tag' => [5, 12]becomes'project_tag[]=5&project_tag[]=12' - Meta queries:
'meta_key' => 'featured', 'meta_value' => 'yes'becomes'featured=yes'(if REST API exposed) - Final example:
queryParams: 'project_type=web&project_tag[]=5&project_tag[]=12&featured=yes'
- Taxonomy queries:
- Your grid container class (default
.pagination-grid) must be on your loop container, or you can customize this with thegridContainersetting in the JavaScript
On to the Code!
Loop Args
Note: The $posts_per_page and $page are custom args that are being passed from the Etch loop into the query
<?php
$query_args = [
'post_type' => 'project',
'posts_per_page' => $posts_per_page,
'post_status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
'paged' => $page
];Example Etch Loop with Loop Args
Note: The $page is what is getting the page from the URL to tell the loop which posts to show, so leave that one alone. And then the $posts_per_page is where you would set up how many posts per page you want to show in this grid.
<div data-etch-element="container" class="project-grid pagination-grid">
{#loop allProjects($page: url.parameter.pagination, $posts_per_page: 3) as project}
<article data-etch-element="flex-div" class="project-card">
<h3 class="project-card__title"><a href={project.permalink.relative} class="project-card__link">{project.title}</a></h3>
<div class="project-card__status">
{#loop project.project-status as status}<span class="project-card__badge">{status.name}</span> {/loop}
</div>
<div class="project-card__description">{project.acf.project_description}</div>
</article>
{/loop}
<div data-etch-element="flex-div" class="pagination-nav"></div>
</div>Key Connection Points:
- The
url.parameter.paginationin the Etch loop matches thepageParam: 'pagination'setting in the JavaScript - The
$posts_per_page: 3should match thepostsPerPage: 3in your JavaScript configuration - The
.pagination-gridclass is what the JavaScript looks for to insert pagination after
Javascript
class EtchUrlPagination {
/**
* Constructor: sets up configuration and initializes the class
*/
constructor(options = {}) {
this.config = {
gridContainer: '.pagination-grid', // Container holding posts
pageParam: 'pagination', // Avoid 'page' and 'paged' as they are reserved by WP)
// Mirror your query args here
postsPerPage: 12, // Posts per page for pagination
contentType: 'project', // WP REST post type slug or rest_base
queryParams: '', // See docs for formatting
...options
};
// Fixed pagination container selector
this.paginationContainer = '.pagination-nav';
this.init();
}
/**
* Initialization: checks grid, creates pagination container, renders UI, binds events
*/
init() {
const gridContainer = document.querySelector(this.config.gridContainer);
if (!gridContainer) return;
this.postsPerPage = this.config.postsPerPage;
this.createContainer(this.paginationContainer, gridContainer, 'after');
this.renderPagination().then(() => {
this.bindEvents();
});
}
/**
* Reads current page number from URL, defaults to 1 if missing
*/
getCurrentPage() {
const urlParams = new URLSearchParams(window.location.search);
return parseInt(urlParams.get(this.config.pageParam), 10) || 1;
}
/**
* Creates the pagination container if it doesn't already exist
*/
createContainer(selector, reference, position) {
let existing = document.querySelector(selector);
if (existing) return existing;
const container = document.createElement('div');
container.className = selector.replace('.', '');
const nextSibling = position === 'after' ? reference.nextSibling : reference;
reference.parentNode.insertBefore(container, nextSibling);
return container;
}
/**
* Fetches totals, builds pagination UI, and injects it into the DOM
*/
async renderPagination() {
const container = document.querySelector(this.paginationContainer);
if (!container) return;
const totals = await this.fetchTotalPosts();
if (!totals) {
container.innerHTML = '';
return;
}
const data = this.getPaginationData(totals);
// If only one page, no need for pagination UI
if (data.current_page === 1 && data.total_pages === 1) {
container.innerHTML = '';
return;
}
container.innerHTML = this.buildPaginationHTML(data);
}
/**
* Builds a simple object describing pagination state
*/
getPaginationData(totals) {
const currentPage = this.getCurrentPage();
const totalPages = totals.totalPages;
return {
current_page: currentPage,
has_prev: currentPage > 1,
has_next: currentPage < totalPages,
total_pages: totalPages
};
}
/**
* Builds a compact page sequence with smart ellipses - Always shows first/last page
*/
getPageSequence(totalPages, current) {
if (!Number.isFinite(totalPages) || totalPages <= 1) return [1];
const set = new Set([1, totalPages, current]);
// Neighbors around current page
if (current - 1 > 1) set.add(current - 1);
if (current + 1 < totalPages) set.add(current + 1);
// Near edges to reduce lonely ellipses like 1 ... 3
if (current > 4) set.add(2);
if (current < totalPages - 3) set.add(totalPages - 1);
const sorted = Array.from(set).sort((a, b) => a - b);
const out = [];
for (let i = 0; i < sorted.length; i++) {
const n = sorted[i];
const prev = sorted[i - 1];
if (i > 0 && n - prev > 1) {
// If gap is only one page, include it
if (n - prev === 2) {
out.push(prev + 1);
} else {
out.push('dots'); // Otherwise show ellipses
}
}
out.push(n);
}
return out;
}
/**
* Builds the pagination UI with ellipses and prev/next links
*/
buildPaginationHTML(data) {
const items = [];
// Previous button
if (data.has_prev) {
items.push(this.createPaginationItem(
this.getPageUrl(data.current_page - 1),
'Previous',
'pagination-btn--prev',
'Go to previous page'
));
}
// Page numbers + ellipses
const seq = this.getPageSequence(data.total_pages, data.current_page);
seq.forEach(token => {
if (token === 'dots') {
items.push(`
<li class="pagination__item">
<span class="pagination-ellipsis" aria-hidden="true">…</span>
</li>
`);
return;
}
if (token === data.current_page) {
items.push(`
<li class="pagination__item">
<span class="pagination-btn pagination-btn--active" aria-current="page">
${token}
</span>
</li>
`);
} else {
items.push(this.createPaginationItem(
this.getPageUrl(token),
String(token),
'',
`Go to page ${token}`
));
}
});
// Next button
if (data.has_next) {
items.push(this.createPaginationItem(
this.getPageUrl(data.current_page + 1),
'Next',
'pagination-btn--next',
'Go to next page'
));
}
return `
<nav class="pagination" role="navigation" aria-label="Pagination">
<ul class="pagination__list">
${items.join('')}
</ul>
</nav>
`;
}
/**
* Builds individual pagination button markup
*/
createPaginationItem(url, text, extraClass, ariaLabel) {
const className = `pagination-btn ${extraClass}`.trim();
return `
<li class="pagination__item">
<a href="${url}" class="${className}" aria-label="${ariaLabel}">
${text}
</a>
</li>
`;
}
/**
* Fetches total post count from WP REST API using `X-WP-Total header
*/
async fetchTotalPosts() {
try {
const base = `${window.location.origin}/wp-json/wp/v2/${this.config.contentType}`;
const qs = new URLSearchParams(this.config.queryParams || '');
// Default to published posts if no status provided
if (!qs.has('status')) {
qs.set('status', 'publish');
}
// Keep payload tiny while still returning totals
qs.set('per_page', '1');
qs.set('_fields', 'id');
const res = await fetch(`${base}?${qs.toString()}`, { credentials: 'same-origin' });
if (!res.ok) return null;
const total = parseInt(res.headers.get('X-WP-Total') || '0', 10);
const totalPages = Math.max(1, Math.ceil(total / this.postsPerPage));
return { total, totalPages };
} catch (e) {
return null;
}
}
/**
* Builds URLs for page links
*/
getPageUrl(page) {
const url = new URL(window.location.href);
if (page === 1) {
url.searchParams.delete(this.config.pageParam);
} else {
url.searchParams.set(this.config.pageParam, String(page));
}
return url.toString();
}
/**
* Listens for pagination clicks and navigates to target page
*/
bindEvents() {
document.addEventListener('click', (e) => {
const link = e.target.closest('.pagination-btn[href]');
if (link) {
e.preventDefault();
this.navigateToPage(link.href);
}
});
}
/**
* Navigates to selected page with loading state
*/
navigateToPage(url) {
this.toggleLoading(true);
window.location.href = url;
}
/**
* Loading state handler as requested: grid fades, pagination disabled
*/
toggleLoading(show) {
const grid = document.querySelector(this.config.gridContainer);
const pagination = document.querySelector(this.paginationContainer);
if (grid) {
grid.style.opacity = show ? '0.5' : '1';
grid.style.transition = 'opacity 0.2s ease';
}
if (pagination) {
pagination.style.pointerEvents = show ? 'none' : '';
}
}
}
document.addEventListener('DOMContentLoaded', () => {
new EtchUrlPagination();
});CSS
Here is some starter styles to get you going. Make sure to load this globally as Etch will strip it since it isn’t in the DOM on load.
.pagination-nav {
min-height: 50px;
grid-column: 1/-1;
display: flex;
align-items: center;
justify-content: center;
margin-top: 40px;
}
.pagination {
.pagination__list {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.pagination__item--ellipsis {
padding: 0.75rem;
color: #666;
}
}
.pagination-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
color: #333;
text-decoration: none;
transition: all 0.2s ease;
font-size: 0.875rem;
font-family: inherit;
&:hover:not(.pagination-btn--active) {
background-color: var(--primary-semi-dark, #0056b3);
border-color: var(--primary-semi-dark, #0056b3);
text-decoration: none;
color: white;
}
&:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
text-decoration: none;
}
.pagination-btn--active {
background-color: var(--primary, #007bff);
color: white;
border-color: var(--primary, #007bff);
&:hover {
background-color: var(--primary, #007bff);
border-color: var(--primary, #007bff);
color: white;
}
}
.pagination-btn--prev,
.pagination-btn--next {
font-weight: 500;
min-width: auto;
padding: 0.5rem 1rem;
}
}
Global Implementation
If you want to use pagination across multiple pages, you can leave off the end where you define new EtchUrlPagination(); and then just enqueue this js in a global js file. Then you can just call this on each page:
document.addEventListener('DOMContentLoaded', () => {
new EtchUrlPagination({
contentType: 'team',
postsPerPage: 8,
gridContainer: '.team-grid'
});
});
Found this Snippet useful?
Consider buying me a coffee! ☕️ Your support helps me create more free snippets for the WordPress community.