Agents (llms.txt)
Octocat

Pagination

Navigation across paginated content with truncation for long ranges.

import { useState } from 'react';

import {
  Pagination,
  usePagination,
} from '@/components/pagination';

export default function PaginationPreview() {
  const [page, setPage] = useState(1);
  const count = 10;
  const items = usePagination({ page, count });

  return (
    <Pagination>
      <Pagination.List>
        <Pagination.Item>
          <Pagination.Previous
            onClick={() => setPage((p) => Math.max(1, p - 1))}
            disabled={page === 1}
          />
        </Pagination.Item>

        {items.map((item) =>
          item.type === 'page' ? (
            <Pagination.Item key={item.key}>
              <Pagination.Link
                isActive={item.selected}
                onClick={() => setPage(item.value)}
              >
                {item.value}
              </Pagination.Link>
            </Pagination.Item>
          ) : (
            <Pagination.Item key={item.key}>
              <Pagination.Ellipsis />
            </Pagination.Item>
          )
        )}

        <Pagination.Item>
          <Pagination.Next
            onClick={() => setPage((p) => Math.min(count, p + 1))}
            disabled={page === count}
          />
        </Pagination.Item>
      </Pagination.List>
    </Pagination>
  );
}

Dependencies

Source Code

import {
  CaretLeftIcon,
  CaretRightIcon,
  DotsThreeIcon,
} from '@phosphor-icons/react/dist/ssr';
import { useMemo } from 'react';

import { Slot, Slottable } from '@/components/slot';
import { cn, cva } from '@/lib/utils/classnames';

const paginationLinkStyle = cva({
  base: [
    'inline-flex h-(--pagination-height) min-w-(--pagination-height) shrink-0 items-center justify-center px-2',
    'rounded-lg font-medium text-foreground-secondary text-sm',
    'transition enabled:cursor-pointer disabled:opacity-40',
    'hover:bg-foreground/5 hover:text-foreground',
    'focus-visible:ring-(length:--ring-width) outline-none ring-ring',
    'data-selected:bg-accent data-selected:text-accent-foreground data-selected:hover:bg-accent/90',
  ],
  variants: {
    size: {
      sm: '[--pagination-height:--spacing(8)]',
      md: '[--pagination-height:--spacing(10)]',
    },
  },
  defaultVariants: {
    size: 'sm',
  },
});

const Pagination = ({
  ref,
  className,
  ...props
}: React.ComponentPropsWithRef<'nav'>) => {
  return (
    <nav
      ref={ref}
      aria-label="pagination"
      className={cn('flex w-full justify-center', className)}
      {...props}
    />
  );
};

const PaginationList = ({
  ref,
  className,
  ...props
}: React.ComponentPropsWithRef<'ul'>) => {
  return (
    <ul
      ref={ref}
      className={cn('flex flex-row items-center gap-1', className)}
      {...props}
    />
  );
};

const PaginationItem = ({
  ref,
  ...props
}: React.ComponentPropsWithRef<'li'>) => {
  return <li ref={ref} {...props} />;
};

interface PaginationLinkProps extends React.ComponentPropsWithRef<'button'> {
  asChild?: boolean;
  isActive?: boolean;
  size?: 'sm' | 'md';
}

const PaginationLink = ({
  ref,
  className,
  asChild,
  isActive,
  size = 'sm',
  type = 'button',
  ...props
}: PaginationLinkProps) => {
  const Comp = asChild ? Slot : 'button';

  return (
    <Comp
      ref={ref}
      type={asChild ? undefined : type}
      aria-current={isActive ? 'page' : undefined}
      data-selected={isActive || undefined}
      className={cn(paginationLinkStyle({ size }), className)}
      {...props}
    />
  );
};

type PaginationStepProps = Omit<PaginationLinkProps, 'children'> & {
  children?: React.ReactElement;
};

const PaginationPrevious = ({
  asChild,
  children,
  ...props
}: PaginationStepProps) => {
  return (
    <PaginationLink
      asChild={asChild}
      aria-label="Go to previous page"
      {...props}
    >
      <Slottable asChild={asChild ?? false} child={children}>
        {(child) => (
          <>
            <CaretLeftIcon />
            {child}
          </>
        )}
      </Slottable>
    </PaginationLink>
  );
};

const PaginationNext = ({
  asChild,
  children,
  ...props
}: PaginationStepProps) => {
  return (
    <PaginationLink asChild={asChild} aria-label="Go to next page" {...props}>
      <Slottable asChild={asChild ?? false} child={children}>
        {(child) => (
          <>
            {child}
            <CaretRightIcon />
          </>
        )}
      </Slottable>
    </PaginationLink>
  );
};

