JavaScript Based Pagination for Etch Loops

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

  1. Create your Etch loop with the “pagination URL parameter” and “posts per page” custom args (detailed below)
  2. Add the pagination-grid class to your loop’s container
  3. (Optional) Add a div to your page where you want pagination to appear with the class of pagination-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
  4. Configure the JavaScript with matching settings for post type, posts per page, and page parameter.
  5. If you have any other query_args that will effect which posts show up in your loop, make sure the queryParams match your query_args

Matching Config Requirements

  • Your JavaScript contentType: 'project' must match your Etch loop post type
  • The postsPerPage: 3 setting must match your loop’s $posts_per_page: 3 parameter
  • Your pageParam: 'pagination' must match the URL parameter in your loop (url.parameter.pagination)
  • Any queryParams for 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'
  • Your grid container class (default .pagination-grid) must be on your loop container, or you can customize this with the gridContainer setting 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
<?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.

HTML
<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.pagination in the Etch loop matches the pageParam: 'pagination' setting in the JavaScript
  • The $posts_per_page: 3 should match the postsPerPage: 3 in your JavaScript configuration
  • The .pagination-grid class is what the JavaScript looks for to insert pagination after

Javascript

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.

CSS
.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:

JavaScript
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.