This Is How To Build Custom Pagination With JavaScript

by kleamerkuri
Custom pagination with vanilla JavaScript.

Hello fellow THT-ers, I’m officially back! And DM’s taking full advantage, having me build his personal design portfolio.

It’s DM’s custom-designing stuff that brought us to this post. Today, we’re diving into the exciting world of creating custom pagination using vanilla JavaScript.

If you’ve ever built a website with a large dataset, you’ve probably encountered the need for user-friendly pagination.

While many libraries and plugins exist, crafting your custom pagination solution can be an enlightening experience.

That said, it will also be a challenging, hair-pulling event 💁‍♀️

In this blog post, we’ll explore the ins and outs of creating a fully functional, interactive, and user-centric pagination system.

This system will empower your users to navigate through extensive datasets easily, incorporating input fields for page selection, intuitive ellipsis buttons, and the ever-familiar “previous” and “next” buttons.

Power of Custom Pagination

Before diving into the details, let’s talk about why custom pagination matters.

When you build a website or web application, you want to ensure that your users have an efficient and enjoyable experience when navigating through your content.

This especially applies to blog-like pages. Trust me when I say, you do NOT want to force infinite scroll on users when it comes to long-list material.

A well-designed custom pagination system can make a significant difference in achieving this goal.

DM upped the user-friendliness of his portfolio pagination by providing the ability to navigate via input. So we’re going to do the same.

Basics of Pagination

Pagination, in essence, is about dividing a large dataset into manageable chunks.

Each chunk, known as a “page,” contains a subset of the data, and users can move between these pages to explore the content.

Typical pagination systems include links or buttons to navigate to the previous or next page, but what sets a custom pagination system apart is the attention to user interaction and accessibility.

User-Centric Features

A user-centric pagination system should provide not only the essentials but also extra conveniences. Here are some crucial features to consider:

  1. Clickable Page Numbers: Users should be able to click on individual page numbers for direct navigation to a specific page.
  2. “Previous” and “Next” Buttons: These are essential for sequential navigation, making it easy to move from one page to the next or back. Often you find these as arrows.
  3. Input Field for Page Selection: Allow users to input a page number directly, simplifying navigation for those who know the page they’re looking for.
  4. Ellipsis Buttons: These buttons signify gaps between page numbers and provide a way to navigate to more pages without overwhelming the user with too many links.
  5. Responsive Design: Ensure that your custom pagination is responsive and looks great on various devices and screen sizes.

Hint: Don’t crowd your pagination! Make use of ellipsis breaks to deliver an easily-readable pagination or skip the buttons all together and opt for a simple input.

Creating a Custom Pagination System

I had a really difficult time finding a functionally similar pagination to DM’s ask. The hardest part was figuring out the logic to display the ellipsis breaks appropriately.

For instance, I needed to selectively display a set number of pages for the intermediary values (between the min and max).

After plenty of research, I came across Easy to understand paginator that was the closest pagination logic to DM’s custom design.

Sadly it was only the pagination part not equipped with the logic for controlling a page of data.

But having something is better than nothing; the main challenge now would be to integrate this external code into my own and customize it!

Here’s a step-by-step guide to creating your custom pagination system with vanilla JavaScript:

1. HTML Structure

Start by designing the HTML structure for your pagination component.

<div class="container"></div>
<div class="pagination"></div>

The HTML is minimalist because I’ll be creating elements for page numbers, “previous” and “next” buttons, and the input field for direct page input using JavaScript.

Of the two elements, container will hold the data—in our case, THT posts from our API— and pagination will hold the buttons making up the pagination system.

2. JavaScript Magic

So this is atypical of me to move directly into JS from HTML without adding the CSS. But we can’t style something we can’t see so let’s implement the functionality using vanilla JavaScript before styling.

I start with some reusable functions that create elements and fetch posts from our API.

Related: How To Use JavaScript Fetch API With Pokémon Example

// Utils
const createElm = (elm) => document.createElement(elm);

