Bootstrap 5 Month Pagination with Year Switcher
A month picker for calendars and event archives: a single pill bar of twelve months with the year shown once, dots on months that have events, and arrows that step month by month and roll over the year. The selected month lists its events below.
<section class="wdw-stage">
<div class="container" style="max-width:960px;min-width:0">
<div class="wdw-card p-4 p-md-5">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div><span class="wdw-eyebrow">Calendar</span><h2 class="wdw-heading h3 mt-1 mb-0">Browse by month</h2></div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-outline-secondary rounded-circle p-0" style="width:44px" id="mpPrev" aria-label="Previous month"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 6-6 6 6 6"/></svg></button>
<span class="fw-bold fs-5 text-body-emphasis text-center" style="min-width:4.5rem" id="mpYear">2026</span>
<button type="button" class="btn btn-outline-secondary rounded-circle p-0" style="width:44px" id="mpNext" aria-label="Next month"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg></button>
</div>
</div>
<div class="mp p-1 rounded-4 bg-body-tertiary border" role="radiogroup" aria-label="Month" id="mpList"></div>
<div class="d-flex align-items-center gap-2 mt-4 mb-2"><h3 class="h6 fw-bold mb-0" id="mpOut" role="status" aria-live="polite"></h3><span class="small wdw-muted d-inline-flex align-items-center gap-1 ms-auto"><span class="mp-dot-key" aria-hidden="true"></span>Has events</span></div>
<ul class="list-group list-group-flush" id="mpEvents"></ul>
</div>
</div>
</section>
/* Web Designer Wall library style — shared base for every Bootstrap 5 snippet.
Indigo accent, Plus Jakarta Sans, soft cards, pill buttons. Works in light and dark. */
@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap");
:root,
[data-bs-theme="light"] {
--wdw-font: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--bs-body-font-family: var(--wdw-font);
--bs-primary: #4f46e5;
--bs-primary-rgb: 79, 70, 229;
--bs-primary-text-emphasis: #3730a3;
--bs-primary-bg-subtle: #eef2ff;
--bs-primary-border-subtle: #c7d2fe;
--bs-link-color: #4f46e5;
--bs-link-color-rgb: 79, 70, 229;
--bs-link-hover-color: #4338ca;
--bs-link-hover-color-rgb: 67, 56, 202;
--bs-focus-ring-color: rgba(79, 70, 229, 0.25);
--bs-border-radius: 0.75rem;
--bs-border-radius-lg: 1rem;
--bs-border-radius-xl: 1.25rem;
--wdw-radius-card: 1.25rem;
--wdw-shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 16px 40px -16px rgba(16, 24, 40, 0.22);
--wdw-shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 12px -4px rgba(16, 24, 40, 0.12);
--wdw-stage: #f5f6fb;
}
[data-bs-theme="dark"] {
--bs-primary: #818cf8;
--bs-primary-rgb: 129, 140, 248;
--bs-primary-text-emphasis: #c7d2fe;
--bs-primary-bg-subtle: rgba(129, 140, 248, 0.14);
--bs-primary-border-subtle: rgba(129, 140, 248, 0.35);
--bs-link-color: #a5b4fc;
--bs-link-color-rgb: 165, 180, 252;
--bs-link-hover-color: #c7d2fe;
--bs-link-hover-color-rgb: 199, 210, 254;
--bs-focus-ring-color: rgba(129, 140, 248, 0.35);
--wdw-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 16px 40px -16px rgba(0, 0, 0, 0.7);
--wdw-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 12px -4px rgba(0, 0, 0, 0.5);
--wdw-stage: #0f1117;
}
body { font-family: var(--wdw-font); -webkit-font-smoothing: antialiased; }
/* Bootstrap compiles its blue into these components; point them at the accent. */
.btn-primary {
--bs-btn-bg: var(--bs-primary); --bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: #4338ca; --bs-btn-hover-border-color: #4338ca;
--bs-btn-active-bg: #3730a3; --bs-btn-active-border-color: #3730a3;
--bs-btn-disabled-bg: var(--bs-primary); --bs-btn-disabled-border-color: var(--bs-primary);
--bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}
[data-bs-theme="dark"] .btn-primary { --bs-btn-color: #0f1117; --bs-btn-hover-bg: #a5b4fc; --bs-btn-hover-border-color: #a5b4fc; --bs-btn-hover-color: #0f1117; --bs-btn-active-color: #0f1117; }
.btn-outline-primary {
--bs-btn-color: var(--bs-primary); --bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary); --bs-btn-hover-border-color: var(--bs-primary);
--bs-btn-active-bg: var(--bs-primary); --bs-btn-active-border-color: var(--bs-primary);
--bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}
.btn { --bs-btn-font-weight: 600; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: .5rem; }
.btn-sm { min-height: 36px; }
.form-control, .form-select { min-height: 44px; border-radius: .75rem; }
.form-control:focus, .form-select:focus, .form-check-input:focus { border-color: rgba(var(--bs-primary-rgb), .6); box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.form-check-input:checked { background-color: var(--bs-primary); border-color: var(--bs-primary); }
.nav-pills { --bs-nav-pills-link-active-bg: var(--bs-primary); }
.nav { --bs-nav-link-color: var(--bs-secondary-color); --bs-nav-link-hover-color: var(--bs-emphasis-color); }
.pagination { --bs-pagination-active-bg: var(--bs-primary); --bs-pagination-active-border-color: var(--bs-primary); --bs-pagination-color: var(--bs-body-color); --bs-pagination-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.progress, .progress-stacked { --bs-progress-bar-bg: var(--bs-primary); }
.list-group { --bs-list-group-active-bg: var(--bs-primary); --bs-list-group-active-border-color: var(--bs-primary); }
.dropdown-menu { --bs-dropdown-link-active-bg: var(--bs-primary); border-radius: .875rem; box-shadow: var(--wdw-shadow-sm); }
.accordion { --bs-accordion-active-bg: var(--bs-primary-bg-subtle); --bs-accordion-active-color: var(--bs-primary-text-emphasis); --bs-accordion-btn-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.badge { font-weight: 600; }
/* Library building blocks — use these instead of re-inventing them per snippet. */
.wdw-stage { min-height: 100vh; display: flex; align-items: center; background: var(--wdw-stage); padding-block: 3rem; }
.wdw-card { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color-translucent); border-radius: var(--wdw-radius-card); box-shadow: var(--wdw-shadow); }
.wdw-card-sm { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color-translucent); border-radius: 1rem; box-shadow: var(--wdw-shadow-sm); }
.wdw-aurora { position: relative; color: #fff; background: radial-gradient(60% 55% at 100% 0, #818cf8 0, transparent 70%), radial-gradient(65% 60% at 0 100%, #ec4899 0, transparent 65%), #1e1b4b; }
.wdw-eyebrow { display: inline-block; font-size: .75rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--bs-primary); }
.wdw-heading { font-weight: 800; letter-spacing: -.02em; color: var(--bs-emphasis-color); }
.wdw-muted { color: var(--bs-secondary-color); }
.wdw-icon { width: 2.75rem; height: 2.75rem; flex: none; display: inline-flex; align-items: center; justify-content: center; border-radius: .875rem; background: var(--bs-primary-bg-subtle); color: var(--bs-primary); }
/* Component */
.mp { display: grid; grid-template-columns: repeat(12, 1fr); gap: .25rem; }
@media (max-width: 767.98px) { .mp { grid-template-columns: repeat(4, 1fr); } }
.mp .btn { --bs-btn-border-color: transparent; --bs-btn-color: var(--bs-body-color); --bs-btn-hover-color: var(--bs-primary); --bs-btn-hover-bg: var(--bs-primary-bg-subtle); --bs-btn-hover-border-color: transparent; --bs-btn-active-color: #fff; position: relative; padding-inline: 0; border-radius: 50rem; }
[data-bs-theme="dark"] .mp .btn { --bs-btn-active-color: #0f1117; }
.mp .btn-check:checked + .btn { color: var(--bs-btn-active-color); }
.mp .btn-check:focus-visible + .btn { box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.mp-dot { position: absolute; bottom: 5px; left: 50%; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: currentColor; opacity: 0; }
.mp .has-ev .mp-dot { opacity: .7; }
.mp-dot-key { width: 6px; height: 6px; border-radius: 50%; background: var(--bs-primary); }
.mp-date { width: 3rem; flex: none; }
function initMonthPagination() {
const list = document.getElementById('mpList');
if (!list) return;
const names = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const full = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const titles = [['Design review','Team workshop'],['Product launch','Community meetup'],['Customer webinar','Roadmap planning'],['Open office hours','Release party'],['Hackathon','Partner briefing']];
const times = ['10:00','13:30','16:00'];
const yearEl = document.getElementById('mpYear');
const out = document.getElementById('mpOut');
const evList = document.getElementById('mpEvents');
const prev = document.getElementById('mpPrev');
const next = document.getElementById('mpNext');
const now = new Date();
const minIdx = (now.getFullYear() - 1) * 12;
const maxIdx = (now.getFullYear() + 1) * 12 + 11;
let idx = now.getFullYear() * 12 + now.getMonth();
const hasEvents = (i) => i % 5 !== 0;
const eventsFor = (i) => hasEvents(i) ? [0, 1, 2].map((k) => ({
day: 3 + ((i * 3 + k * 9) % 24),
title: titles[(i + k) % titles.length][k % 2],
time: times[(i + k) % 3]
})).sort((a, b) => a.day - b.day) : [];
list.innerHTML = names.map((n, m) => '<div><input type="radio" class="btn-check" name="mpMonth" id="mp' + m + '" value="' + m + '" autocomplete="off"><label class="btn btn-outline-primary w-100 fw-semibold" for="mp' + m + '" aria-label="' + full[m] + '">' + n + '<span class="mp-dot" aria-hidden="true"></span></label></div>').join('');
const radios = list.querySelectorAll('input');
const labels = list.querySelectorAll('label');
function update() {
const year = Math.floor(idx / 12);
const month = idx % 12;
yearEl.textContent = year;
radios.forEach((r, m) => {
const i = year * 12 + m;
r.checked = m === month;
r.disabled = i < minIdx || i > maxIdx;
labels[m].classList.toggle('has-ev', hasEvents(i));
});
prev.disabled = idx <= minIdx;
next.disabled = idx >= maxIdx;
out.textContent = 'Events in ' + full[month] + ' ' + year;
const evs = eventsFor(idx);
evList.innerHTML = evs.length ? evs.map((e) => '<li class="list-group-item bg-transparent px-0 d-flex align-items-center gap-3"><span class="mp-date wdw-icon text-center fw-bold" style="width:3rem">' + e.day + '</span><span class="fw-semibold">' + e.title + '</span><span class="ms-auto small wdw-muted">' + names[month] + ' ' + e.day + ', ' + e.time + '</span></li>').join('') : '<li class="list-group-item bg-transparent px-0 wdw-muted">No events this month.</li>';
}
radios.forEach((r) => r.addEventListener('change', () => { idx = Math.floor(idx / 12) * 12 + Number(r.value); update(); }));
prev.addEventListener('click', () => { if (idx > minIdx) { idx--; update(); } });
next.addEventListener('click', () => { if (idx < maxIdx) { idx++; update(); } });
update();
}
initMonthPagination();
Built on Bootstrap 5.3.8 with plain JavaScript — no jQuery. Supports light and dark mode via data-bs-theme.