Navigation
Pagination
Seven-slot pager that turns compact when space runs out
Preview
When its container gets narrow, the row turns into a label like "Page 6 of 12", whose text comes from the formatLabel prop. The size sets the threshold (392 px for md, 320 for sm) and the change of shape is CSS only. Without the row, variant and shape don't show, because they only style its numbers.
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 | Side of each box (32/40). It also sets the width the full row needs (a 320/392px container, with pages of up to two digits); below it the row turns into the label. Fixed or responsive. |
| variant | "outline" | "solid" | "soft" | required | How the current page is marked: outline, solid fill or a faint fill. |
| shape | "rounded" | "circular" | required | Box shape: rounded corners or a circle, which stretches into a pill when the number does not fit (three digits at sm, four at md). |
| align | "start" | "center" | "end" | ResponsiveAlign | required | Aligns the row within its own slot, which it fills because it turns compact by that width; it does not place the component. The compact label follows it. Under a data table it goes to the end, with the range on the other side. Fixed or responsive. |
| total | number | required | Number of pages. Up to 7 they all show; past that, seven fixed slots with ellipses. |
| aria-label | string | required | Name of the <nav> for screen readers; it tells this navigation apart from the others on the page. |
| previousAriaLabel | string | required | Name of the previous arrow for screen readers; it only draws an icon. |
| nextAriaLabel | string | required | Name of the next arrow for screen readers; it only draws an icon. |
| pageAriaLabel | (page: number, current: boolean) => string | required | Name of each page for screen readers. A function because the text carries the number and the library has no language. |
| formatLabel | (page: number, total: number) => string | required | Text of the compact label ("Page 3 of 12"). Screen readers also announce it on every change. |
| page | number | required | Current page (controlled), starting at 1. |
| onChange | (page: number) => void | required | Callback with the chosen page. It does not fire for the current one. |