// Fetch THT blog posts
const fetchPosts = async () => {
  const resp = await fetch(WPAPI);
  const posts = await resp.json();

  // custom data
  const customPosts = posts.map((post) => {
    return {
      link: post.link,
      img: post.yoast_head_json.og_image[0].url,
      title: post.title.rendered,
      desc: post.excerpt.rendered
    };
  });
  // console.log(customPosts);
  return customPosts;
};

// Build post card
// @post -- single blog post, extract:
// { @link, @yoast_head_json: og_image[]: url, @title: rendered, @excerpt:rendered } for display
const singlePostCard = (post) => {
  const cardWrapper = createElm("div");
  const cardOverlay = createElm("div");
  const postContent = createElm("div");
  const postLink = createElm("a");
  const featImg = createElm("img");
  const title = createElm("h3");
  const desc = createElm("p");

  cardWrapper.classList.add("card_wrapper");
  cardOverlay.classList.add("card_overlay");
  postContent.classList.add("post_content");
  postLink.href = post.link;
  postLink.alt = "";
  postLink.target = "_blank";
  postLink.rel = "noopener";
  featImg.src = post.img;
  title.innerText = post.title;
  desc.innerHTML = post.desc;

  postLink.appendChild(cardOverlay);
  [title, desc].forEach((item) => postContent.appendChild(item));
  [featImg, postLink, postContent].forEach((item) =>
    cardWrapper.appendChild(item)
  );
  document.querySelector(".container").appendChild(cardWrapper);
};

Now, the real magic happens in the following function:

