'use client';
/**
* Pagination — moving through a result set one page at a time.
*
* ```tsx
*
* ```
*
* The component owns the arithmetic and nothing else. Give it how many pages
* there are and which one you are on, and it works out which numbers to show,
* where the gaps fall, and which controls are dead at the ends; it never
* fetches and never slices, because the data is yours.
*
* ## Three shapes
*
* `numbers` is the full run, with the middle folded into gaps. It is the one
* to reach for on a search result or a table: the page you are on sits among
* its neighbours, and the two ends stay visible while you move.
*
* `compact` puts the two arrows either side of `3 / 12`. Digits and a slash
* rather than a sentence, so the shape is the same in any language and fits a
* table footer or a phone.
*
* `simple` replaces the numbers with the words Previous and Next. Use it for
* a flow you walk rather than jump around in: a wizard, a set of onboarding
* cards, the pages of an article. It says where the flow goes, which a row of
* numbers does not.
*
* ## The run holds its width
*
* The visible run is a fixed width, not a window that grows and shrinks, so
* the arrows stay where the hand left them as you step through the pages.
* `siblings` sets how many pages sit either side of the current one and
* `boundaries` how many are pinned at each end. A gap of exactly one page is
* drawn as that page, because a gap marker wider than the number it hides is
* a worse trade than the number.
*
* An ellipsis is a button, not punctuation. A dead target in the middle of a
* row of live ones is a thing people press and then think is broken, so
* pressing one jumps `pageJump` pages towards the end the gap is on.
*
* ## With something beside it
*
* Given children, the root becomes a row with them on the leading edge and
* the controls on the trailing one. That is where `Pagination.Status` goes:
*
* ```tsx
*
*
*
* ```
*
* `Pagination.Item` is a numbered target that changes the page in this
* component. `Pagination.Link` is the same target as a real link, for a pager
* whose pages have addresses and can be opened in another tab. Pass
* `getPageHref` and the whole run is drawn from links, so each page can be
* shared, followed without JavaScript, and opened beside the one you are on.
*
* ## Keyboard and assistive technology
*
* The root is a `nav` with a label, the targets are a list, the current page
* carries `aria-current="page"`, and every arrow and gap has a label that says
* where it goes. Every target is a real button or link, so Tab, Enter and
* Space work as the platform defines them, without a key handler of ours. A
* control that is dead at an end is
* announced as disabled and stays in place, so the row does not rearrange
* under the pointer.
*/
import { createContext, useCallback, useContext, useMemo, useState, type ComponentProps, type ReactNode } from 'react';
import { tv } from 'tailwind-variants';
import { cn } from '../../lib/cn';
import {
clampPage,
paginationRange,
paginationSpan,
type PaginationItemValue,
} from './pagination-range';
export type PaginationVariant = 'numbers' | 'compact' | 'simple';
export type PaginationSize = 'sm' | 'default';
const paginationVariants = tv({
slots: {
root: [
'group/pagination flex w-full max-w-full flex-wrap items-center gap-3',
'data-disabled:opacity-60',
],
// Scrolls rather than wraps, so a run that is too wide for its column stays
// one row. The ring is drawn inside each target, so the clip takes nothing
// off it, and a keyboard focus still scrolls the target into view.
list: 'flex min-w-0 max-w-full items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
item: [
'relative inline-flex shrink-0 cursor-pointer items-center justify-center whitespace-nowrap px-2 font-medium tabular-nums outline-none select-none',
'text-foreground transition-[color,background-color] duration-150 motion-reduce:transition-none',
'hover:bg-accent hover:text-accent-foreground',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
// A spent arrow or a disabled row keeps its place and says why it does
// nothing, rather than vanishing and moving its neighbours.
'aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:hover:bg-transparent aria-disabled:hover:text-foreground',
'[&_svg]:pointer-events-none [&_svg]:shrink-0',
],
ellipsis: [
'relative inline-flex shrink-0 cursor-pointer items-center justify-center text-muted-foreground outline-none select-none',
'transition-colors duration-150 hover:bg-accent hover:text-foreground motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
'aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:hover:bg-transparent aria-disabled:hover:text-muted-foreground',
],
status: 'min-w-0 text-muted-foreground tabular-nums',
summary: 'shrink-0 text-center text-muted-foreground tabular-nums',
icon: 'shrink-0',
},
variants: {
size: {
default: {
list: 'gap-1',
item: 'h-9 min-w-9 rounded-lg text-sm',
ellipsis: 'size-9 rounded-lg',
status: 'text-sm',
summary: 'px-2 text-sm',
icon: 'size-4',
},
sm: {
list: 'gap-0.5',
item: 'h-8 min-w-8 rounded-md px-1.5 text-xs',
ellipsis: 'size-8 rounded-md',
status: 'text-xs',
summary: 'px-1.5 text-xs',
icon: 'size-3.5',
},
},
/** The page you are on, in the weight the collection uses for a chosen item. */
current: {
true: {
item:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 hover:text-primary-foreground',
},
},
},
defaultVariants: { size: 'default' },
});
interface PaginationContextValue {
/** The page being drawn, already clamped into the set. */
page: number;
/** How many pages there are. */
count: number;
/** The target size every part reads. */
size: PaginationSize;
/** Whether the whole row is deaf. */
disabled: boolean;
/** Ask for another page. Clamped here, so a part never has to. */
goTo: (page: number) => void;
}
const PaginationContext = createContext(null);
function usePagination(part: string): PaginationContextValue {
const context = useContext(PaginationContext);
if (!context) throw new Error(`${part} must be used inside .`);
return context;
}
export interface PaginationProps extends Omit, 'children'> {
/** How many pages there are. Pages are numbered from 1, so this is also the last page's number. */
count: number;
/** The page being shown. Pass it to control the component. */
page?: number;
/** The page to start on when the component keeps its own. Defaults to 1. */
defaultPage?: number;
/** Called with the page that was asked for, already clamped to `count`. */
onPageChange?: (page: number) => void;
/** Which presentation to draw. Default `numbers`. */
variant?: PaginationVariant;
/** Its height and text size. Default `default`. */
size?: PaginationSize;
/** How many pages to keep either side of the current one. Default 1. */
siblings?: number;
/** How many pages to keep pinned at each end of the run. Default 1. */
boundaries?: number;
/**
* Show the previous and next arrows. Turning them off leaves the numbers
* alone, for a row that something else moves — a swipe, a scroller reaching
* its end.
*/
controls?: boolean;
/** How far pressing an ellipsis jumps. Default 5. */
pageJump?: number;
/**
* Turns a page number into its address. When set, every numbered target is
* a real link — one that opens in another tab and works without JavaScript —
* instead of a button that only moves this component.
*/
getPageHref?: (page: number) => string;
/** Greys out and deafens the whole row, for a page that is still loading. */
disabled?: boolean;
/** Labels the row for a screen reader. Default "Pagination". */
'aria-label'?: string;
/** Leading content, where a `Pagination.Status` or a page-size control goes. */
children?: ReactNode;
/** Extra classes for the row. */
className?: string;
}
/**
* Holds the page and draws the row. Give every pagination on a page its own
* `aria-label` when there is more than one, so a screen reader can tell them
* apart.
*/
function PaginationRoot({
count,
page: controlledPage,
defaultPage = 1,
onPageChange,
variant = 'numbers',
size = 'default',
siblings = 1,
boundaries = 1,
controls = true,
pageJump = 5,
getPageHref,
disabled = false,
children,
className,
'aria-label': ariaLabel = 'Pagination',
...props
}: PaginationProps) {
const [uncontrolledPage, setUncontrolledPage] = useState(() => clampPage(defaultPage, count));
const isControlled = controlledPage !== undefined;
// Clamped on the way out as well as on the way in: `count` can shrink under
// a controlled page — a filter narrowing the result set — and a page number
// past the end would light nothing and disable both arrows at once.
const current = clampPage(isControlled ? controlledPage : uncontrolledPage, count);
const goTo = useCallback(
(next: number) => {
const target = clampPage(next, count);
if (disabled || target === 0 || target === current) return;
if (!isControlled) setUncontrolledPage(target);
onPageChange?.(target);
},
[count, current, disabled, isControlled, onPageChange]
);
const context = useMemo(
() => ({ page: current, count, size, disabled, goTo }),
[current, count, size, disabled, goTo]
);
const items = useMemo(
() =>
variant === 'numbers'
? paginationRange({ count, page: current, siblings, boundaries })
: [],
[variant, count, current, siblings, boundaries]
);
const slots = paginationVariants({ size });
const hasChildren = children !== undefined && children !== null;
return (
);
}
PaginationRoot.displayName = 'Pagination';
export interface PaginationItemProps extends Omit, 'children'> {
/** The page this target goes to. */
page: number;
/** What a screen reader calls this target. Defaults to "Go to page N". */
'aria-label'?: string;
/** Replaces the number, for a target that says something other than its page. */
children?: ReactNode;
/** Extra classes for the target. */
className?: string;
}
/**
* One numbered target. It is announced as the current page when it is, so the
* page you are on is spoken as state and not only painted.
*/
function PaginationItem({
page,
children,
className,
onClick,
disabled: ownDisabled,
'aria-label': ariaLabel,
...props
}: PaginationItemProps) {
const { page: current, size, disabled, goTo } = usePagination('Pagination.Item');
const isCurrent = page === current;
const isDisabled = disabled || ownDisabled === true;
const slots = paginationVariants({ size, current: isCurrent });
return (
);
}
PaginationItem.displayName = 'Pagination.Item';
export interface PaginationLinkProps extends Omit, 'children' | 'href'> {
/** The page this link goes to. */
page: number;
/** The address of that page. A real link opens in a new tab and works without JavaScript. */
href: string;
/** What a screen reader calls this target. Defaults to "Go to page N". */
'aria-label'?: string;
/** Replaces the number. */
children?: ReactNode;
/** Extra classes for the target. */
className?: string;
}
/**
* A numbered target drawn as a link, for a pager whose pages have addresses.
*
* A modified click — a new tab, a new window — is left to the browser, so the
* page the reader is on is not changed behind it. A plain click still moves
* the component, so a router or a caller holding the page sees it change
* before the navigation lands.
*/
function PaginationLink({
page,
href,
children,
className,
onClick,
'aria-label': ariaLabel,
...props
}: PaginationLinkProps) {
const { page: current, size, disabled, goTo } = usePagination('Pagination.Link');
const isCurrent = page === current;
const slots = paginationVariants({ size, current: isCurrent });
return (
{
onClick?.(event);
if (event.defaultPrevented) return;
if (disabled || isCurrent) {
event.preventDefault();
return;
}
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
return;
}
goTo(page);
}}
className={slots.item({ className })}
{...props}
>
{children ?? page}
);
}
PaginationLink.displayName = 'Pagination.Link';
export interface PaginationPreviousProps extends Omit, 'children'> {
/** Write the word beside the arrow rather than leaving it as a glyph. */
label?: boolean;
/** What a screen reader calls the control. Defaults to "Previous page". */
'aria-label'?: string;
/** Extra classes for the control. */
className?: string;
}
export interface PaginationNextProps extends Omit, 'children'> {
/** Write the word beside the arrow rather than leaving it as a glyph. */
label?: boolean;
/** What a screen reader calls the control. Defaults to "Next page". */
'aria-label'?: string;
/** Extra classes for the control. */
className?: string;
}
interface PaginationArrowProps {
step: -1 | 1;
part: string;
word: string;
label?: boolean;
className?: string;
disabled?: boolean;
'aria-label'?: string;
onClick?: ComponentProps<'button'>['onClick'];
}
/**
* The two arrows, which are one control pointed the other way.
*
* A spent arrow is `aria-disabled` rather than removed from the page: it
* keeps its place, so the row does not rearrange, and it stays in the tab
* order, so a keyboard user can hear that there is a first page rather than
* finding the row shorter than it looked.
*/
function PaginationArrow({
step,
part,
word,
label = false,
className,
disabled: ownDisabled,
onClick,
'aria-label': ariaLabel,
...props
}: PaginationArrowProps) {
const { page, count, size, disabled, goTo } = usePagination(part);
const slots = paginationVariants({ size });
const target = page + step;
const spent = target < 1 || target > count;
const isDisabled = disabled || spent || ownDisabled === true;
return (
);
}
/** Back one page. Dead on the first page rather than looping to the last. */
function PaginationPrevious({ label, ...props }: PaginationPreviousProps) {
return ;
}
PaginationPrevious.displayName = 'Pagination.Previous';
/** Forward one page. Dead on the last page rather than looping to the first. */
function PaginationNext({ label, ...props }: PaginationNextProps) {
return ;
}
PaginationNext.displayName = 'Pagination.Next';
export interface PaginationEllipsisProps extends Omit, 'children'> {
/** Which way the gap runs: `-1` towards page 1, `1` towards the last page. */
direction?: -1 | 1;
/** How many pages a press covers. Default 5. */
jump?: number;
/** What a screen reader calls the control. Defaults to "Jump back/forward N pages". */
'aria-label'?: string;
/** Replaces the three dots. */
children?: ReactNode;
/** Extra classes for the control. */
className?: string;
}
/**
* The gap in the run, and a way across it. Pressing it jumps `jump` pages
* towards the end the gap is on, which is why the two gaps are told apart:
* a jump that always went forwards would strand anyone reading the row right
* to left.
*/
function PaginationEllipsis({
direction = 1,
jump = 5,
children,
className,
onClick,
disabled: ownDisabled,
'aria-label': ariaLabel,
...props
}: PaginationEllipsisProps) {
const { page, size, disabled, goTo } = usePagination('Pagination.Ellipsis');
const slots = paginationVariants({ size });
const distance = Number.isFinite(jump) ? Math.max(1, Math.floor(jump)) : 5;
const isDisabled = disabled || ownDisabled === true;
return (
);
}
PaginationEllipsis.displayName = 'Pagination.Ellipsis';
export interface PaginationSummaryProps extends Omit, 'children'> {
/** Extra classes for the line. */
className?: string;
}
/**
* `3 / 12` — where you are, in the space two arrows leave between them.
*
* Digits and a slash rather than "Page 3 of 12", because the row has no room
* for the sentence and the sentence would need translating. A screen reader
* still hears the words, placed around the numbers with `sr-only`.
*/
function PaginationSummary({ className, ...props }: PaginationSummaryProps) {
const { page, count, size } = usePagination('Pagination.Summary');
const slots = paginationVariants({ size });
return (
Page
{page}
/ of
{count}
);
}
PaginationSummary.displayName = 'Pagination.Summary';
export interface PaginationStatusProps extends Omit, 'children'> {
/** Which page the span is counted from. Read from the root when left out. */
page?: number;
/** How many rows a page holds. Required, with `total`, for a range. */
pageSize?: number;
/** How many rows there are altogether. */
total?: number;
/** Words to show instead of a worked-out span, for a set whose size is not known yet. */
children?: ReactNode;
/** Extra classes for the line. */
className?: string;
}
/**
* The line that says how much of the set you are looking at: `1–20 of 143`.
*
* It answers the question a page number alone does not, which is how much is
* left. Given `pageSize` and `total` it draws a range; inside a `Pagination`
* without them it falls back to `3 / 12`; given children it shows those
* instead.
*/
function PaginationStatus({
page: ownPage,
pageSize,
total,
children,
className,
...props
}: PaginationStatusProps) {
const context = useContext(PaginationContext);
const size = context?.size ?? 'default';
const page = ownPage ?? context?.page ?? 1;
const count = context?.count;
const slots = paginationVariants({ size });
const span = useMemo(() => {
if (children !== undefined) return null;
if (pageSize !== undefined && total !== undefined) return paginationSpan({ page, pageSize, total });
if (count !== undefined) return `${page} / ${count}`;
return null;
}, [children, page, pageSize, total, count]);
return (
{span ?? children}
);
}
PaginationStatus.displayName = 'Pagination.Status';
function Chevron({ direction, className }: { direction: 'left' | 'right'; className?: string }) {
return (
);
}
function EllipsisGlyph({ className }: { className?: string }) {
return (
);
}
export const Pagination = Object.assign(PaginationRoot, {
Previous: PaginationPrevious,
Next: PaginationNext,
Item: PaginationItem,
Link: PaginationLink,
Ellipsis: PaginationEllipsis,
Status: PaginationStatus,
Summary: PaginationSummary,
});
export {
PaginationPrevious,
PaginationNext,
PaginationItem,
PaginationLink,
PaginationEllipsis,
PaginationStatus,
PaginationSummary,
};
export { clampPage, paginationRange, paginationSpan, type PaginationItemValue };