← Navigation

Pagination Dots

A row of page dots where the current one is stretched into a pill. Move to another page and the pill slides along the row, its leading end reaching out first and the trailing end catching up, while the old dot shrinks back to a dot. Point it at a row of slides and they move along with it.

01Plan02Design03Build04Test05Launch

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-pagination-dots data-count="5"></div>. The script makes the dots as buttons labelled “Page 1”, “Page 2” and so on. Or put your own elements inside and it uses those.
  3. To move a carousel with it, give data-slides a selector for the element holding the slides (one child per slide, inside a box with overflow: hidden).

Options

Add any of these attributes to the same element.

data-countHow many dots to make. Default 5.
data-activeIndex of the current dot at first, counting from 0. Default 0.
data-colorColour of the pill. Default #6a0dad.
data-dotColour of the other dots. Default #d9cce8.
data-slidesA selector for a row of slides to move along with the dots.
data-durationHow long the slide takes. Default 520 ms.
data-autoplayIt moves to the next page every couple of seconds while in view, pausing when someone taps. Handy for demos.

Size the dots with the CSS variables --pgd-size (default 8px) and --pgd-pill (the pill’s length, default 26px). The current dot gets aria-current="true" and the class pgd-on, the arrow keys move between pages, and it fires a pagechange event (e.detail.index). From code, animacious.paginationDots(el, 2) moves to the third page.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pagination Dots: a row of page dots where the current one is stretched
    // into a pill. Move to another page and the pill slides along the row:
    // its leading end reaches out first and the trailing end catches up, while
    // the old dot shrinks back to a dot and the new one widens.
    // Usage: <div data-pagination-dots data-count="5"></div>
    //    or: <div data-pagination-dots><button>…</button><button>…</button>…</div>
    // With no children the script makes data-count dots (buttons labelled
    // "Page 1", "Page 2"…). Any children you give it are used as the dots.
    // Optional: data-count (dots to make, default 5), data-active (index of the
    // current dot at first, default 0), data-color (pill colour, default
    // #6a0dad), data-dot (colour of the other dots, default #d9cce8),
    // data-slides (a selector for a track of slides it moves along with it:
    // each child is a slide), data-duration (ms, default 520), data-autoplay
    // (moves to the next page every couple of seconds while in view, pausing
    // when someone taps; handy for demos).
    // It fires a "pagechange" event (e.detail.index) and the current dot gets
    // aria-current="true" and the class "pgd-on". Arrow keys move between pages.
    // Script use: animacious.paginationDots(el, index) moves to a page.
    var DEFAULTS = { count: 5, color: '#6a0dad', dot: '#d9cce8', duration: 520, every: 1800 };

    var css =
      '.pgd{position:relative;display:inline-flex;align-items:center;gap:8px;--pgd-size:8px;--pgd-pill:26px;isolation:isolate}' +
      '.pgd-dot{position:relative;z-index:0;flex:none;width:var(--pgd-size);height:var(--pgd-size);padding:0;border:0;border-radius:999px;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
      // A bigger invisible hit area for fingers.
      '.pgd-dot::after{content:"";position:absolute;inset:-8px -3px}' +
      '.pgd-pill{position:absolute;left:0;top:50%;z-index:1;height:var(--pgd-size);margin-top:calc(var(--pgd-size)/-2);border-radius:999px;pointer-events:none}' +
      '.pgd-track{display:flex;will-change:transform}' +
      '.pgd-track>*{flex:none;width:100%}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function find(el, sel) {
      if (!sel) return null;
      for (var p = el.parentElement; p; p = p.parentElement) {
        var m = p.querySelector(sel);
        if (m) return m;
      }
      return null;
    }

    function sizes(el) {
      var cs = getComputedStyle(el);
      return { dot: parseFloat(cs.getPropertyValue('--pgd-size')) || 8, pill: parseFloat(cs.getPropertyValue('--pgd-pill')) || 26 };
    }

    function edges(dot) { return { l: dot.offsetLeft, r: dot.offsetLeft + dot.offsetWidth }; }

    function drawPill(s) {
      s.pill.style.transform = 'translateX(' + s.p.l + 'px)';
      s.pill.style.width = Math.max(0, s.p.r - s.p.l) + 'px';
    }

    function go(el, i, instant) {
      var s = el._pgd;
      var n = s.dots.length;
      i = ((i % n) + n) % n;
      if (i === s.index && !instant) return;
      var prev = s.index;
      s.index = i;
      s.dots.forEach(function (d, k) {
        d.classList.toggle('pgd-on', k === i);
        if (s.a11y) {
          if (k === i) d.setAttribute('aria-current', 'true'); else d.removeAttribute('aria-current');
        }
      });
      var z = sizes(el);
      if (s.track) {
        anime.utils.remove(s.track);
        if (instant || reduced()) anime.utils.set(s.track, { x: (-100 * i) + '%' });
        else anime.animate(s.track, { x: (-100 * i) + '%', duration: s.duration * 1.2, ease: 'inOut(3)' });
      }
      anime.utils.remove(s.dots);
      if (s.anim) s.anim.pause();
      if (instant || reduced() || prev < 0) {
        s.dots.forEach(function (d, k) { d.style.width = (k === i ? z.pill : z.dot) + 'px'; });
        var e = edges(s.dots[i]);
        s.p.l = e.l; s.p.r = e.r;
        drawPill(s);
      } else {
        // The old dot shrinks and the new one widens, so the layout shifts as
        // we go; the pill follows both live, its leading end first.
        var right = i > prev;
        var from = { l: s.p.l, r: s.p.r };
        var q = { a: 0, b: 0 };
        anime.animate(s.dots[prev], { width: z.dot, duration: s.duration, ease: 'inOut(3)' });
        anime.animate(s.dots[i], { width: z.pill, duration: s.duration, ease: 'inOut(3)' });
        var dist = Math.abs(i - prev);
        var dur = s.duration + Math.min(4, dist - 1) * 70;
        var lag = dur * 0.3;
        s.anim = anime.animate(q, {
          a: { to: 1, delay: right ? lag : 0 },
          b: { to: 1, delay: right ? 0 : lag },
          duration: dur,
          ease: 'inOut(3)',
          onRender: function () {
            var e = edges(s.dots[i]);
            s.p.l = from.l + (e.l - from.l) * q.a;
            s.p.r = from.r + (e.r - from.r) * q.b;
            drawPill(s);
          }
        });
      }
      if (prev >= 0 && !instant) el.dispatchEvent(new CustomEvent('pagechange', { bubbles: true, detail: { index: i } }));
    }

    function setup(el) {
      var d = el.dataset;
      var a11y = !el.closest('[aria-hidden="true"]');
      el.classList.add('pgd');
      if (!el.children.length) {
        var count = +d.count || DEFAULTS.count;
        for (var k = 0; k < count; k++) {
          var b = document.createElement(a11y ? 'button' : 'span');
          if (a11y) { b.type = 'button'; b.setAttribute('aria-label', 'Page ' + (k + 1)); }
          el.appendChild(b);
        }
      }
      var dots = Array.prototype.slice.call(el.children);
      var dotColor = d.dot || DEFAULTS.dot;
      dots.forEach(function (dot) { dot.classList.add('pgd-dot'); dot.style.background = dotColor; });
      var pill = document.createElement('span');
      pill.className = 'pgd-pill';
      pill.setAttribute('aria-hidden', 'true');
      pill.style.background = d.color || DEFAULTS.color;
      el.appendChild(pill);
      var track = find(el, d.slides);
      if (track) track.classList.add('pgd-track');

      var s = el._pgd = {
        dots: dots, pill: pill, track: track, a11y: a11y, index: -1, anim: null, p: { l: 0, r: 0 },
        duration: +d.duration || DEFAULTS.duration
      };
      go(el, +d.active || 0, true);

      var idleUntil = 0;
      dots.forEach(function (dot, k) {
        dot.addEventListener('click', function (e) {
          if (dot.tagName === 'A' && dot.getAttribute('href') === '#') e.preventDefault();
          idleUntil = Date.now() + 8000;
          go(el, k);
        });
      });
      el.addEventListener('keydown', function (e) {
        if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
        e.preventDefault();
        idleUntil = Date.now() + 8000;
        go(el, s.index + (e.key === 'ArrowRight' ? 1 : -1));
        if (s.a11y && s.dots[s.index].focus) s.dots[s.index].focus();
      });
      if (window.ResizeObserver) new ResizeObserver(function () { if (!s.anim || s.anim.completed) go(el, s.index, true); }).observe(el);

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
        var timer = null, canHover = window.matchMedia('(hover: hover)').matches;
        var tick = function () {
          if (Date.now() > idleUntil && !(canHover && el.matches(':hover'))) go(el, s.index + 1);
          timer = setTimeout(tick, DEFAULTS.every);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(tick, 900);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-pagination-dots]').forEach(function (el) {
        if (el._pgd) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.paginationDots = function (el, i) { if (el._pgd) go(el, i); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-pagination-dots data-count="5"></div>

<!-- moving a carousel: -->
<div class="carousel" style="overflow:hidden">
  <div id="slides"><img …><img …><img …></div>
</div>
<div data-pagination-dots data-count="3" data-slides="#slides"></div>