const PaginationEllipsis = ({
  className,
  ...props
}: React.ComponentPropsWithRef<'span'>) => {
  return (
    <span
      aria-hidden="true"
      className={cn(
        'flex h-9 w-9 items-center justify-center text-foreground-secondary',
        className
      )}
      {...props}
    >
      <DotsThreeIcon className="size-4" />
      <span className="sr-only">More pages</span>
    </span>
  );
};

const CompoundPagination = Object.assign(Pagination, {
  List: PaginationList,
  Item: PaginationItem,
  Link: PaginationLink,
  Previous: PaginationPrevious,
  Next: PaginationNext,
  Ellipsis: PaginationEllipsis,
});

export { CompoundPagination as Pagination };

interface UsePaginationOptions {
  /** Current 1-indexed page. */
  page: number;
  /** Total number of pages. */
  count: number;
  /** Pages shown on either side of the current page. */
  siblingCount?: number;
  /** Pages always shown at the start and end. */
  boundaryCount?: number;
}

export type PaginationItemDescriptor =
  | { type: 'page'; value: number; selected: boolean; key: string }
  | { type: 'ellipsis'; key: 'start-ellipsis' | 'end-ellipsis' };

const range = (start: number, end: number): number[] => {
  const length = Math.max(0, end - start + 1);
  return Array.from({ length }, (_, i) => start + i);
};

/**
 * Returns the visible page sequence for a paginator with truncation.
 */
export const usePagination = ({
  page,
  count,
  siblingCount = 1,
  boundaryCount = 1,
}: UsePaginationOptions): PaginationItemDescriptor[] => {
  return useMemo(() => {
    if (count <= 0) return [];

    const startPages = range(1, Math.min(boundaryCount, count));
    const endPages = range(
      Math.max(count - boundaryCount + 1, boundaryCount + 1),
      count
    );

    const siblingsStart = Math.max(
      Math.min(
        page - siblingCount,
        count - boundaryCount - siblingCount * 2 - 1
      ),
      boundaryCount + 2
    );
    const siblingsEnd = Math.min(
      Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),
      endPages.length > 0 ? endPages[0] - 2 : count - 1
    );

    const items: PaginationItemDescriptor[] = [];

    for (const value of startPages) {
      items.push({
        type: 'page',
        value,
        selected: value === page,
        key: `page-${value}`,
      });
    }

    if (siblingsStart > boundaryCount + 2) {
      items.push({ type: 'ellipsis', key: 'start-ellipsis' });
    } else if (boundaryCount + 1 < count - boundaryCount) {
      items.push({
        type: 'page',
        value: boundaryCount + 1,
        selected: page === boundaryCount + 1,
        key: `page-${boundaryCount + 1}`,
      });
    }

    for (const value of range(siblingsStart, siblingsEnd)) {
      items.push({
        type: 'page',
        value,
        selected: value === page,
        key: `page-${value}`,
      });
    }

    if (siblingsEnd < count - boundaryCount - 1) {
      items.push({ type: 'ellipsis', key: 'end-ellipsis' });
    } else if (count - boundaryCount > boundaryCount) {
      items.push({
        type: 'page',
        value: count - boundaryCount,
        selected: page === count - boundaryCount,
        key: `page-${count - boundaryCount}`,
      });
    }

    for (const value of endPages) {
      items.push({
        type: 'page',
        value,
        selected: value === page,
        key: `page-${value}`,
      });
    }

    return items;
  }, [page, count, siblingCount, boundaryCount]);
};

Anatomy


          <Pagination>
  <Pagination.List>
    <Pagination.Item>
      <Pagination.Previous />
    </Pagination.Item>
    <Pagination.Item>
      <Pagination.Link />
    </Pagination.Item>
    <Pagination.Item>
      <Pagination.Ellipsis />
    </Pagination.Item>
    <Pagination.Item>
      <Pagination.Next />
    </Pagination.Item>
  </Pagination.List>
</Pagination>
        

The compound parts are purely structural and styled. Pagination state (current page, total pages) lives with the consumer — wire it up to URL params, local state, or a router. The optional usePagination hook handles the truncation algorithm so you don’t reinvent it.

API Reference

Pagination

Extends the nav element. Renders with aria-label="pagination".

Pagination.List

Extends the ul element.

Pagination.Item

Extends the li element.

Extends the button element. Use asChild to render as an <a> (or framework Link) for URL-driven pagination.

Prop Default Type Description
isActive - boolean Marks the link as the current page. Adds aria-current='page' and a selected style.
size "sm" "sm""md"
asChild - boolean

