Pagination
Free · MITControlled 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
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
/*
* 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">
…
</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.