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, 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">&hellip;</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

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
bit-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".