Skip to content

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, configurable aria-label

Source Code

View Source Code
ts
import { computed, define, html } from '@vielzeug/craftit';

import '../icon/icon';
import '../../inputs/button/button';
import type { ComponentSize, ThemeColor, VisualVariant } from '../../types';

import { sizableBundle, themableBundle } from '../../inputs/shared/bundles';
import { coarsePointerMixin, colorThemeMixin, sizeVariantMixin } from '../../styles';
import componentStyles from './pagination.css?inline';

export type BitPaginationEvents = {
  change: { page: number };
};

export type BitPaginationProps = {
  color?: ThemeColor;
  label?: string;
  page?: number;
  'show-first-last'?: boolean;
  'show-prev-next'?: boolean;
  siblings?: number;
  size?: ComponentSize;
  'total-pages'?: number;
  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
 *
 * @part page-btn - Page button.
 * @part nav - Navigation container.
 * @part list - List container.
 * @part first-btn - First page button.
 * @part prev-btn - Previous page button.
 * @part next-btn - Next page button.
 * @part last-btn - Last page button.
 * @example
 * ```html
 * <bit-pagination page="3" total-pages="10" color="primary"></bit-pagination>
 * ```
 */
export const PAGINATION_TAG = define<BitPaginationProps, BitPaginationEvents>('bit-pagination', {
  props: {
    ...themableBundle,
    ...sizableBundle,
    label: 'Pagination',
    page: 1,
    'show-first-last': false,
    'show-prev-next': false,
    siblings: 1,
    'total-pages': 1,
    variant: undefined,
  },
  setup(props, { emit }) {
    function goTo(page: number) {
      const total = props['total-pages'].value || 1;
      const next = Math.min(Math.max(1, page), total);

      if (next === props.page.value) return;

      props.page.value = 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(props.page.value || 1, props['total-pages'].value || 1, props.siblings.value ?? 1),
    );

    const isFirst = computed(() => (props.page.value || 1) <= 1);
    const isLast = computed(() => (props.page.value || 1) >= (props['total-pages'].value || 1));

    return () => html`
      <nav :aria-label="${props.label}" 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}
                    @click=${() => goTo(1)}>
                    <bit-icon name="chevrons-left" size="16" aria-hidden="true"></bit-icon>
                  </button>
                </li>`
              : ''}
          ${() =>
            props['show-prev-next'].value
              ? html`<li>
                  <button
                    type="button"
                    class="nav-btn"
                    part="prev-btn"
                    aria-label="Previous page"
                    ?disabled=${isFirst}
                    @click=${() => goTo((props.page.value || 1) - 1)}>
                    <bit-icon name="chevron-left" size="16" aria-hidden="true"></bit-icon>
                  </button>
                </li>`
              : ''}
          <li style="display: contents;">
            ${() =>
              pageItems.value.map((item) => {
                if (item === 'ellipsis-start' || item === 'ellipsis-end') {
                  return html`<span class="ellipsis" aria-hidden="true">&hellip;</span>`;
                }

                const pg = item as number;
                const isCurrent = pg === (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>`;
              })}
          </li>
          ${() =>
            props['show-prev-next'].value
              ? html`<li>
                  <button
                    type="button"
                    class="nav-btn"
                    part="next-btn"
                    aria-label="Next page"
                    ?disabled=${isLast}
                    @click=${() => goTo((props.page.value || 1) + 1)}>
                    <bit-icon name="chevron-right" size="16" aria-hidden="true"></bit-icon>
                  </button>
                </li>`
              : ''}
          ${() =>
            props['show-first-last'].value
              ? html`<li>
                  <button
                    type="button"
                    class="nav-btn"
                    part="last-btn"
                    aria-label="Last page"
                    ?disabled=${isLast}
                    @click=${() => goTo(props['total-pages'].value || 1)}>
                    <bit-icon name="chevrons-right" size="16" aria-hidden="true"></bit-icon>
                  </button>
                </li>`
              : ''}
        </ol>
      </nav>
    `;
  },

  styles: [coarsePointerMixin, colorThemeMixin, sizeVariantMixin(), componentStyles],
});

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('change', (e) => {
    console.log('New page:', e.detail.page);
  });
</script>

Colors

PreviewCode
RTL

Variants

The variant prop controls the visual style of the previous, next, first, and last navigation buttons. Page number buttons are unaffected.

PreviewCode
RTL

Sizes

PreviewCode
RTL

With Previous / Next

PreviewCode
RTL

With First / Last

PreviewCode
RTL

All Controls

PreviewCode
RTL

Ellipsis / Sibling Pages

Use siblings to control how many page numbers appear on each side of the current page before collapsing to an ellipsis.

PreviewCode
RTL

API Reference

Attributes

AttributeTypeDefaultDescription
pagenumber1Currently active page (1-based)
total-pagesnumber1Total number of pages
siblingsnumber1Page buttons visible on each side of the current page
show-first-lastbooleanfalseShow first and last page buttons
show-prev-nextbooleanfalseShow 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
labelstring'Pagination'aria-label for the nav landmark

Events

EventDetailDescription
change{ page: number }Fired when the user selects a page

CSS Custom Properties

PropertyDescription
--pagination-item-sizeWidth and height of each page button
--pagination-gapGap between page buttons
--pagination-radiusBorder radius of page buttons

Accessibility

The pagination component follows WAI-ARIA best practices.

bit-pagination

Keyboard Navigation

  • Tab moves focus between page buttons; Enter / Space activate 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".