// Add cards to document
const addPostCardsToDoc = async () => {
  const posts = await fetchPosts();

  let itemsPerPage =
      window.innerWidth > 648 && window.innerWidth < 1078 ? 4 : 3,
    totalItems = posts.length,
    totalPages = Math.ceil(totalItems / itemsPerPage),
    currentPage = 1;

  // PAGINATION ---------
  const Pagination = {
    code: "",

    Extend: function (data) {
      data = data || {};

      Pagination.size = data.size || 30;
      Pagination.page = data.page || 1;
      Pagination.step = data.step || 3;
      Pagination.items = data.items || 3;
      Pagination.posts = posts;
    },

    Add: function (s, f) {
      for (var i = s; i < f; i++) {
        Pagination.code += '<button class="page_num">' + i + "</button>";
      }
    },

    Last: function () {
      Pagination.code +=
        '<i>...</i><button class="page_num">' + Pagination.size + "</button>";
    },

    First: function () {
      Pagination.code += '<button class="page_num">1</button><i>...</i>';
    },

    DisplayPage: function (pN) {
      const container = document.querySelector(".container");
      container.innerHTML = "";

      const startIndex = (pN - 1) * Pagination.items;
      const endIndex = startIndex + Pagination.items;

      if (Pagination.posts) {
        for (
          let i = startIndex;
          i < endIndex && i < Pagination.posts.length;
          i++
        ) {
          Pagination.posts &&
            Pagination.posts.forEach(
              (post, index) => index === i && singlePostCard(post)
            );
        }
      }
    },

    Click: function () {
      Pagination.page = +this.innerHTML;
      Pagination.DisplayPage(Pagination.page);
      Pagination.Start();
    },

    Prev: function () {
      Pagination.page--;
      if (Pagination.page < 1) {
        Pagination.page = Pagination.size;
      }
      Pagination.Start();
    },

    Next: function () {
      Pagination.page++;

      if (Pagination.page > Pagination.size) {
        Pagination.page = 1;
      }
      Pagination.Start();
    },

    TypePage: function () {
      Pagination.code =
        '<input class="input_num" onclick="this.setSelectionRange(0, this.value.length);this.focus();" onkeypress="if (event.keyCode == 13) { this.blur(); }" value="' +
        Pagination.page +
        '" />   /   ' +
        Pagination.size;
      Pagination.Finish();

      var v = Pagination.e.getElementsByTagName("input")[0];
      v.click();

      v.addEventListener(
        "blur",
        function (event) {
          var p = parseInt(this.value);

          if (!isNaN(parseFloat(p)) && isFinite(p)) {
            if (p > Pagination.size) {
              p = Pagination.size;
            } else if (p < 1) {
              p = 1;
            }
          } else {
            p = Pagination.page;
          }

          Pagination.Init(document.querySelector(".pagination"), {
            size: Pagination.size,
            page: p,
            step: Pagination.step,
            items: Pagination.items,
            posts: Pagination.posts
          });

          Pagination.DisplayPage(p);
        },
        false
      );
    },

    Bind: function () {
      var btn = Pagination.e.getElementsByTagName("button");
      for (var i = 0; i < btn.length; i++) {
        if (+btn[i].innerHTML === Pagination.page)
          btn[i].className += " active";
        btn[i].addEventListener("click", Pagination.Click, false);
      }

      var d = Pagination.e.getElementsByTagName("i");
      for (i = 0; i < d.length; i++) {
        d[i].addEventListener("click", Pagination.TypePage, false);
      }
    },

    Finish: function () {
      Pagination.e.innerHTML = Pagination.code;
      Pagination.code = "";
      Pagination.Bind();
    },

    Start: function () {
      Pagination.step = 3;

      if (Pagination.size < Pagination.step) {
        Pagination.Add(1, Pagination.size + 1);
      } else if (Pagination.page <= Pagination.step) {
        Pagination.Add(1, Pagination.step + 1);
        Pagination.Last();
      } else if (Pagination.page > Pagination.size - Pagination.step) {
        Pagination.First();
        Pagination.Add(
          Pagination.size + 1 - Pagination.step,
          Pagination.size + 1
        );
      } else {
        Pagination.First();
        Pagination.Add(
          Pagination.page - Pagination.step + 1,
          Pagination.page + Pagination.step
        );
        Pagination.Last();
      }
      Pagination.Finish();
    },

    Buttons: function (e) {
      var nav = e.querySelectorAll(".navBtn");
      nav[0].addEventListener("click", Pagination.Prev, false);
      nav[1].addEventListener("click", Pagination.Next, false);
    },

    Create: function (e) {
      var html = [
        '<button class="navBtn">Prev</button>', // previous button
        "<span></span>", // pagination container
        '<button class="navBtn">Next</button>' // next button
      ];
      e.innerHTML = html.join("");
      Pagination.e = e.getElementsByTagName("span")[0];
      Pagination.Buttons(e);
    },

    Init: function (e, data) {
      Pagination.Extend(data);
      Pagination.Create(e);
      Pagination.Start();

      // Initialize first page display
      Pagination.DisplayPage(1);
    }
  };

  Pagination.Init(document.querySelector(".pagination"), {
    size: totalPages, // pages size
    page: currentPage, // selected page
    step: 3, // pages before and after current
    items: itemsPerPage, // items to show per page
    posts // fetched posts
  });
};

// Show posts
document.addEventListener("DOMContentLoaded", addPostCardsToDoc, false);

The last line executes addPostCardsToDoc when the page loads.

This code is part of the one I’m integrating so the developer provided false as an optional parameter called “useCapture” in the addEventListener method.

Specifying false is not about the event being “true” or “false”; rather, it determines whether the event should be captured during the capturing phase or the bubbling phase.

We’re processing during the bubbling phase so the event starts at the target element and bubbles up through the ancestor elements, from the target element toward the root element.

When true is provided, the event is captured during the capturing phase. This means the event is processed starting from the outermost ancestor element and moving inward toward the target element. Then it goes through the target element and continues to bubble up to the root element.

The vast majority of event listeners use the default behavior, which is the bubbling phase, and often, false is omitted because the default behavior is to capture during the bubbling phase.

Moving on, let me explain other key features of the code above.

Defining a pagination object

Per the reference code, I’ll be using a semi-complex pagination object with defined methods to build the pagination system.

Most of the code is adapted—I’ll point out some of my customizations below.

