Skip to content
Velocity X Docs
← All components

Pagination

Free · MIT

Controlled numbered pager with ellipsis compression for large page counts and prev/next controls.

Category
Core UI
Framework
react
Version
1.0.0
Peer deps
react

Install

bash
velocityx add pagination

Free and MIT licensed — no account needed. The file lands under src/components/velocity/pagination/ by default (override with --dest), and it's yours to edit from the moment it's written.

Demo

View the live demo on the showcase site ↗

Source

Pulled live from the registry — this is the exact file velocityx add pagination writes into your project right now.

components/velocity/pagination/Pagination.jsx

tsx
/*
 *    VELOCITY COMPONENT · theming
 *
 *    Colours here are Tailwind `primary-*` / `accent-*`, which resolve through
 *    the Velocity preset. Out of the box they are a neutral indigo — NOT the
 *    Aidxn brand — so this drops into your project without dragging our look in.
 *
 *    ⚡ NOT USING VELOCITY? Run this once, in your project root:
 *
 *          velocityx theme
 *
 *    It writes velocity-preset.cjs + velocity-theme.css. Add the preset to your
 *    tailwind.config, import the css, then set your whole brand in ONE place:
 *
 *          :root {
 *            --vx-brand: custom;
 *            --vx-primary-600: 220 38 38;   <- your action colour
 *            --vx-primary-700: 185 28 28;
 *          }
 *
 *    ⚠️  Raw RGB channels, space separated — NOT hex. `#DC2626` here silently
 *        breaks every /opacity variant in the library (bg-primary-600/20 etc).
 *
 *    Prefer not to install anything? Find-and-replace `primary-` with your own
 *    scale name. Nothing else in this file depends on Velocity.
 */
/**
 * Pagination — numbered pager with ellipsis compression + prev/next.
 *
 * Controlled component: caller owns `page` state. Compresses to
 * first/last + a window around the current page once the total exceeds 7,
 * so it never overruns on mobile. Arrow keys move focus along the list.
 *
 * Props:
 *   page       number (1-indexed)
 *   totalPages number
 *   onChange   (page: number) => void
 */
import React from 'react';

function getRange(page, total) {
  if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
  const pages = new Set([1, total, page, page - 1, page + 1]);
  const sorted = Array.from(pages).filter((p) => p >= 1 && p <= total).sort((a, b) => a - b);
  const withGaps = [];
  sorted.forEach((p, i) => {
    if (i > 0 && p - sorted[i - 1] > 1) withGaps.push('...');
    withGaps.push(p);
  });
  return withGaps;
}

export default function Pagination({ page = 1, totalPages = 10, onChange }) {
  const items = getRange(page, totalPages);

  const Btn = ({ children, onClick, disabled, active, label }) => (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      aria-current={active ? 'page' : undefined}
      aria-label={label}
      className={`inline-flex h-9 min-w-9 items-center justify-center rounded-lg px-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 disabled:opacity-40 disabled:cursor-not-allowed ${
        active
          ? 'bg-primary-600 text-white'
          : 'text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-800'
      }`}
    >
      {children}
    </button>
  );

  return (
    <nav aria-label="Pagination" className="flex items-center gap-1">
      <Btn onClick={() => onChange?.(page - 1)} disabled={page <= 1} label="Previous page">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="m15 18-6-6 6-6" /></svg>
      </Btn>

      {items.map((it, i) =>
        it === '...' ? (
          <span key={`gap-${i}`} aria-hidden="true" className="px-1.5 text-secondary-400">
            &hellip;
          </span>
        ) : (
          <Btn key={it} onClick={() => onChange?.(it)} active={it === page} label={`Page ${it}`}>
            {it}
          </Btn>
        )
      )}

      <Btn onClick={() => onChange?.(page + 1)} disabled={page >= totalPages} label="Next page">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="m9 18 6-6-6-6" /></svg>
      </Btn>
    </nav>
  );
}

Theming

Uses the shared primary-* / accent-* scale — see Theming if colours look unstyled after install.