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.
How to use it
- Paste the snippet just before
</head>. - 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. - To move a carousel with it, give
data-slidesa selector for the element holding the slides (one child per slide, inside a box withoverflow: hidden).
Options
Add any of these attributes to the same element.
data-count | How many dots to make. Default 5. |
data-active | Index of the current dot at first, counting from 0. Default 0. |
data-color | Colour of the pill. Default #6a0dad. |
data-dot | Colour of the other dots. Default #d9cce8. |
data-slides | A selector for a row of slides to move along with the dots. |
data-duration | How long the slide takes. Default 520 ms. |
data-autoplay | It 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.
<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><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>