Paginación
Un control para navegar entre páginas de contenido, permitiendo a los usuarios moverse a través de conjuntos de datos grandes o colecciones de contenido en fragmentos manejables.
Casos de Uso
- Datos de tabla con cientos o miles de filas
- Listados de publicaciones de blog y archivos de artículos
- Páginas de resultados de búsqueda
- Exploración de catálogos de productos en comercio electrónico
- Galerías de imágenes y bibliotecas de medios
- Hilos de comentarios con muchas respuestas
- Paneles de administración con registros de auditoría o listas de usuarios
Implementación Más Simple
"use client";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
return (
<nav aria-label="Paginación" className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
aria-current={page === currentPage ? "page" : undefined}
className={`rounded-lg px-3 py-2 text-sm font-medium ${
page === currentPage
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
>
{page}
</button>
))}
</nav>
);
}Una paginación numerada mínima. La página activa se resalta con un fondo sólido, y aria-current="page" marca la página actual para lectores de pantalla.
Variaciones
Con Botones Anterior y Siguiente
"use client";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
return (
<nav aria-label="Paginación" className="flex items-center gap-1">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
>
Anterior
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
aria-current={page === currentPage ? "page" : undefined}
className={`rounded-lg px-3 py-2 text-sm font-medium ${
page === currentPage
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
>
{page}
</button>
))}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
>
Siguiente
</button>
</nav>
);
}Agrega botones Anterior y Siguiente que se desactivan en los límites. Este es el patrón de paginación más común y funciona bien cuando el número total de páginas es lo suficientemente pequeño para mostrar todos los números.
Con Puntos Suspensivos para Muchas Páginas
"use client";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
siblingCount?: number;
}
function generatePages(current: number, total: number, siblings: number): (number | "ellipsis")[] {
const range = (start: number, end: number) =>
Array.from({ length: end - start + 1 }, (_, i) => start + i);
const leftSibling = Math.max(current - siblings, 2);
const rightSibling = Math.min(current + siblings, total - 1);
const showLeftEllipsis = leftSibling > 2;
const showRightEllipsis = rightSibling < total - 1;
if (!showLeftEllipsis && !showRightEllipsis) {
return range(1, total);
}
if (!showLeftEllipsis && showRightEllipsis) {
const leftRange = range(1, Math.max(rightSibling, 2 + siblings * 2));
return [...leftRange, "ellipsis", total];
}
if (showLeftEllipsis && !showRightEllipsis) {
const rightRange = range(Math.min(leftSibling, total - 1 - siblings * 2), total);
return [1, "ellipsis", ...rightRange];
}
return [1, "ellipsis", ...range(leftSibling, rightSibling), "ellipsis", total];
}
export function Pagination({ currentPage, totalPages, onPageChange, siblingCount = 1 }: PaginationProps) {
const pages = generatePages(currentPage, totalPages, siblingCount);
return (
<nav aria-label="Paginación" className="flex items-center gap-1">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
>
Anterior
</button>
{pages.map((page, index) =>
page === "ellipsis" ? (
<span key={`ellipsis-${index}`} className="px-2 py-2 text-sm text-gray-400">
...
</span>
) : (
<button
key={page}
onClick={() => onPageChange(page)}
aria-current={page === currentPage ? "page" : undefined}
className={`rounded-lg px-3 py-2 text-sm font-medium ${
page === currentPage
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
>
{page}
</button>
)
)}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
>
Siguiente
</button>
</nav>
);
}La función generatePages calcula qué números de página mostrar, insertando puntos suspensivos donde se omiten páginas. El siblingCount controla cuántas páginas aparecen alrededor de la página actual.
Compacta (Compatible con Dispositivos Móviles)
"use client";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
return (
<nav aria-label="Paginación" className="flex items-center justify-between gap-4">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none"
>
Anterior
</button>
<span className="text-sm text-gray-600">
Página <span className="font-medium">{currentPage}</span> de{" "}
<span className="font-medium">{totalPages}</span>
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none"
>
Siguiente
</button>
</nav>
);
}Un diseño mínimo con solo botones Anterior/Siguiente e un indicador de página. Funciona bien en pantallas estrechas donde mostrar números de página individuales sería abarrotado.
Con Selector de Tamaño de Página
"use client";
interface PaginationProps {
currentPage: number;
totalPages: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
pageSizeOptions?: number[];
}
export function Pagination({
currentPage,
totalPages,
pageSize,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 25, 50, 100],
}: PaginationProps) {
function handlePageSizeChange(e: React.ChangeEvent<HTMLSelectElement>) {
onPageSizeChange(Number(e.target.value));
onPageChange(1); // Reinicia a la primera página cuando cambia el tamaño de página
}
return (
<nav aria-label="Paginación" className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<label htmlFor="page-size" className="text-sm text-gray-600">
Filas por página:
</label>
<select
id="page-size"
value={pageSize}
onChange={handlePageSizeChange}
className="rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-700"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
>
Anterior
</button>
<span className="px-3 py-2 text-sm text-gray-600">
{currentPage} / {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
>
Siguiente
</button>
</div>
</nav>
);
}Combina navegación con un desplegable de tamaño de página. Cuando el usuario cambia el tamaño de la página, el componente se reinicia a la página 1 para evitar aterrizar en una página fuera de rango.
Basada en Cursor / Cargar Más
"use client";
import { useState, useTransition } from "react";
interface LoadMoreProps<T> {
initialItems: T[];
fetchMore: (cursor: string) => Promise<{ items: T[]; nextCursor: string | null }>;
initialCursor: string | null;
renderItem: (item: T) => React.ReactNode;
}
export function LoadMore<T>({
initialItems,
fetchMore,
initialCursor,
renderItem,
}: LoadMoreProps<T>) {
const [items, setItems] = useState<T[]>(initialItems);
const [cursor, setCursor] = useState<string | null>(initialCursor);
const [isPending, startTransition] = useTransition();
function handleLoadMore() {
if (!cursor) return;
startTransition(async () => {
const result = await fetchMore(cursor);
setItems((prev) => [...prev, ...result.items]);
setCursor(result.nextCursor);
});
}
return (
<div>
<div>{items.map(renderItem)}</div>
{cursor && (
<div className="mt-6 flex justify-center">
<button
onClick={handleLoadMore}
disabled={isPending}
className="rounded-lg border border-gray-300 px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{isPending ? (
<span className="inline-flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Cargando...
</span>
) : (
"Cargar más"
)}
</button>
</div>
)}
</div>
);
}
// Uso
// <LoadMore
// initialItems={posts}
// initialCursor={nextCursor}
// fetchMore={async (cursor) => {
// const res = await fetch(`/api/posts?cursor=${cursor}`);
// return res.json();
// }}
// renderItem={(post) => <PostCard key={post.id} post={post} />}
// />Una alternativa de paginación basada en cursor que agrega elementos en lugar de reemplazarlos. Utiliza useTransition para mantener la interfaz receptiva durante la obtención. El botón desaparece cuando no hay más elementos (nextCursor es null).
Con Visualización de Recuento Total
"use client";
interface PaginationProps {
currentPage: number;
totalPages: number;
pageSize: number;
totalItems: number;
onPageChange: (page: number) => void;
}
export function Pagination({
currentPage,
totalPages,
pageSize,
totalItems,
onPageChange,
}: PaginationProps) {
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, totalItems);
return (
<nav aria-label="Paginación" className="flex items-center justify-between">
<p className="text-sm text-gray-600">
Mostrando <span className="font-medium">{start}</span> a{" "}
<span className="font-medium">{end}</span> de{" "}
<span className="font-medium">{totalItems.toLocaleString()}</span> resultados
</p>
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(1)}
disabled={currentPage <= 1}
aria-label="Primera página"
className="rounded-lg px-2 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7M18 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
aria-label="Página anterior"
className="rounded-lg px-2 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="px-3 py-2 text-sm font-medium text-gray-700">
{currentPage} / {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
aria-label="Página siguiente"
className="rounded-lg px-2 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<button
onClick={() => onPageChange(totalPages)}
disabled={currentPage >= totalPages}
aria-label="Última página"
className="rounded-lg px-2 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M6 5l7 7-7 7" />
</svg>
</button>
</div>
</nav>
);
}Muestra "Mostrando X a Y de Z resultados" junto a botones de icono primera/anterior/siguiente/última. El cálculo de rango contabiliza el hecho de que la última página potencialmente tiene menos elementos que el tamaño de página.
Implementación Compleja
"use client";
import { useMemo, useCallback } from "react";
// --- Tipos ---
type Variant = "numbered" | "compact" | "simple";
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems?: number;
pageSize?: number;
siblingCount?: number;
variant?: Variant;
onPageChange: (page: number) => void;
className?: string;
}
// --- Generador de rango de páginas ---
function generatePageRange(
current: number,
total: number,
siblings: number
): (number | "ellipsis-start" | "ellipsis-end")[] {
if (total <= siblings * 2 + 5) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const leftSibling = Math.max(current - siblings, 2);
const rightSibling = Math.min(current + siblings, total - 1);
const showLeftEllipsis = leftSibling > 2;
const showRightEllipsis = rightSibling < total - 1;
const pages: (number | "ellipsis-start" | "ellipsis-end")[] = [1];
if (showLeftEllipsis) {
pages.push("ellipsis-start");
} else {
for (let i = 2; i < leftSibling; i++) pages.push(i);
}
for (let i = leftSibling; i <= rightSibling; i++) pages.push(i);
if (showRightEllipsis) {
pages.push("ellipsis-end");
} else {
for (let i = rightSibling + 1; i < total; i++) pages.push(i);
}
pages.push(total);
return pages;
}
// --- Subcomponentes ---
function NavButton({
onClick,
disabled,
label,
children,
}: {
onClick: () => void;
disabled: boolean;
label: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:pointer-events-none"
>
{children}
</button>
);
}
function PageButton({
page,
active,
onClick,
}: {
page: number;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
aria-current={active ? "page" : undefined}
className={`inline-flex h-9 min-w-[36px] items-center justify-center rounded-lg px-2 text-sm font-medium transition-colors ${
active
? "bg-blue-600 text-white shadow-sm"
: "text-gray-700 hover:bg-gray-100"
}`}
>
{page}
</button>
);
}
// --- Componente Principal ---
export function Pagination({
currentPage,
totalPages,
totalItems,
pageSize,
siblingCount = 1,
variant = "numbered",
onPageChange,
className,
}: PaginationProps) {
const pages = useMemo(
() => generatePageRange(currentPage, totalPages, siblingCount),
[currentPage, totalPages, siblingCount]
);
const goTo = useCallback(
(page: number) => {
const clamped = Math.max(1, Math.min(page, totalPages));
if (clamped !== currentPage) onPageChange(clamped);
},
[currentPage, totalPages, onPageChange]
);
if (totalPages <= 1) return null;
// Texto de rango
const rangeText =
totalItems != null && pageSize != null
? `${((currentPage - 1) * pageSize + 1).toLocaleString()}–${Math.min(
currentPage * pageSize,
totalItems
).toLocaleString()} de ${totalItems.toLocaleString()}`
: null;
const prevDisabled = currentPage <= 1;
const nextDisabled = currentPage >= totalPages;
const prevArrow = (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
);
const nextArrow = (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
return (
<nav
aria-label="Paginación"
className={`flex items-center ${
variant === "simple" ? "justify-between" : "justify-center gap-1"
} ${className ?? ""}`}
>
{/* Rango de elementos totales (numerado y compacto) */}
{rangeText && variant === "numbered" && (
<p className="mr-auto text-sm text-gray-500">{rangeText}</p>
)}
{/* Variante simple: anterior / información de página / siguiente */}
{variant === "simple" && (
<>
<button
onClick={() => goTo(currentPage - 1)}
disabled={prevDisabled}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none"
>
Anterior
</button>
<span className="text-sm text-gray-600">
Página <span className="font-medium">{currentPage}</span> de{" "}
<span className="font-medium">{totalPages}</span>
</span>
<button
onClick={() => goTo(currentPage + 1)}
disabled={nextDisabled}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none"
>
Siguiente
</button>
</>
)}
{/* Variante compacta: solo flechas y recuento de página */}
{variant === "compact" && (
<>
<NavButton onClick={() => goTo(currentPage - 1)} disabled={prevDisabled} label="Página anterior">
{prevArrow}
</NavButton>
<span className="px-2 text-sm text-gray-600">
{currentPage} / {totalPages}
</span>
<NavButton onClick={() => goTo(currentPage + 1)} disabled={nextDisabled} label="Página siguiente">
{nextArrow}
</NavButton>
</>
)}
{/* Variante numerada: botones de página completa */}
{variant === "numbered" && (
<>
<NavButton onClick={() => goTo(currentPage - 1)} disabled={prevDisabled} label="Página anterior">
{prevArrow}
</NavButton>
{pages.map((page) =>
typeof page === "string" ? (
<span key={page} className="inline-flex h-9 w-9 items-center justify-center text-sm text-gray-400">
...
</span>
) : (
<PageButton
key={page}
page={page}
active={page === currentPage}
onClick={() => goTo(page)}
/>
)
)}
<NavButton onClick={() => goTo(currentPage + 1)} disabled={nextDisabled} label="Página siguiente">
{nextArrow}
</NavButton>
</>
)}
</nav>
);
}Aspectos clave:
- Tres modos de variante —
numberedmuestra botones de página con puntos suspensivos,compactmuestra solo flechas con un contador, ysimplemuestra botones de texto Anterior/Siguiente. Esto cubre los casos de uso de escritorio, móvil y mínimos en un componente. - Generación de rango de página estable — la función
generatePageRangeestá memoizada y produce claves de cadena únicas para puntos suspensivos ("ellipsis-start"/"ellipsis-end") para evitar conflictos de claves de React cuando ambos aparecen. - Navegación limitada —
goTolimita la página de destino a[1, totalPages]y omite la devolución de llamada si la página no ha cambiado, previniendo actualizaciones de estado redundantes y llamadas a la API. - Visualización de texto de rango — cuando se proporcionan
totalItemsypageSize, aparece una etiqueta "1-25 de 500", dando a los usuarios contexto sobre cuántos datos existen. - Oculto cuando hay una sola página — el componente devuelve
nullcuandototalPages <= 1, evitando interfaz innecesaria para conjuntos de datos pequeños. - Etiquetado de botones accesible — cada botón de navegación tiene un
aria-labeldescriptivo y el botón de página activa utilizaaria-current="page"para contexto de lector de pantalla. - Subcomponentes componibles —
NavButtonyPageButtonse extraen para mantener el renderizado principal limpio y hacer que el estilo de cada botón sea independientemente mantenible.
Gotchas
-
No limitar valores de página — Si
currentPageexcedetotalPages(por ejemplo, después de que el filtrado reduzca los resultados), el componente puede renderizar un estado inválido. Siempre limita o reinicia la página cuando cambia el recuento total. -
Olvidar reiniciar a la página 1 en cambio de filtro/búsqueda — Cuando un usuario aplica un filtro, debe ser llevado de vuelta a la página 1. Permanecer en la página 5 de un nuevo conjunto de resultados es confuso.
-
Generar todos los botones de página para conjuntos de datos grandes — Renderizar 1.000 botones de página bloquea la interfaz. Siempre usa una estrategia de puntos suspensivos para conjuntos de datos con más de aproximadamente 7 páginas.
-
Errores fuera por uno en visualización de rango — "Mostrando 0 a 0 de 0" parece roto en estados vacíos. Maneja el caso vacío por separado y muestra un mensaje como "Sin resultados" en lugar del componente de paginación.
-
APIs basadas en cursor con números de página — Si tu backend utiliza paginación basada en cursor, no puedes saltar a números de página arbitrarios. Utiliza un patrón de Cargar Más o desplazamiento infinito en lugar de páginas numeradas.
-
Falta navegación de teclado — Los usuarios esperan usar teclas de flecha o Tab para moverse entre botones de página. El comportamiento de enfoque de botón predeterminado maneja Tab, pero considera agregar soporte de tecla de flecha para controles agrupados.
-
Estado de URL no sincronizado con página — Si el estado de paginación vive solo en el estado de React, actualizar la página pierde la posición. Sincroniza
currentPageypageSizecon parámetros de búsqueda de URL utilizandouseSearchParams.
Relacionado
- Breadcrumb — Rastro de navegación que complementa vistas paginadas
- Button — Elemento interactivo base utilizado en controles de paginación
- Forms — Patrones de formulario para selección de tamaño de página