Navigation
Pagination
Paginador de siete huecos fijos que se compacta cuando no cabe
Preview
Cuando su contenedor se estrecha, la fila pasa a una etiqueta como «Página 6 de 12», cuyo texto escribe la prop formatLabel. El umbral lo pone la talla (392 px en md, 320 en sm) y el cambio de forma es solo CSS. Sin la fila, variant y shape no se notan, porque solo afectan a sus números.
size
variant
shape
align
total
Code
import css from "./Pagination.module.css";
import { useEffect, useRef } from "react";
import { CaretLeftIcon } from "@phosphor-icons/react/dist/csr/CaretLeft";
import { CaretRightIcon } from "@phosphor-icons/react/dist/csr/CaretRight";
type Size = "sm" | "md";
type Align = "start" | "center" | "end";
type Item = number | "start-ellipsis" | "end-ellipsis";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface ResponsiveAlign {
base: Align;
mobile?: Align;
tablet?: Align;
desktop?: Align;
wide?: Align;
}
interface PaginationProps {
size: Size | ResponsiveSize;
variant: "outline" | "solid" | "soft";
shape: "rounded" | "circular";
align: Align | ResponsiveAlign;
page: number;
total: number;
"aria-label": string;
previousAriaLabel: string;
nextAriaLabel: string;
pageAriaLabel: (page: number, current: boolean) => string;
formatLabel: (page: number, total: number) => string;
onChange: (page: number) => void;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
function getAlignClasses(align: Align | ResponsiveAlign): string {
if (typeof align === "string") {
return css[align];
}
const classes = [css[`${align.base}_base`]];
if (align.mobile) classes.push(css[`${align.mobile}_mobile`]);
if (align.tablet) classes.push(css[`${align.tablet}_tablet`]);
if (align.desktop) classes.push(css[`${align.desktop}_desktop`]);
if (align.wide) classes.push(css[`${align.wide}_wide`]);
return classes.join(" ");
}
// Seven slots whatever the page, so the arrows never move under the pointer.
// An ellipsis always hides two pages or more: hiding one would save no room.
function getItems(page: number, total: number): Item[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
if (page <= 4) return [1, 2, 3, 4, 5, "end-ellipsis", total];
if (page >= total - 3) {
return [1, "start-ellipsis", total - 4, total - 3, total - 2, total - 1, total];
}
return [1, "start-ellipsis", page - 1, page, page + 1, "end-ellipsis", total];
}
export default function Pagination({
size,
variant,
shape,
align,
page,
total,
"aria-label": ariaLabel,
previousAriaLabel,
nextAriaLabel,
pageAriaLabel,
formatLabel,
onChange,
}: PaginationProps) {
const previous = useRef<HTMLButtonElement>(null);
const next = useRef<HTMLButtonElement>(null);
const pendingFocus = useRef<HTMLButtonElement | null>(null);
const select = (target: number) => {
if (target === page) return;
onChange(target);
};
// A disabled button drops focus. The opposite arrow is the one control both
// layouts share: the compact one hides the page buttons.
const step = (delta: number) => {
const target = page + delta;
if (target === 1) pendingFocus.current = next.current;
if (target === total) pendingFocus.current = previous.current;
select(target);
};
useEffect(() => {
pendingFocus.current?.focus();
pendingFocus.current = null;
}, [page]);
return (
<nav
className={`${css.pagination} ${getSizeClasses(size)} ${css[variant]} ${css[shape]} ${getAlignClasses(align)}`}
aria-label={ariaLabel}
>
<div className={css.pagination_row}>
<button
ref={previous}
type="button"
className={css.row_arrow}
aria-label={previousAriaLabel}
disabled={page <= 1}
onClick={() => step(-1)}
>
<CaretLeftIcon weight="bold" />
</button>
<ul className={css.row_list}>
{getItems(page, total).map((item) =>
typeof item === "number" ? (
<li key={item}>
<button
type="button"
className={css.list_page}
aria-label={pageAriaLabel(item, item === page)}
aria-current={item === page ? "page" : undefined}
onClick={() => select(item)}
>
{item}
</button>
</li>
) : (
<li key={item} className={css.list_ellipsis} aria-hidden="true">
…
</li>
),
)}
</ul>
<p className={css.row_label} aria-hidden="true">
{formatLabel(page, total)}
</p>
{/* Focus stays on the arrow after a step, so nothing else would say where it landed. */}
<p className={css.row_status} aria-live="polite">
{formatLabel(page, total)}
</p>
<button
ref={next}
type="button"
className={css.row_arrow}
aria-label={nextAriaLabel}
disabled={page >= total}
onClick={() => step(1)}
>
<CaretRightIcon weight="bold" />
</button>
</div>
</nav>
);
}
.pagination {
/* The root is the container its own layout queries. Inline-size containment
drops the content width, so a flex parent would shrink it to 0 without this. */
width: 100%;
container-type: inline-size;
font-family: var(--font-body);
font-weight: var(--weight-500);
-webkit-tap-highlight-color: transparent;
/* A disabled arrow lets clicks through: a run of them on the first page
would select the text behind it. */
user-select: none;
&.rounded {
--pagination-radius: var(--radius-8);
}
/* Pill, not --radius-full: a long number widens the box, and 100% would draw an ellipse. */
&.circular {
--pagination-radius: var(--radius-pill);
}
&.sm {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
&.sm_base {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md_base {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
@media (min-width: 480px) {
&.sm_mobile {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md_mobile {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
}
@media (min-width: 768px) {
&.sm_tablet {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md_tablet {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md_desktop {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
}
@media (min-width: 1440px) {
&.sm_wide {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md_wide {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
}
.pagination_row {
display: flex;
align-items: center;
gap: var(--space-4);
}
/* The root fills its slot for the container query, so the row aligns inside it. */
&.start .pagination_row {
justify-content: flex-start;
}
&.center .pagination_row {
justify-content: center;
}
&.end .pagination_row {
justify-content: flex-end;
}
&.start_base .pagination_row {
justify-content: flex-start;
}
&.center_base .pagination_row {
justify-content: center;
}
&.end_base .pagination_row {
justify-content: flex-end;
}
@media (min-width: 480px) {
&.start_mobile .pagination_row {
justify-content: flex-start;
}
&.center_mobile .pagination_row {
justify-content: center;
}
&.end_mobile .pagination_row {
justify-content: flex-end;
}
}
@media (min-width: 768px) {
&.start_tablet .pagination_row {
justify-content: flex-start;
}
&.center_tablet .pagination_row {
justify-content: center;
}
&.end_tablet .pagination_row {
justify-content: flex-end;
}
}
@media (min-width: 1024px) {
&.start_desktop .pagination_row {
justify-content: flex-start;
}
&.center_desktop .pagination_row {
justify-content: center;
}
&.end_desktop .pagination_row {
justify-content: flex-end;
}
}
@media (min-width: 1440px) {
&.start_wide .pagination_row {
justify-content: flex-start;
}
&.center_wide .pagination_row {
justify-content: center;
}
&.end_wide .pagination_row {
justify-content: flex-end;
}
}
.row_list {
display: none;
align-items: center;
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
li {
display: flex;
}
}
.list_page,
.row_arrow {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--pagination-size);
height: var(--pagination-size);
padding: 0 var(--space-4);
border: 0;
border-radius: var(--pagination-radius);
background: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
font-variant-numeric: tabular-nums;
line-height: 1;
color: var(--color-text);
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&:disabled {
opacity: 0.5;
pointer-events: none;
}
}
.row_arrow svg {
width: var(--pagination-icon);
height: var(--pagination-icon);
}
/* The fade lives on the hover rule only: the window slides, so the current
page changes node, and a transition on the box would leave a trail behind. */
@media (hover: hover) {
.list_page:not([aria-current]):hover,
.row_arrow:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
transition: background 0.15s ease;
}
}
.list_page:not([aria-current]):active,
.row_arrow:active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
}
&.solid .list_page[aria-current] {
background: var(--color-accent);
color: var(--color-bg);
font-weight: var(--weight-600);
}
&.outline .list_page[aria-current] {
box-shadow: inset 0 0 0 1px var(--color-accent);
font-weight: var(--weight-600);
}
&.soft .list_page[aria-current] {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
}
.list_ellipsis {
align-items: center;
justify-content: center;
min-width: var(--pagination-size);
height: var(--pagination-size);
color: var(--color-text-secondary);
}
.row_label {
margin: 0;
padding: 0 var(--space-12);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.row_status {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* A query cannot read custom properties, so each step names its own threshold
and resets to compact first, or the smaller step's query would win. */
@container (min-width: 320px) {
&:is(.sm, .sm_base) {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@container (min-width: 392px) {
&:is(.md, .md_base) {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@media (min-width: 480px) {
&:is(.sm_mobile, .md_mobile) {
.row_list {
display: none;
}
.row_label {
display: block;
}
}
@container (min-width: 320px) {
&.sm_mobile {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@container (min-width: 392px) {
&.md_mobile {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
}
@media (min-width: 768px) {
&:is(.sm_tablet, .md_tablet) {
.row_list {
display: none;
}
.row_label {
display: block;
}
}
@container (min-width: 320px) {
&.sm_tablet {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@container (min-width: 392px) {
&.md_tablet {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
}
@media (min-width: 1024px) {
&:is(.sm_desktop, .md_desktop) {
.row_list {
display: none;
}
.row_label {
display: block;
}
}
@container (min-width: 320px) {
&.sm_desktop {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@container (min-width: 392px) {
&.md_desktop {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
}
@media (min-width: 1440px) {
&:is(.sm_wide, .md_wide) {
.row_list {
display: none;
}
.row_label {
display: block;
}
}
@container (min-width: 320px) {
&.sm_wide {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@container (min-width: 392px) {
&.md_wide {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
}
}
<script setup lang="ts">
import { computed, useTemplateRef, watch } from "vue";
import { PhCaretLeft, PhCaretRight } from "@phosphor-icons/vue";
type Size = "sm" | "md";
type Align = "start" | "center" | "end";
type Item = number | "start-ellipsis" | "end-ellipsis";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface ResponsiveAlign {
base: Align;
mobile?: Align;
tablet?: Align;
desktop?: Align;
wide?: Align;
}
interface PaginationProps {
size: Size | ResponsiveSize;
variant: "outline" | "solid" | "soft";
shape: "rounded" | "circular";
align: Align | ResponsiveAlign;
total: number;
ariaLabel: string;
previousAriaLabel: string;
nextAriaLabel: string;
pageAriaLabel: (page: number, current: boolean) => string;
formatLabel: (page: number, total: number) => string;
}
// Seven slots whatever the page, so the arrows never move under the pointer.
// An ellipsis always hides two pages or more: hiding one would save no room.
function getItems(page: number, total: number): Item[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
if (page <= 4) return [1, 2, 3, 4, 5, "end-ellipsis", total];
if (page >= total - 3) {
return [1, "start-ellipsis", total - 4, total - 3, total - 2, total - 1, total];
}
return [1, "start-ellipsis", page - 1, page, page + 1, "end-ellipsis", total];
}
const { size, align, total } = defineProps<PaginationProps>();
const page = defineModel<number>("page", { required: true });
const previous = useTemplateRef<HTMLButtonElement>("previous");
const next = useTemplateRef<HTMLButtonElement>("next");
let pendingFocus: HTMLButtonElement | null = null;
const alignClasses = computed(() => {
if (typeof align === "string") {
return align;
}
const classes = [`${align.base}_base`];
if (align.mobile) classes.push(`${align.mobile}_mobile`);
if (align.tablet) classes.push(`${align.tablet}_tablet`);
if (align.desktop) classes.push(`${align.desktop}_desktop`);
if (align.wide) classes.push(`${align.wide}_wide`);
return classes.join(" ");
});
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const select = (target: number) => {
if (target === page.value) return;
page.value = target;
};
// A disabled button drops focus. The opposite arrow is the one control both
// layouts share: the compact one hides the page buttons.
const step = (delta: number) => {
const target = page.value + delta;
if (target === 1) pendingFocus = next.value;
if (target === total) pendingFocus = previous.value;
select(target);
};
watch(
page,
() => {
pendingFocus?.focus();
pendingFocus = null;
},
{ flush: "post" },
);
</script>
<template>
<nav
class="pagination"
:class="[sizeClasses, variant, shape, alignClasses]"
:aria-label="ariaLabel"
>
<div class="pagination_row">
<button
ref="previous"
type="button"
class="row_arrow"
:aria-label="previousAriaLabel"
:disabled="page <= 1"
@click="step(-1)"
>
<PhCaretLeft weight="bold" />
</button>
<ul class="row_list">
<template v-for="item in getItems(page, total)" :key="item">
<li v-if="typeof item === 'number'">
<button
type="button"
class="list_page"
:aria-label="pageAriaLabel(item, item === page)"
:aria-current="item === page ? 'page' : undefined"
@click="select(item)"
>
{{ item }}
</button>
</li>
<li v-else class="list_ellipsis" aria-hidden="true">…</li>
</template>
</ul>
<p class="row_label" aria-hidden="true">{{ formatLabel(page, total) }}</p>
<!-- Focus stays on the arrow after a step, so nothing else would say where it landed. -->
<p class="row_status" aria-live="polite">{{ formatLabel(page, total) }}</p>
<button
ref="next"
type="button"
class="row_arrow"
:aria-label="nextAriaLabel"
:disabled="page >= total"
@click="step(1)"
>
<PhCaretRight weight="bold" />
</button>
</div>
</nav>
</template>
<style scoped>
.pagination {
/* The root is the container its own layout queries. Inline-size containment
drops the content width, so a flex parent would shrink it to 0 without this. */
width: 100%;
container-type: inline-size;
font-family: var(--font-body);
font-weight: var(--weight-500);
-webkit-tap-highlight-color: transparent;
/* A disabled arrow lets clicks through: a run of them on the first page
would select the text behind it. */
user-select: none;
&.rounded {
--pagination-radius: var(--radius-8);
}
/* Pill, not --radius-full: a long number widens the box, and 100% would draw an ellipse. */
&.circular {
--pagination-radius: var(--radius-pill);
}
&.sm {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
&.sm_base {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md_base {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
@media (min-width: 480px) {
&.sm_mobile {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md_mobile {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
}
@media (min-width: 768px) {
&.sm_tablet {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md_tablet {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md_desktop {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
}
@media (min-width: 1440px) {
&.sm_wide {
--pagination-size: var(--size-32);
--pagination-icon: 16px;
font-size: var(--text-14);
}
&.md_wide {
--pagination-size: var(--size-40);
--pagination-icon: 20px;
font-size: var(--text-14);
}
}
.pagination_row {
display: flex;
align-items: center;
gap: var(--space-4);
}
/* The root fills its slot for the container query, so the row aligns inside it. */
&.start .pagination_row {
justify-content: flex-start;
}
&.center .pagination_row {
justify-content: center;
}
&.end .pagination_row {
justify-content: flex-end;
}
&.start_base .pagination_row {
justify-content: flex-start;
}
&.center_base .pagination_row {
justify-content: center;
}
&.end_base .pagination_row {
justify-content: flex-end;
}
@media (min-width: 480px) {
&.start_mobile .pagination_row {
justify-content: flex-start;
}
&.center_mobile .pagination_row {
justify-content: center;
}
&.end_mobile .pagination_row {
justify-content: flex-end;
}
}
@media (min-width: 768px) {
&.start_tablet .pagination_row {
justify-content: flex-start;
}
&.center_tablet .pagination_row {
justify-content: center;
}
&.end_tablet .pagination_row {
justify-content: flex-end;
}
}
@media (min-width: 1024px) {
&.start_desktop .pagination_row {
justify-content: flex-start;
}
&.center_desktop .pagination_row {
justify-content: center;
}
&.end_desktop .pagination_row {
justify-content: flex-end;
}
}
@media (min-width: 1440px) {
&.start_wide .pagination_row {
justify-content: flex-start;
}
&.center_wide .pagination_row {
justify-content: center;
}
&.end_wide .pagination_row {
justify-content: flex-end;
}
}
.row_list {
display: none;
align-items: center;
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
li {
display: flex;
}
}
.list_page,
.row_arrow {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--pagination-size);
height: var(--pagination-size);
padding: 0 var(--space-4);
border: 0;
border-radius: var(--pagination-radius);
background: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
font-variant-numeric: tabular-nums;
line-height: 1;
color: var(--color-text);
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&:disabled {
opacity: 0.5;
pointer-events: none;
}
}
.row_arrow svg {
width: var(--pagination-icon);
height: var(--pagination-icon);
}
/* The fade lives on the hover rule only: the window slides, so the current
page changes node, and a transition on the box would leave a trail behind. */
@media (hover: hover) {
.list_page:not([aria-current]):hover,
.row_arrow:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
transition: background 0.15s ease;
}
}
.list_page:not([aria-current]):active,
.row_arrow:active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
}
&.solid .list_page[aria-current] {
background: var(--color-accent);
color: var(--color-bg);
font-weight: var(--weight-600);
}
&.outline .list_page[aria-current] {
box-shadow: inset 0 0 0 1px var(--color-accent);
font-weight: var(--weight-600);
}
&.soft .list_page[aria-current] {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
}
.list_ellipsis {
align-items: center;
justify-content: center;
min-width: var(--pagination-size);
height: var(--pagination-size);
color: var(--color-text-secondary);
}
.row_label {
margin: 0;
padding: 0 var(--space-12);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.row_status {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* A query cannot read custom properties, so each step names its own threshold
and resets to compact first, or the smaller step's query would win. */
@container (min-width: 320px) {
&:is(.sm, .sm_base) {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@container (min-width: 392px) {
&:is(.md, .md_base) {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@media (min-width: 480px) {
&:is(.sm_mobile, .md_mobile) {
.row_list {
display: none;
}
.row_label {
display: block;
}
}
@container (min-width: 320px) {
&.sm_mobile {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@container (min-width: 392px) {
&.md_mobile {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
}
@media (min-width: 768px) {
&:is(.sm_tablet, .md_tablet) {
.row_list {
display: none;
}
.row_label {
display: block;
}
}
@container (min-width: 320px) {
&.sm_tablet {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@container (min-width: 392px) {
&.md_tablet {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
}
@media (min-width: 1024px) {
&:is(.sm_desktop, .md_desktop) {
.row_list {
display: none;
}
.row_label {
display: block;
}
}
@container (min-width: 320px) {
&.sm_desktop {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@container (min-width: 392px) {
&.md_desktop {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
}
@media (min-width: 1440px) {
&:is(.sm_wide, .md_wide) {
.row_list {
display: none;
}
.row_label {
display: block;
}
}
@container (min-width: 320px) {
&.sm_wide {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
@container (min-width: 392px) {
&.md_wide {
.row_list {
display: flex;
}
.row_label {
display: none;
}
}
}
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "sm" | "md" | ResponsiveSize | required | Lado de cada caja (32/40). Decide también el ancho a partir del cual cabe la fila completa (320/392px de contenedor, con páginas de hasta dos cifras); por debajo pasa a la etiqueta. Fijo o responsive. |
| variant | "outline" | "solid" | "soft" | required | Cómo se marca la página actual: contorno, relleno sólido o relleno tenue. |
| shape | "rounded" | "circular" | required | Forma de las cajas: esquinas redondeadas o círculo, que se alarga a píldora cuando el número no cabe (tres cifras en sm, cuatro en md). |
| align | "start" | "center" | "end" | ResponsiveAlign | required | Alinea la fila dentro de su propio hueco, que ocupa entero porque se compacta según ese ancho; no coloca el componente. La etiqueta compacta la sigue. Bajo una tabla de datos va al final, con el rango al otro lado. Fijo o responsive. |
| total | number | required | Número de páginas. Hasta 7 se ven todas; a partir de ahí, siete huecos fijos con puntos suspensivos. |
| aria-label | string | required | Nombre del <nav> para lectores de pantalla; distingue esta navegación de las demás de la página. |
| previousAriaLabel | string | required | Nombre de la flecha anterior para lectores de pantalla; solo dibuja un icono. |
| nextAriaLabel | string | required | Nombre de la flecha siguiente para lectores de pantalla; solo dibuja un icono. |
| pageAriaLabel | (page: number, current: boolean) => string | required | Nombre de cada página para lectores de pantalla. Es una función porque el texto lleva el número dentro y la librería no tiene idioma. |
| formatLabel | (page: number, total: number) => string | required | Texto de la etiqueta compacta ("Página 3 de 12"). También lo anuncia el lector en cada cambio. |
| page | number | required | Página actual (controlada), empezando en 1. |
| onChange | (page: number) => void | required | Callback con la página elegida. No salta al pulsar la actual. |