Pagination
A page-navigation control for splitting content across multiple pages. Renders numbered page buttons with optional ellipsis, first/last shortcuts, and previous/next arrows.
Features
- 🔢 Numbered pages with automatic ellipsis when the range is large
- ⏮️ First / Last navigation buttons (opt-in)
- ◀️ Previous / Next buttons (opt-in)
- 🌈 6 Theme Colors: primary, secondary, info, success, warning, error
- 🎨 8 Variants: solid, flat, bordered, outline, ghost, text, frost, glass
- 📏 3 Sizes: sm, md, lg
- ♿ Accessible:
aria-current="page"on active button, configurablearia-label
Source Code
View Source Code
ts
import { computed, defineComponent, html } from '@vielzeug/craftit';
import { each } from '@vielzeug/craftit/directives';
import '../../inputs/button/button';
import type { ComponentSize, ThemeColor, VisualVariant } from '../../types';
import { coarsePointerMixin, colorThemeMixin, sizeVariantMixin } from '../../styles';
import styles from './pagination.css?inline';
export type BitPaginationEvents = {
change: { page: number };
};
/** Pagination props */
export type BitPaginationProps = {
/** Theme color */
color?: ThemeColor;
/** Accessible label for the nav landmark */
label?: string;
/** Current page (1-indexed) */
page?: number;
/** Show first/last page navigation buttons */
'show-first-last'?: boolean;
/** Show prev/next navigation buttons */
'show-prev-next'?: boolean;
/** Number of sibling pages shown around the current page */
siblings?: number;
/** Size */
size?: ComponentSize;
/** Total number of pages */
'total-pages'?: number;
/** Visual variant for nav buttons */
variant?: VisualVariant;
};
function buildPageRange(
currentPage: number,
totalPages: number,
siblings: number,
): Array<number | 'ellipsis-start' | 'ellipsis-end'> {
const BOUNDARY = 1; // always show first and last page
const total = totalPages;
const pages: Array<number | 'ellipsis-start' | 'ellipsis-end'> = [];
// If total is small enough, show all pages
if (total <= 2 * BOUNDARY + 2 * siblings + 3) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const leftSibling = Math.max(currentPage - siblings, BOUNDARY + 1);
const rightSibling = Math.min(currentPage + siblings, total - BOUNDARY);
pages.push(1);
if (leftSibling > BOUNDARY + 2) pages.push('ellipsis-start');
else if (leftSibling === BOUNDARY + 2) pages.push(BOUNDARY + 1);
for (let i = leftSibling; i <= rightSibling; i++) pages.push(i);
if (rightSibling < total - BOUNDARY - 1) pages.push('ellipsis-end');
else if (rightSibling === total - BOUNDARY - 1) pages.push(total - BOUNDARY);
pages.push(total);
return pages;
}
/**
* Page-based navigation control.
*
* @element bit-pagination
*
* @attr {number} page - Current page (1-indexed, default: 1)
* @attr {number} total-pages - Total number of pages (required)
* @attr {number} siblings - Sibling pages around current (default: 1)
* @attr {boolean} show-first-last - Show first/last page buttons (default: false)
* @attr {boolean} show-prev-next - Show prev/next buttons (default: false)
* @attr {string} color - Theme color
* @attr {string} variant - Visual variant for nav buttons: 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text' | 'frost' | 'glass' (default: 'ghost')
* @attr {string} size - 'sm' | 'md' | 'lg'
* @attr {string} label - Accessible nav label (default: 'Pagination')
*
* @fires change - Emitted when the page changes, with { page: number }
*
* @cssprop --pagination-item-size - Width/height of each item
* @cssprop --pagination-gap - Gap between items
* @cssprop --pagination-radius - Border radius of items
*
* @example
* ```html
* <bit-pagination page="3" total-pages="10" color="primary"></bit-pagination>
* ```
*/
export const PAGINATION_TAG = defineComponent<BitPaginationProps, BitPaginationEvents>({
props: {
color: { default: undefined },
label: { default: 'Pagination' },
page: { default: 1 },
'show-first-last': { default: false, type: Boolean },
'show-prev-next': { default: false, type: Boolean },
siblings: { default: 1 },
size: { default: undefined },
'total-pages': { default: 1 },
variant: { default: undefined },
},
setup({ emit, host, props }) {
function goTo(page: number) {
const total = Number(props['total-pages'].value) || 1;
const next = Math.min(Math.max(1, page), total);
if (next === Number(props.page.value)) return;
host.setAttribute('page', String(next));
emit('change', { page: next });
}
function handlePageClick(event: Event) {
const btn = (event.target as HTMLElement)?.closest('[part="page-btn"]') as HTMLButtonElement | null;
if (!btn) return;
const ariaLabel = btn.getAttribute('aria-label');
if (!ariaLabel) return;
const pageMatch = ariaLabel.match(/\d+/);
if (!pageMatch) return;
const page = Number(pageMatch[0]);
goTo(page);
}
const pageItems = computed(() =>
buildPageRange(
Number(props.page.value) || 1,
Number(props['total-pages'].value) || 1,
// eslint-disable-next-line no-constant-binary-expression
Number(props.siblings.value) ?? 1,
),
);
const isFirst = computed(() => (Number(props.page.value) || 1) <= 1);
const isLast = computed(() => (Number(props.page.value) || 1) >= (Number(props['total-pages'].value) || 1));
return html`
<nav :aria-label="${() => props.label.value}" part="nav" @click=${handlePageClick}>
<ol class="pagination" part="list">
${() =>
props['show-first-last'].value
? html`<li>
<button
type="button"
class="nav-btn"
part="first-btn"
aria-label="First page"
?disabled=${() => isFirst.value}
@click=${() => goTo(1)}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true">
<polyline points="11 17 6 12 11 7" />
<polyline points="18 17 13 12 18 7" />
</svg>
</button>
</li>`
: ''}
${() =>
props['show-prev-next'].value
? html`<li>
<button
type="button"
class="nav-btn"
part="prev-btn"
aria-label="Previous page"
?disabled=${() => isFirst.value}
@click=${() => goTo((Number(props.page.value) || 1) - 1)}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</li>`
: ''}
<li style="display: contents;">
${each(
pageItems,
(item) => {
if (item === 'ellipsis-start' || item === 'ellipsis-end') {
return html`<span class="ellipsis" aria-hidden="true">…</span>`;
}
const pg = item as number;
const isCurrent = pg === (Number(props.page.value) || 1);
return isCurrent
? html`<button type="button" part="page-btn" aria-label="Page ${pg}" aria-current="page">
${pg}
</button>`
: html`<button type="button" part="page-btn" aria-label="Page ${pg}">${pg}</button>`;
},
undefined,
{ key: (item) => `${item}` },
)}
</li>
${() =>
props['show-prev-next'].value
? html`<li>
<button
type="button"
class="nav-btn"
part="next-btn"
aria-label="Next page"
?disabled=${() => isLast.value}
@click=${() => goTo((Number(props.page.value) || 1) + 1)}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</li>`
: ''}
${() =>
props['show-first-last'].value
? html`<li>
<button
type="button"
class="nav-btn"
part="last-btn"
aria-label="Last page"
?disabled=${() => isLast.value}
@click=${() => goTo(Number(props['total-pages'].value) || 1)}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true">
<polyline points="13 17 18 12 13 7" />
<polyline points="6 17 11 12 6 7" />
</svg>
</button>
</li>`
: ''}
</ol>
</nav>
`;
},
styles: [colorThemeMixin, sizeVariantMixin({}), coarsePointerMixin, styles],
tag: 'bit-pagination',
});Basic Usage
html
<bit-pagination page="1" total-pages="10"></bit-pagination>
<script type="module">
import '@vielzeug/buildit';
</script>Listen for page changes:
html
<bit-pagination id="pager" page="1" total-pages="20" show-prev-next></bit-pagination>
<script type="module">
import '@vielzeug/buildit';
document.getElementById('pager').addEventListener('bit-change', (e) => {
console.log('New page:', e.detail.page);
});
</script>Colors
Variants
The variant prop controls the visual style of the previous, next, first, and last navigation buttons. Page number buttons are unaffected.
Sizes
Navigation Controls
With Previous / Next
With First / Last
All Controls
Ellipsis / Sibling Pages
Use siblings to control how many page numbers appear on each side of the current page before collapsing to an ellipsis.
API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Currently active page (1-based) |
total-pages | number | 1 | Total number of pages |
siblings | number | 1 | Page buttons visible on each side of the current page |
show-first-last | boolean | false | Show first and last page buttons |
show-prev-next | boolean | false | Show previous and next page buttons |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | — | Active page color |
variant | 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text' | 'frost' | 'glass' | 'ghost' | Visual style of nav buttons |
size | 'sm' | 'md' | 'lg' | 'md' | Component size |
label | string | 'pagination' | aria-label for the nav landmark |
Events
| Event | Detail | Description |
|---|---|---|
bit-change | { page: number } | Fired when the user selects a page |
CSS Custom Properties
| Property | Description |
|---|---|
--pagination-item-size | Width and height of each page button |
--pagination-gap | Gap between page buttons |
--pagination-radius | Border radius of page buttons |
Accessibility
The pagination component follows WAI-ARIA best practices.
bit-pagination
✅ Keyboard Navigation
Tabmoves focus between page buttons;Enter/Spaceactivate the focused button.- Previous and next navigation buttons are individually focusable.
✅ Screen Readers
- The component renders a
<nav>element as a navigation landmark. - Each page button's accessible name includes the page number.
- The active page has
aria-current="page". - Ellipsis items are decorative and marked
aria-hidden="true".