Paginação
Um controle para navegar entre páginas de conteúdo, permitindo que os usuários percorram grandes conjuntos de dados ou coleções de conteúdo em blocos gerenciáveis.
Busque em todas as páginas da documentação
Um controle para navegar entre páginas de conteúdo, permitindo que os usuários percorram grandes conjuntos de dados ou coleções de conteúdo em blocos gerenciáveis.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
return (
<nav aria-label="Paginação" 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>
);
}Uma paginação numerada mínima. A página ativa é destacada com um fundo sólido, e aria-current="page" marca a página atual para leitores de tela.
"use client";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
return (
<nav aria-label="Paginação" 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"
>
Próximo
</button>
</nav>
);
}Adiciona botões Anterior e Próximo que desabilitam nos limites. Este é o padrão de paginação mais comum e funciona bem quando o número total de páginas é pequeno o suficiente para mostrar todos os números.
"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="Paginação" 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"
>
Próximo
</button>
</nav>
);
}A função generatePages calcula quais números de página mostrar, inserindo reticências onde as páginas são puladas. O siblingCount controla quantas páginas aparecem em torno da página atual.
"use client";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
return (
<nav aria-label="Paginação" 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"
>
Próximo
</button>
</nav>
);
}Um layout mínimo com apenas botões Anterior/Próximo e um indicador de página. Funciona bem em telas estreitas onde mostrar números de página individuais seria apertado.
"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 para a primeira página quando o tamanho da página muda
}
return (
<nav aria-label="Paginação" 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">
Linhas 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"
>
Próximo
</button>
</div>
</nav>
);
}Combina navegação com um dropdown de tamanho de página. Quando o usuário altera o tamanho da página, o componente reinicia para a página 1 para evitar cair em uma página fora do intervalo.
"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>
Carregando...
</span>
) : (
"Carregar mais"
)}
</button>
</div>
)}
</div>
);
}
// Usage
// <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} />}
// />Uma alternativa de paginação baseada em cursor que anexa itens em vez de substituí-los. Usa useTransition para manter a interface responsiva durante o carregamento. O botão desaparece quando não há mais itens (nextCursor é nulo).
"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="Paginação" 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="Primeira 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="Próxima 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="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>
);
}Mostra "Mostrando X a Y de Z resultados" ao lado de botões de ícone de primeiro/anterior/próximo/último. O cálculo do intervalo leva em conta que a última página pode ter menos itens que o tamanho da página.
"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;
}
// --- Gerador de intervalo 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 do intervalo
const rangeText =
totalItems != null && pageSize != null
? `${((currentPage - 1) * pageSize + 1).toLocaleString()}\u2013${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="Paginação"
className={`flex items-center ${
variant === "simple" ? "justify-between" : "justify-center gap-1"
} ${className ?? ""}`}
>
{/* Intervalo total de itens (numbered e compact) */}
{rangeText && variant === "numbered" && (
<p className="mr-auto text-sm text-gray-500">{rangeText}</p>
)}
{/* Variante simples: anterior / info da página / próximo */}
{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"
>
Próximo
</button>
</>
)}
{/* Variante compacta: apenas setas e contagem de páginas */}
{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="Próxima página">
{nextArrow}
</NavButton>
</>
)}
{/* Variante numerada: botões de página completos */}
{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="Próxima página">
{nextArrow}
</NavButton>
</>
)}
</nav>
);
}Aspectos chave:
numbered mostra botões de página com reticências, compact mostra apenas setas com um contador, e simple mostra botões de texto Anterior/Próximo. Isso cobre casos de uso de desktop, mobile e mínimos em um único componente.generatePageRange é memoizada e produz chaves de string únicas para reticências ("ellipsis-start" / "ellipsis-end") para evitar conflitos de chave do React quando ambos aparecem.goTo limita a página de destino a [1, totalPages] e pula o callback se a página não mudou, evitando atualizações de estado e chamadas de API redundantes.totalItems e pageSize são fornecidos, um rótulo "1-25 de 500" aparece, dando aos usuários contexto sobre a quantidade de dados existente.null quando totalPages <= 1, evitando UI desnecessária para conjuntos de dados pequenos.aria-label descritivo e o botão da página ativa usa aria-current="page" para contexto do leitor de tela.NavButton e PageButton são extraídos para manter o render principal limpo e tornar o estilo de cada botão independentemente mantenível.Não limitar os valores da página - Se currentPage exceder totalPages (por exemplo, após a filtragem reduzir os resultados), o componente pode renderizar um estado inválido. Sempre limite ou reinicie a página quando a contagem total mudar.
Esquecer de reiniciar para a página 1 ao alterar filtro/pesquisa - Quando um usuário aplica um filtro, ele deve ser levado de volta à página 1. Permanecer na página 5 de um novo conjunto de resultados é confuso.
Gerar todos os botões de página para grandes conjuntos de dados - Renderizar 1.000 botões de página trava a interface. Sempre use uma estratégia de reticências para conjuntos de dados com mais de cerca de 7 páginas.
Erros de um em um no cálculo do intervalo - "Mostrando 0 a 0 de 0" parece quebrado em estados vazios. Trate o caso vazio separadamente e mostre uma mensagem como "Nenhum resultado" em vez do componente de paginação.
APIs baseadas em cursor com números de página - Se o seu backend usa paginação baseada em cursor, você não pode pular para números de página arbitrários. Use um padrão de Carregar Mais ou scroll infinito em vez de páginas numeradas.
Navegação por teclado ausente - Os usuários esperam usar as teclas de seta ou Tab para se mover entre os botões de página. O comportamento padrão de foco do botão lida com o Tab, mas considere adicionar suporte para teclas de seta para controles agrupados.
Estado da URL não sincronizado com a página - Se o estado da paginação viver apenas no estado do React, atualizar a página perde a posição. Sincronize currentPage e pageSize com os parâmetros de busca da URL usando useSearchParams.
Revisado por Chris St. John·Última atualização: 10 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