Trapdoor Menu
Press Menu and the page swings down on its hinge like a trapdoor, showing the menu underneath: links rise as the edge passes them, an image follows your pointer. Closing replays it backwards. No GSAP.
View codeSeven slots that never jump: the ink pill slides to the page and the numbers that change roll into place. Compact version included.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add Kodza Pagination under my blog list.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<nav class="kodza-pagination" aria-label="Pagination" data-pages="12" data-current="3"></nav>
<nav class="kodza-pagination kodza-pagination--compact" aria-label="Pagination" data-pages="12" data-current="3"></nav>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Pagination
Page numbers with previous and next, seven slots wide (five when the row is too narrow, a
phone) so nothing jumps: the ink
pill slides to the chosen page and the numbers that change roll to their new value.
--compact shows "Page 3 of 12" instead. Set the pages with data-pages and data-current;
data-href="?page={n}" turns the pages into real links.
Everything is tuned with the --kz-pages-* variables. */
.kodza-pagination {
--kz-pages-fg: #0a0a0a;
--kz-pages-muted: rgba(10, 10, 10, 0.58);
--kz-pages-border: rgba(10, 10, 10, 0.1);
--kz-pages-soft: rgba(10, 10, 10, 0.06);
--kz-pages-ink: #0a0a0a;
--kz-pages-on-ink: #ffffff;
--kz-pages-size: 40px;
--kz-pages-gap: 4px;
--kz-pages-duration: 0.5s;
--kz-pages-ease: cubic-bezier(0.22, 1, 0.36, 1);
display: flex;
align-items: center;
gap: var(--kz-pages-gap);
color: var(--kz-pages-fg);
font-size: 15px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.kodza-pagination__slots {
position: relative;
display: flex;
gap: var(--kz-pages-gap);
margin: 0;
padding: 0;
list-style: none;
}
/* The pill under the current page: it only moves with transform */
.kodza-pagination__indicator {
position: absolute;
top: 0;
left: 0;
width: var(--kz-pages-size);
height: var(--kz-pages-size);
border-radius: 999px;
background: var(--kz-pages-ink);
transform: translateX(var(--kz-pages-x, 0));
}
.kodza-pagination.is-ready .kodza-pagination__indicator {
transition: transform var(--kz-pages-duration) var(--kz-pages-ease);
}
.kodza-pagination__slot {
position: relative;
display: grid;
width: var(--kz-pages-size);
height: var(--kz-pages-size);
overflow: hidden;
border-radius: 999px;
}
.kodza-pagination__slot > * {
grid-area: 1 / 1;
}
.kodza-pagination__page,
.kodza-pagination__arrow {
display: grid;
place-items: center;
width: var(--kz-pages-size);
height: var(--kz-pages-size);
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: var(--kz-pages-muted);
font: inherit;
text-decoration: none;
cursor: pointer;
transition: background-color 0.25s ease, color 0.3s ease;
}
.kodza-pagination__page:hover {
background: var(--kz-pages-soft);
color: var(--kz-pages-fg);
}
.kodza-pagination__page[aria-current="page"] {
background: none;
color: var(--kz-pages-on-ink);
cursor: default;
transition-delay: 0.1s;
}
.kodza-pagination__gap {
display: grid;
place-items: center;
color: var(--kz-pages-muted);
}
.kodza-pagination__arrow {
flex-shrink: 0;
box-shadow: inset 0 0 0 1px var(--kz-pages-border);
color: var(--kz-pages-fg);
}
.kodza-pagination__arrow:hover {
background: var(--kz-pages-soft);
}
.kodza-pagination__arrow:disabled {
color: var(--kz-pages-muted);
opacity: 0.45;
cursor: default;
}
.kodza-pagination__arrow:disabled:hover {
background: none;
}
.kodza-pagination__arrow svg {
width: 16px;
height: 16px;
}
.kodza-pagination__page:focus-visible,
.kodza-pagination__arrow:focus-visible {
outline: 2px solid var(--kz-pages-fg);
outline-offset: -2px;
}
.kodza-pagination__page[aria-current="page"]:focus-visible {
outline-color: var(--kz-pages-on-ink);
outline-offset: -5px;
}
/* --compact: "Page 3 of 12" between the arrows */
.kodza-pagination__label {
display: flex;
align-items: center;
gap: 0.3em;
padding: 0 14px;
color: var(--kz-pages-muted);
white-space: nowrap;
}
.kodza-pagination__label b {
display: inline-grid;
overflow: hidden;
color: var(--kz-pages-fg);
font-weight: 500;
}
.kodza-pagination__label b > * {
grid-area: 1 / 1;
text-align: center;
}
.kodza-pagination__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.kodza-pagination.is-ready .kodza-pagination__indicator,
.kodza-pagination__page,
.kodza-pagination__arrow {
transition-duration: 0.01ms !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-pagination {
--kz-pages-fg: #f2f2f0;
--kz-pages-muted: rgba(242, 242, 240, 0.6);
--kz-pages-border: rgba(255, 255, 255, 0.14);
--kz-pages-soft: rgba(255, 255, 255, 0.08);
--kz-pages-ink: #f2f2f0;
--kz-pages-on-ink: #0a0a0a;
}
*/
/* kodza-guard 83c4b8526a69 — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-pagination__arrow):not(#_),
:where(.kodza-pagination__page):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-pagination__indicator):not(#_) {
transition: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-pagination:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
font-family: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-pagination__arrow:not(#_) {
margin: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-pagination__slots:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-pagination__indicator:not(#_) {
border: revert;
margin: revert;
padding: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-pagination__slot:not(#_) {
border: revert;
margin: revert;
padding: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-pagination__page:not(#_) {
margin: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-pagination__gap:not(#_),
.kodza-pagination__sr:not(#_),
.kodza-pagination__label span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-pagination__label:not(#_) {
border: revert;
margin: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-pagination__arrow svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-pagination__label b:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-pagination__indicator:not(#_):where(:not([hidden])),
.kodza-pagination__arrow svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-pagination__indicator:not(#_):where([hidden]),
.kodza-pagination__arrow svg:where(:not([class*="kodza-"])):not(#_):where([hidden]) {
display: none;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-pagination:not(#_) {
display: flex;
color: var(--kz-pages-fg);
font-size: 15px;
font-weight: 500;
}
.kodza-pagination__slots:not(#_) {
position: relative;
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-pagination__indicator:not(#_) {
position: absolute;
border-radius: 999px;
background: var(--kz-pages-ink);
}
.kodza-pagination.is-ready .kodza-pagination__indicator:not(#_) {
transition: transform var(--kz-pages-duration) var(--kz-pages-ease);
}
.kodza-pagination__slot:not(#_) {
position: relative;
display: grid;
border-radius: 999px;
}
.kodza-pagination__slot > *:not(#_) {
grid-area: 1 / 1;
}
.kodza-pagination__page:not(#_),
.kodza-pagination__arrow:not(#_) {
display: grid;
place-items: center;
width: var(--kz-pages-size);
height: var(--kz-pages-size);
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: var(--kz-pages-muted);
font: inherit;
text-decoration: none;
cursor: pointer;
transition: background-color 0.25s ease, color 0.3s ease;
}
.kodza-pagination__page:hover:not(#_) {
background: var(--kz-pages-soft);
color: var(--kz-pages-fg);
}
.kodza-pagination__page[aria-current="page"]:not(#_) {
background: none;
color: var(--kz-pages-on-ink);
cursor: default;
transition-delay: 0.1s;
}
.kodza-pagination__arrow:not(#_) {
flex-shrink: 0;
box-shadow: inset 0 0 0 1px var(--kz-pages-border);
color: var(--kz-pages-fg);
}
.kodza-pagination__arrow:hover:not(#_) {
background: var(--kz-pages-soft);
}
.kodza-pagination__arrow:disabled:not(#_) {
color: var(--kz-pages-muted);
opacity: 0.45;
cursor: default;
}
.kodza-pagination__arrow:disabled:hover:not(#_) {
background: none;
}
.kodza-pagination__page:focus-visible:not(#_),
.kodza-pagination__arrow:focus-visible:not(#_) {
outline: 2px solid var(--kz-pages-fg);
outline-offset: -2px;
}
.kodza-pagination__page[aria-current="page"]:focus-visible:not(#_) {
outline-color: var(--kz-pages-on-ink);
outline-offset: -5px;
}
.kodza-pagination__label:not(#_) {
display: flex;
padding: 0 14px;
color: var(--kz-pages-muted);
}
.kodza-pagination__label b:not(#_) {
display: inline-grid;
color: var(--kz-pages-fg);
font-weight: 500;
}
@media (prefers-reduced-motion: reduce) {
.kodza-pagination.is-ready .kodza-pagination__indicator:not(#_) {
transition-duration: 0.01ms !important;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-pagination__page:not(#_),
.kodza-pagination__arrow:not(#_) {
transition-duration: 0.01ms !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Pagination
// Draws the pages from data-pages and data-current, seven slots wide (first, last, the
// neighbours of the current page, gaps as "…"), five when the row is too narrow for seven (a
// phone: 1 … 3 … 12). On a change, the pill slides and only the slots whose number changes
// roll. Fires "kodza:page" with { page } on the nav.
(() => {
const SELECTOR = '.kodza-pagination';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const SLOTS = 7;
const NARROW = 5;
const arrow = (d) => `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="${d}" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
// The numbers to show around the current page, `count` of them (null is a gap). A gap never
// hides a single page: that page is shown instead
const windowAround = (current, pages, count) => {
if (pages <= count) return Array.from({ length: pages }, (_, i) => i + 1);
const side = (count - 5) / 2; // neighbours on each side of the current page
const run = (from, length) => Array.from({ length }, (_, i) => from + i);
if (current <= side + 3) return [...run(1, count - 2), null, pages];
if (current >= pages - side - 2) return [1, null, ...run(pages - count + 3, count - 2)];
return [1, null, ...run(current - side, side * 2 + 1), null, pages];
};
// Old content leaves through the top, new content rises from below
const roll = (box, next, duration, ease, delay = 0) => {
const prev = box.firstElementChild;
box.replaceChildren(...(prev ? [prev, next] : [next]));
if (!prev || reduceMotion) {
prev?.remove();
return;
}
prev.setAttribute('aria-hidden', 'true');
prev.animate([{ transform: 'none' }, { transform: 'translateY(-105%)' }], { duration, delay, easing: ease, fill: 'forwards' }).finished.then(() => prev.remove());
next.animate([{ transform: 'translateY(105%)' }, { transform: 'none' }], { duration, delay, easing: ease, fill: 'backwards' });
};
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const pages = Math.max(1, parseInt(root.dataset.pages, 10) || 1);
const href = root.dataset.href;
const compact = root.classList.contains('kodza-pagination--compact');
const style = getComputedStyle(root);
const duration = parseFloat(style.getPropertyValue('--kz-pages-duration')) * 1000 || 500;
const ease = style.getPropertyValue('--kz-pages-ease').trim() || 'ease';
let current = Math.min(pages, Math.max(1, parseInt(root.dataset.current, 10) || 1));
const makeArrow = (label, d) => {
const el = document.createElement(href ? 'a' : 'button');
el.className = 'kodza-pagination__arrow';
el.setAttribute('aria-label', label);
el.innerHTML = arrow(d);
if (!href) el.type = 'button';
return el;
};
const prev = makeArrow('Previous page', 'M10 3.5 5.5 8l4.5 4.5');
const next = makeArrow('Next page', 'M6 3.5 10.5 8 6 12.5');
// Slots (numbers) or a label (compact), between the two arrows
const slotList = document.createElement(compact ? 'p' : 'ol');
let slots = [];
let indicator = null;
let number = null;
const live = document.createElement('span');
live.className = 'kodza-pagination__sr';
live.setAttribute('aria-live', 'polite');
if (compact) {
slotList.className = 'kodza-pagination__label';
number = document.createElement('b');
slotList.append('Page', number, `of ${pages}`);
} else {
slotList.className = 'kodza-pagination__slots';
indicator = document.createElement('li');
indicator.className = 'kodza-pagination__indicator';
indicator.setAttribute('aria-hidden', 'true');
}
root.replaceChildren(prev, slotList, next, live);
// Seven slots, or five when the parent has no room for seven. Arrows and slots share one size
const slotCount = () => {
const parent = root.parentElement;
const box = parent && getComputedStyle(parent);
const room = parent ? parent.clientWidth - parseFloat(box.paddingLeft) - parseFloat(box.paddingRight) : 0;
const size = prev.offsetWidth;
const gap = parseFloat(style.columnGap) || 0;
return Math.min(pages, room > 0 && (SLOTS + 2) * size + (SLOTS + 1) * gap > room ? NARROW : SLOTS);
};
const buildSlots = (count) => {
slots = Array.from({ length: count }, () => {
const li = document.createElement('li');
li.className = 'kodza-pagination__slot';
return li;
});
slotList.replaceChildren(indicator, ...slots);
};
if (!compact) buildSlots(slotCount());
const pageEl = (n) => {
if (n === null) {
const gap = document.createElement('span');
gap.className = 'kodza-pagination__gap';
gap.setAttribute('aria-hidden', 'true');
gap.textContent = '…';
return gap;
}
const el = document.createElement(href ? 'a' : 'button');
el.className = 'kodza-pagination__page';
el.textContent = n;
el.dataset.page = n;
el.setAttribute('aria-label', `Page ${n}`);
if (href) el.href = href.replace('{n}', n);
else el.type = 'button';
if (n === current) el.setAttribute('aria-current', 'page');
return el;
};
const render = (animate) => {
const hadFocus = root.contains(document.activeElement) && document.activeElement.classList.contains('kodza-pagination__page');
if (compact) {
const span = document.createElement('span');
span.textContent = current;
if (number.textContent !== String(current)) roll(number, span, animate ? duration : 0, ease);
} else {
windowAround(current, pages, slots.length).forEach((n, i) => {
const slot = slots[i];
const old = slot.lastElementChild;
const same = old && (old.dataset.page ?? null) === (n === null ? null : String(n));
if (same) {
old.toggleAttribute('aria-current', n === current);
if (n === current) old.setAttribute('aria-current', 'page');
} else {
roll(slot, pageEl(n), animate ? duration : 0, ease, i * 30);
}
if (n === current) indicator.style.setProperty('--kz-pages-x', `${slot.offsetLeft}px`);
});
if (hadFocus) root.querySelector('[aria-current="page"]')?.focus({ preventScroll: true });
}
const ends = [[prev, current <= 1, current - 1], [next, current >= pages, current + 1]];
ends.forEach(([el, off, n]) => {
if (href) {
el.toggleAttribute('aria-disabled', off);
if (off) el.removeAttribute('href');
else el.href = href.replace('{n}', n);
} else {
el.disabled = off;
}
});
live.textContent = `Page ${current} of ${pages}`;
};
const go = (n) => {
const page = Math.min(pages, Math.max(1, n));
if (page === current) return;
current = page;
root.dataset.current = page;
render(true);
root.dispatchEvent(new CustomEvent('kodza:page', { detail: { page }, bubbles: true }));
};
if (!href) {
root.addEventListener('click', (event) => {
const target = event.target.closest('button');
if (!target || target.disabled) return;
if (target === prev) go(current - 1);
else if (target === next) go(current + 1);
else if (target.dataset.page) go(Number(target.dataset.page));
}, { signal });
}
// The arrow keys turn the page: the next number, or the link to the next page
root.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
const link = event.key === 'ArrowRight' ? next : prev;
if (!href) go(current + (event.key === 'ArrowRight' ? 1 : -1));
else if (link.hasAttribute('href')) link.click();
}, { signal });
render(false);
// The pill takes its place before is-ready turns its transition on: it must not slide in
if (indicator) void getComputedStyle(indicator).transform;
// The row narrows or widens (a phone turned, a sidebar opened): seven slots or five again.
// The pill jumps with the new layout instead of sliding across it
if (!compact && root.parentElement) {
const observer = new ResizeObserver(() => {
const count = slotCount();
if (count === slots.length) return;
const hadFocus = root.contains(document.activeElement);
buildSlots(count);
indicator.style.transition = 'none';
render(false);
void getComputedStyle(indicator).transform;
indicator.style.transition = '';
if (hadFocus && !root.contains(document.activeElement)) root.querySelector('[aria-current="page"]')?.focus({ preventScroll: true });
});
observer.observe(root.parentElement);
signal.addEventListener('abort', () => observer.disconnect());
}
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load.
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
instances.set(root, { controller, html: root.innerHTML, attrs: [...root.attributes].map((a) => [a.name, a.value]) });
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.controller.abort();
[...root.attributes].forEach((a) => root.removeAttribute(a.name));
instance.attrs.forEach(([name, value]) => root.setAttribute(name, value));
root.innerHTML = instance.html;
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Pagination, for React. From https://kodza.dev/components/pagination/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: pagination.css and pagination.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './pagination.css';
type Variant = "compact";
export type PaginationProps = Omit<ComponentPropsWithoutRef<'nav'>, "children" | "variant" | "vars" | "pages" | "current" | "onPage"> & {
/** Modifier class(es): .kodza-pagination--compact */
variant?: Variant | Variant[];
/** data-pages on the root (default: 12) */
pages?: "12";
/** data-current on the root (default: 3) */
current?: "3";
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-pages-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.58) */
"--kz-pages-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-pages-border"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-pages-soft"?: string | number;
/** default: #0a0a0a */
"--kz-pages-ink"?: string | number;
/** default: #ffffff */
"--kz-pages-on-ink"?: string | number;
/** default: 40px */
"--kz-pages-size"?: string | number;
/** default: 4px */
"--kz-pages-gap"?: string | number;
/** default: 0.5s */
"--kz-pages-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-pages-ease"?: string | number;
};
/** "kodza:page", fired by the script (bubbles from the root) */
onPage?: (event: CustomEvent) => void;
className?: string;
};
export function Pagination({ variant, pages = "12", current = "3", vars, onPage, className, style, ...rest }: PaginationProps) {
const ref = useRef<ComponentRef<'nav'>>(null);
const classes = ["kodza-pagination", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-pagination--${v}`), className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, pages, current, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./pagination.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
useEffect(() => {
const root = ref.current;
if (!root || !onPage) return;
const listener = (event: Event) => onPage(event as CustomEvent);
root.addEventListener("kodza:page", listener);
return () => root.removeEventListener("kodza:page", listener);
}, [onPage, key]);
return (
<nav aria-label="Pagination" key={key} {...rest} className={classes} data-pages={pages} data-current={current} style={{ ...vars, ...style } as CSSProperties} ref={ref} />
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.