Modifications:

  • Extend: Updated to support data like the number of items per page and the fetched posts.
  • Add, Last, First: Adjusted to add button elements instead of anchor tags for the page numbers. Custom classes are also included.
  • Click: Initialized page to display.
  • Prev, Next: Adjust to display the last page if the current page is the first and clicking Previous and the first page if the current page is the last and clicking Next.
  • TypePage: Initialize Pagination with fetched posts and displayed paged when navigating via input.
  • Start: This is a tricky method; should be adjusted based on preferences for the display of the pagination buttons, especially for intermediary values.
  • Buttons, Create: Modified the elements to use those of my code.
  • Init: Initialize first page display.

Addition:

  • DisplayPage: Method to display the page that corresponds to each pagination button number. This ties the pagination system with the data pages.

Using these custom methods, I handle events like a click on page numbers, “previous” and “next” buttons, and even the input field for direct page input.

TypePage holds the ellipsis logic that hides or displays pages dynamically based on the user’s location in the dataset.

3. Styling with SCSS

I’ll use SCSS to style the pagination component. Pay attention to responsiveness and aesthetics.

Related: How To Make A Beautiful Accessible Accordion With SCSS

I didn’t focus too much on DM’s design here since my goal was to figure out the pagination functionality (stay tuned for DM’s conception) 😬

But I do want to point out something new I came across that is pretty neat when it comes to responsive design!

Responsive design made easy

I started by defining a function that turns pixels into rem.

@function pxtorem($px, $base:16px) {
  @return #{$px / $base}rem;
};

Pixels are often used for specifying fixed sizes in web design—they’re not inherently responsive. This means they don’t adapt well to different screen sizes or zoom levels.

But pixels are straightforward to work with and more intuitive (in my humble opinion) than rems.

Meanwhile, rems are relative units based on the font size of the root element. They’re great for responsive design because they scale proportionally with changes in the root font size.

Since rems make it easier to create designs that adapt to different devices and screen sizes, they’re particularly useful for text and layout elements.

Hence I opted for SCSS to create a function that easily transforms the pixels I easily use to the rems that make my application responsive!

In action, it looks like this:

body {
  margin: 0;
  padding: 0;
  padding-top: pxtorem(120px);
  font-family: sans-serif;
  background: rgb(178, 190, 181, 0.2);

  .container {
    max-width: calc(#{pxtorem(320px)} * 3 + #{pxtorem(20px)});
    width: 100%;
    margin: 0 auto;
    display: grid;
    grid-template-columns: repeat(
      auto-fit,
      minmax(pxtorem(320px), pxtorem(320px))
    );
    grid-template-rows: auto;
    gap: pxtorem(10px);
    justify-content: center;
.
.
.

Note 🧐
To use the function within the built-in calc, interpolate the return value (another things SCSS lets you do easily)!

I opt to use CSS Grid for my layout but you can choose to use Flexbox instead.

More on Flexbox: 2 Ways To Build A Technical Documentation Page (freeCodeCamp Challenge)

Another notable feature is how I deal with the shift that occurs on hover when the “active” button gets a border on the bottom.

That shift is often because the border occupies space within the box model. When the border is added, it increases the height of the element, causing the surrounding content to shift.

To prevent this shift, I initialize the pagination buttons with a transparent border-bottom that’ll change color on hover. This way, the element’s size remains constant, and there’s no shift when hovering 🙌

I’ll not paste all the styles here since they’re available on the GitHub repo for this project.

Live Demo 👇
Hop over to my CodePen to see custom pagination in action!

It’s a wrap

Building a custom pagination system using vanilla JavaScript offers a unique opportunity to enhance your website’s navigation, empowering your users to explore your content effortlessly.

With clickable page numbers, “Previous” and “Next” buttons, ellipsis buttons, and direct page input, you’ll provide your users with a seamless and enjoyable browsing experience.

This is by no means a finished product but it’s a good base. Responsive behavior is an area of improvement (I did some basic responsiveness via the items per page to display).

Don’t forget to test your custom pagination thoroughly, optimizing it for performance to ensure smooth and efficient navigation.

The next step for me is to use the logic I developed here in DM’s portfolio that’s React-based. That’s gonna be interesting to see 🙈

Thanks for joining me ✌️

Related Posts