Pagination
Anterior, números de página y Siguiente a una misma altura de 48px, con la página actual rellena y anunciada.
Orders 81–100 of 240
Instalación
$ pnpm dlx shadcn@latest add @ssntl/paginationUso
import { Pagination } from "@/components/ui/pagination"
// Enlaces, para páginas que se comparten y que siguen los buscadores
<Pagination page={5} total={12} getHref={(p) => `/pedidos?page=${p}`} labels={etiquetas} />
// Botones, para una lista que cambia en el cliente
<Pagination page={page} total={12} onPageChange={setPage} labels={etiquetas} />- Alineación de controles: Anterior, los números y Siguiente miden 48px, sobre una misma línea base.
- La página actual va rellena y lleva aria-current="page"; cada número se nombra “Página 5”.
- Muestra los extremos, los vecinos de la página actual y saltos (1 … 4 5 6 … 12). En teléfonos solo queda el número actual.
- Prefiere getHref: los enlaces reales se abren en otra pestaña y se comparten. Pasa labels en el idioma de la persona.
Código
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/ssntl/ui/button"
// Control alignment: Previous, the page numbers and Next are all 48px tall
// and share one baseline, so the row reads as one control. The current page
// is filled and carries aria-current. On phones only the current number
// stays between Previous and Next, so the row never wraps.
export interface PaginationLabels {
nav: string
previous: string
next: string
page: (page: number) => string
}
const englishLabels: PaginationLabels = {
nav: "Pagination",
previous: "Previous",
next: "Next",
page: (page) => `Page ${page}`,
}
/** 1 … 4 5 6 … 12: the ends, the neighbours of the current page, and gaps. */
const pagesToShow = (page: number, total: number) => {
const pages = new Set([1, total, page - 1, page, page + 1])
const sorted = [...pages]
.filter((p) => p >= 1 && p <= total)
.sort((a, b) => a - b)
return sorted.flatMap((p, i) =>
i > 0 && p - sorted[i - 1]! > 1 ? (["gap", p] as const) : [p]
)
}
function Pagination({
page,
total,
getHref,
onPageChange,
labels = englishLabels,
className,
...props
}: Omit<React.ComponentProps<"nav">, "children"> & {
/** The current page, from 1. */
page: number
total: number
/** Renders links (best for SEO and sharing). */
getHref?: (page: number) => string
/** Renders buttons, for client-side lists. */
onPageChange?: (page: number) => void
labels?: PaginationLabels
}) {
// A link when there is somewhere to go, a disabled button at either end.
const item = (
target: number,
disabled: boolean,
buttonProps: React.ComponentProps<typeof Button>,
children: React.ReactNode
) =>
getHref && !disabled ? (
<Button asChild {...buttonProps}>
<a href={getHref(target)}>{children}</a>
</Button>
) : (
<Button
{...buttonProps}
disabled={disabled}
onClick={() => onPageChange?.(target)}
>
{children}
</Button>
)
return (
<nav
data-slot="pagination"
aria-label={labels.nav}
className={cn("w-full", className)}
{...props}
>
<ul className="flex items-center justify-center gap-1">
<li>
{item(
page - 1,
page <= 1,
{ variant: "ghost" },
<>
<ChevronLeft data-icon="inline-start" strokeWidth={1.6} />
{labels.previous}
</>
)}
</li>
{pagesToShow(page, total).map((p, i) =>
p === "gap" ? (
<li
key={`gap-${i}`}
aria-hidden
className="grid size-12 place-items-center text-label text-muted-foreground max-sm:hidden"
>
…
</li>
) : (
<li key={p} className={cn(p !== page && "max-sm:hidden")}>
{item(
p,
false,
{
size: "icon",
variant: p === page ? "secondary" : "ghost",
"aria-current": p === page ? "page" : undefined,
"aria-label": labels.page(p),
className: cn(
"px-0 tabular-nums",
p === page && "text-foreground"
),
},
p
)}
</li>
)
)}
<li>
{item(
page + 1,
page >= total,
{ variant: "ghost" },
<>
{labels.next}
<ChevronRight data-icon="inline-end" strokeWidth={1.6} />
</>
)}
</li>
</ul>
</nav>
)
}
export { Pagination, pagesToShow }