Pagination.Previous

A Pagination.Link preset that renders a left chevron with aria-label="Go to previous page". Icon-only by design — disable it (or omit href when used asChild) at the first page.

Pagination.Next

A Pagination.Link preset that renders a right chevron with aria-label="Go to next page". Icon-only by design.

Pagination.Ellipsis

A decorative gap between page ranges. Rendered with aria-hidden="true" and an sr-only “More pages” label.

usePagination hook

Computes the visible page sequence with truncation. Pass the current page and total count; it returns an array of items you can map over.


          import { usePagination } from "@/foundations/ui/pagination/pagination";

const items = usePagination({
  page: 5,
  count: 20,
  siblingCount: 1, // pages on each side of `page`
  boundaryCount: 1, // pages always visible at the start/end
});

// items: [
//   { type: 'page', value: 1, selected: false, key: 'page-1' },
//   { type: 'ellipsis', key: 'start-ellipsis' },
//   { type: 'page', value: 4, selected: false, key: 'page-4' },
//   { type: 'page', value: 5, selected: true,  key: 'page-5' },
//   { type: 'page', value: 6, selected: false, key: 'page-6' },
//   { type: 'ellipsis', key: 'end-ellipsis' },
//   { type: 'page', value: 20, selected: false, key: 'page-20' },
// ]
        

The hook returns a stable key for each item so React reconciles correctly across page changes.

Examples

Simple range

For small page counts, skip the hook and map over the range directly.

import { useState } from 'react';

import { Pagination } from '@/components/pagination';

export default function PaginationSimplePreview() {
  const [page, setPage] = useState(1);
  const count = 5;

  return (
    <Pagination>
      <Pagination.List>
        <Pagination.Item>
          <Pagination.Previous
            onClick={() => setPage((p) => Math.max(1, p - 1))}
            disabled={page === 1}
          />
        </Pagination.Item>

        {Array.from({ length: count }, (_, i) => i + 1).map((value) => (
          <Pagination.Item key={value}>
            <Pagination.Link
              isActive={value === page}
              onClick={() => setPage(value)}
            >
              {value}
            </Pagination.Link>
          </Pagination.Item>
        ))}

        <Pagination.Item>
          <Pagination.Next
            onClick={() => setPage((p) => Math.min(count, p + 1))}
            disabled={page === count}
          />
        </Pagination.Item>
      </Pagination.List>
    </Pagination>
  );
}

URL-driven with asChild

Wrap the Pagination.Link, Pagination.Previous, and Pagination.Next around your routing library’s link component.

import {
  Pagination,
  usePagination,
} from '@/components/pagination';

export default function PaginationLinkPreview() {
  // In a real app, derive `page` from the URL (search params or route).
  const page = 3;
  const count = 8;
  const items = usePagination({ page, count });

  const hrefFor = (p: number) => `?page=${p}`;

  return (
    <Pagination>
      <Pagination.List>
        <Pagination.Item>
          <Pagination.Previous asChild>
            <a href={hrefFor(Math.max(1, page - 1))}>
              <span className="sr-only">Previous</span>
            </a>
          </Pagination.Previous>
        </Pagination.Item>

        {items.map((item) =>
          item.type === 'page' ? (
            <Pagination.Item key={item.key}>
              <Pagination.Link asChild isActive={item.selected}>
                <a href={hrefFor(item.value)}>{item.value}</a>
              </Pagination.Link>
            </Pagination.Item>
          ) : (
            <Pagination.Item key={item.key}>
              <Pagination.Ellipsis />
            </Pagination.Item>
          )
        )}

        <Pagination.Item>
          <Pagination.Next asChild>
            <a href={hrefFor(Math.min(count, page + 1))}>
              <span className="sr-only">Next</span>
            </a>
          </Pagination.Next>
        </Pagination.Item>
      </Pagination.List>
    </Pagination>
  );
}

Best Practices

  1. State:

    • Pagination is almost always controlled. Source the current page from the URL when possible — it makes results shareable and survives refresh.
    • usePagination is optional. For ≤7 pages, mapping a fixed range is clearer.
  2. Accessibility:

    • The active page must have aria-current="page" (set automatically when isActive is true).
    • Disable Pagination.Previous / Pagination.Next at the boundaries — don’t just hide them.
    • Ellipses are decorative; never put a focusable element inside them.
  3. Routing:

    • Use asChild to compose with next/link or any router-aware component. Pass the destination href to the inner element so prefetching and link semantics work as expected.

Previous

OTP Input

Next

Popover