Skip to content

Rows per page, rolling range, jump to page, and rows that crossfade the way you paged.

Tablesmotion@base-ui/react@number-flow/react

01Preview

Audit log
TimeActorActionEvent
22 Sept, 16:40Theo ParkChanged billing planevt_5dcyg
22 Sept, 16:24Maya LinExported invoicesevt_5dcxf
22 Sept, 16:01deploy-botInvited a memberevt_5dcwe
22 Sept, 15:31Priya RamanUpdated SSO settingsevt_5dcvd
22 Sept, 14:54Jonas WeberRotated API keyevt_5dcuc
22 Sept, 14:41Ana SouzaDeployed checkout-webevt_5dctb
22 Sept, 14:21Theo ParkRemoved a domainevt_5dcsa
22 Sept, 13:54Maya LinChanged billing planevt_5dcr9
22 Sept, 13:20deploy-botExported invoicesevt_5dcq8
22 Sept, 13:10Priya RamanInvited a memberevt_5dcp7

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @base-ui/react @number-flow/react

03Usage

import { TablePagination, TablePageTransition } from "@/components/ui/table-pagination";

<table>
  <thead></thead>
  <TablePageTransition page={page}>
    {rows.map((row) => <tr key={row.id}></tr>)}
  </TablePageTransition>
</table>

<TablePagination
  total={243}
  page={page}
  onPageChange={setPage}
  pageSize={pageSize}
  onPageSizeChange={setPageSize}
  busy={isFetching}
/>

04Source

"use client";
import { NumberField } from "@base-ui/react/number-field";
import { Select } from "@base-ui/react/select";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Check, ChevronLeft, ChevronRight, ChevronsUpDown, Loader } from "@/lib/icons";
import { ease } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));

/* -------------------------------------------------------------------------------------------------
 * Headless state
 * -----------------------------------------------------------------------------------------------*/

export type UseTablePaginationOptions = {
  total?: number;
  page?: number;
  defaultPage?: number;
  onPageChange?: (page: number) => void;
  pageSize?: number;
  defaultPageSize?: number;
  onPageSizeChange?: (size: number) => void;
};

/** Page and page size, both controllable, plus the numbers every footer needs. Pages are 1-based. */
export function useTablePagination({ total, page: pageProp, defaultPage = 1, onPageChange, pageSize: sizeProp, defaultPageSize = 25, onPageSizeChange }: UseTablePaginationOptions = {}) {
  const [rawPage, setRawPage] = useControllableState({ value: pageProp, defaultValue: defaultPage, onChange: onPageChange });
  const [pageSize, setRawSize] = useControllableState({ value: sizeProp, defaultValue: defaultPageSize, onChange: onPageSizeChange });
  const pageCount = total == null ? undefined : Math.max(1, Math.ceil(total / pageSize));
  // A page past the end (rows were deleted, a filter narrowed) reads as the last page.
  const page = pageCount ? clamp(rawPage, 1, pageCount) : Math.max(1, rawPage);
  const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
  const to = total == null ? page * pageSize : Math.min(total, page * pageSize);

  const setPage = (next: number) => setRawPage(pageCount ? clamp(next, 1, pageCount) : Math.max(1, next));
  // Changing the size keeps the first visible row on screen instead of jumping back to page 1.
  const setPageSize = (size: number) => {
    const first = Math.max(0, from - 1);
    setRawSize(size);
    setRawPage(Math.floor(first / size) + 1);
  };

  return { page, pageSize, pageCount, from, to, setPage, setPageSize, slice: <T,>(rows: T[]) => rows.slice((page - 1) * pageSize, page * pageSize) };
}

/* -------------------------------------------------------------------------------------------------
 * The rows: a fast crossfade that leans the way you paged
 * -----------------------------------------------------------------------------------------------*/

export type TablePageTransitionProps = Omit<React.ComponentProps<"tbody">, "ref"> & {
  page: number;
  /** Render as a tbody (default) or a div for grid tables. */
  as?: "tbody" | "div";
};

// Wait mode keeps table layout honest: the old rows leave in 90ms, the new ones arrive
// in 160ms, drifting 6px from the side you paged towards.
export function TablePageTransition({ page, as = "tbody", className, children, ...rest }: TablePageTransitionProps) {
  const reduce = useReducedMotion();
  const [prev, setPrev] = useState(page);
  const [dir, setDir] = useState(1);
  if (page !== prev) {
    setDir(page > prev ? 1 : -1);
    setPrev(page);
  }
  const Tag = as === "tbody" ? motion.tbody : motion.div;
  return (
    <AnimatePresence mode="wait" initial={false} custom={dir}>
      <Tag
        key={page}
        custom={dir}
        variants={{
          enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 6 }),
          show: { opacity: 1, x: 0, transition: { duration: reduce ? 0.1 : 0.16, ease: ease.out } },
          leave: (d: number) => (reduce ? { opacity: 0, transition: { duration: 0.06 } } : { opacity: 0, x: d * -4, transition: { duration: 0.09, ease: ease.in } }),
        }}
        initial="enter"
        animate="show"
        exit="leave"
        className={className}
        {...(rest as object)}
      >
        {children}
      </Tag>
    </AnimatePresence>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Footer
 * -----------------------------------------------------------------------------------------------*/

export type TablePaginationProps = Omit<React.ComponentProps<"nav">, "children"> &
  UseTablePaginationOptions & {
    /** Page sizes offered. Pass an empty array to hide the picker. */
    pageSizes?: number[];
    /** With no total (cursor pagination), whether there is a page after this one. */
    hasNextPage?: boolean;
    /** The requested page is loading: the button that asked for it shows a spinner in place of its arrow. */
    busy?: boolean;
    /** Plural noun for the range text and announcements. */
    itemLabel?: string;
    /** Show first and last page buttons. */
    edges?: boolean;
  };

export function TablePagination({
  total,
  page: pageProp,
  defaultPage,
  onPageChange,
  pageSize: sizeProp,
  defaultPageSize,
  onPageSizeChange,
  pageSizes = [10, 25, 50, 100],
  hasNextPage,
  busy = false,
  itemLabel = "rows",
  edges = true,
  className,
  "aria-label": ariaLabel = "Pagination",
  ...rest
}: TablePaginationProps) {
  const p = useTablePagination({ total, page: pageProp, defaultPage, onPageChange, pageSize: sizeProp, defaultPageSize, onPageSizeChange });
  const [pending, setPending] = useState<"first" | "prev" | "next" | "last" | null>(null);
  const empty = total === 0;
  const canPrev = !empty && p.page > 1;
  const canNext = !empty && (p.pageCount ? p.page < p.pageCount : !!hasNextPage);

  const go = (target: number, which: NonNullable<typeof pending>) => {
    // Paging again while a page loads just supersedes it; the latest request wins.
    setPending(which);
    p.setPage(target);
  };

  const fmt = new Intl.NumberFormat("en-US");
  const announce = empty
    ? `No ${itemLabel}`
    : `Page ${p.page}${p.pageCount ? ` of ${p.pageCount}` : ""}, ${itemLabel} ${fmt.format(p.from)} to ${fmt.format(p.to)}${total != null ? ` of ${fmt.format(total)}` : ""}`;

  return (
    <nav aria-label={ariaLabel} className={cn("@container flex min-h-11 w-full items-center gap-3 text-[12.5px] text-fg-2", className)} {...rest}>
      {pageSizes.length > 0 && (
        <PageSizeSelect value={p.pageSize} sizes={pageSizes} onChange={p.setPageSize} disabled={empty} />
      )}

      <p className="ml-auto flex items-center whitespace-nowrap tabular @max-sm:hidden" aria-hidden>
        {empty ? (
          <span>No {itemLabel}</span>
        ) : (
          <>
            <NumberFlow value={p.from} className="tabular text-fg" />
            <span className="px-0.5 text-fg-4"></span>
            <NumberFlow value={p.to} className="tabular text-fg" />
            {total != null && (
              <>
                <span className="px-1 text-fg-3">of</span>
                <NumberFlow value={total} className="tabular" />
              </>
            )}
          </>
        )}
      </p>

      <div className="flex items-center gap-1 @max-sm:ml-auto">
        {edges && p.pageCount != null && (
          <PageButton label="First page" disabled={!canPrev} busy={busy && pending === "first"} onClick={() => go(1, "first")} className="@max-md:hidden">
            <EdgeGlyph side="first" />
          </PageButton>
        )}
        <PageButton label="Previous page" disabled={!canPrev} busy={busy && pending === "prev"} onClick={() => go(p.page - 1, "prev")}>
          <ChevronLeft size={16} />
        </PageButton>

        {p.pageCount != null ? (
          <PageJump page={p.page} pageCount={p.pageCount} disabled={empty} onCommit={(n) => go(n, n > p.page ? "next" : "prev")} />
        ) : (
          <span className="px-1.5 tabular text-fg">Page {p.page}</span>
        )}

        <PageButton label="Next page" disabled={!canNext} busy={busy && pending === "next"} onClick={() => go(p.page + 1, "next")}>
          <ChevronRight size={16} />
        </PageButton>
        {edges && p.pageCount != null && (
          <PageButton label="Last page" disabled={!canNext} busy={busy && pending === "last"} onClick={() => go(p.pageCount!, "last")} className="@max-md:hidden">
            <EdgeGlyph side="last" />
          </PageButton>
        )}
      </div>

      <span role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </nav>
  );
}

// Only ever called from effects and timers.
const now = () => performance.now();

/** A spinner that waits 150ms before showing, then stays at least 300ms so it never flickers. */
function useSettledBusy(busy: boolean) {
  const [show, setShow] = useState(false);
  const shownAt = useRef(0);
  useEffect(() => {
    if (busy) {
      const t = window.setTimeout(() => {
        shownAt.current = now();
        setShow(true);
      }, 150);
      return () => window.clearTimeout(t);
    }
    const t = window.setTimeout(() => setShow(false), Math.max(0, 300 - (now() - shownAt.current)));
    return () => window.clearTimeout(t);
  }, [busy]);
  return show;
}

function PageButton({ label, busy: busyProp, disabled, className, children, ...rest }: React.ComponentProps<"button"> & { label: string; busy: boolean }) {
  const reduce = useReducedMotion();
  const busy = useSettledBusy(busyProp);
  return (
    <button
      type="button"
      aria-label={label}
      title={label}
      aria-busy={busyProp || undefined}
      // Disabled rather than aria-disabled: at an edge there is nothing to do, and Tab shouldn't stop there.
      disabled={disabled}
      className={cn(
        "relative grid size-7 shrink-0 place-items-center rounded-md border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)] outline-none",
        "transition-[background-color,border-color,color,scale,opacity] duration-150 hover:border-fg-4 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75",
        "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
        "disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none",
        "before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
        busy && "text-fg",
        className,
      )}
      {...rest}
    >
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={busy ? "busy" : "idle"}
          className="grid place-items-center"
          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
          animate={{ opacity: 1, scale: 1 }}
          exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
          transition={{ duration: 0.14, ease: ease.out }}
        >
          {busy ? <Loader size={14} className="animate-spin-slow" /> : children}
        </motion.span>
      </AnimatePresence>
    </button>
  );
}

function EdgeGlyph({ side }: { side: "first" | "last" }) {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden focusable="false">
      {side === "first" ? <path d="M10.25 4.5 6.75 8l3.5 3.5M4.25 4.5v7" /> : <path d="m5.75 4.5 3.5 3.5-3.5 3.5M11.75 4.5v7" />}
    </svg>
  );
}

// "Page [3] of 50": type a number and press Enter, or use the arrow keys to step.
function PageJump({ page, pageCount, disabled, onCommit }: { page: number; pageCount: number; disabled: boolean; onCommit: (page: number) => void }) {
  const [draft, setDraft] = useState<number | null>(page);
  const [synced, setSynced] = useState(page);
  if (page !== synced) {
    setSynced(page);
    setDraft(page);
  }
  const digits = String(pageCount).length;
  const commit = (v: number | null) => {
    // Empty or out-of-range input settles on the nearest real page.
    const next = v == null ? page : clamp(Math.round(v), 1, pageCount);
    setDraft(next);
    if (next !== page) onCommit(next);
  };

  return (
    <NumberField.Root
      value={draft}
      min={1}
      max={pageCount}
      disabled={disabled}
      onValueChange={(v) => setDraft(v)}
      onValueCommitted={commit}
      className="flex items-center gap-1.5 px-1 whitespace-nowrap"
    >
      <span className="text-fg-3 @max-sm:hidden">Page</span>
      <NumberField.Group>
        <NumberField.Input
          aria-label={`Page, of ${pageCount}`}
          onFocus={(e) => e.currentTarget.select()}
          onKeyDown={(e) => {
            if (e.key === "Enter") commit(draft);
          }}
          style={{ width: `calc(${digits}ch + 18px)` }}
          className={cn(
            "h-7 rounded-md border border-line-2 bg-frame px-2 text-center text-base text-fg tabular outline-none sm:text-[12.5px]",
            "transition-[border-color,box-shadow] duration-150 hover:border-fg-4 focus:border-fg-4 focus:ring-2 focus:ring-fg/10",
            "data-disabled:opacity-50",
          )}
        />
      </NumberField.Group>
      <span className="text-fg-3">
        of <span className="tabular">{pageCount.toLocaleString("en-US")}</span>
      </span>
    </NumberField.Root>
  );
}

function PageSizeSelect({ value, sizes, onChange, disabled }: { value: number; sizes: number[]; onChange: (n: number) => void; disabled: boolean }) {
  return (
    <div className="flex items-center gap-2">
      <Select.Root value={value} onValueChange={(v) => v != null && onChange(v as number)} disabled={disabled} items={sizes.map((s) => ({ value: s, label: String(s) }))}>
        <Select.Label className="whitespace-nowrap text-fg-3 @max-md:sr-only">Rows per page</Select.Label>
        <Select.Trigger
          className={cn(
            "group/size relative inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-line-2 bg-raised pl-2 pr-1 text-fg shadow-[var(--shadow)] outline-none",
            "transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97] data-popup-open:border-fg-4 data-popup-open:bg-hover",
            "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
            "data-disabled:pointer-events-none data-disabled:opacity-50",
            "before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
          )}
        >
          <Select.Value className="min-w-[3ch] tabular">{(v: number) => <>{v}<span className="sr-only"> per page</span></>}</Select.Value>
          <Select.Icon className="text-fg-3 transition-colors group-hover/size:text-fg-2">
            <ChevronsUpDown size={14} />
          </Select.Icon>
        </Select.Trigger>
        <Select.Portal>
          <Select.Positioner side="top" align="start" sideOffset={6} alignItemWithTrigger={false} collisionPadding={8} className="z-(--z-dropdown) outline-none">
            <Select.Popup
              className={cn(
                "min-w-[max(5.5rem,var(--anchor-width))] rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none",
                "origin-(--transform-origin) transition-[opacity,scale,translate] duration-180 ease-out-expo",
                "data-starting-style:scale-96 data-starting-style:opacity-0 data-[side=top]:data-starting-style:translate-y-1 data-[side=bottom]:data-starting-style:-translate-y-1",
                "data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120",
                "data-instant:duration-0 motion-reduce:scale-100 motion-reduce:translate-none",
              )}
            >
              <Select.List>
                {sizes.map((s) => (
                  <Select.Item
                    key={s}
                    value={s}
                    className="flex h-8 cursor-default select-none items-center gap-2 rounded-lg pl-2 pr-2.5 text-[13px] tabular outline-none transition-colors duration-100 data-highlighted:bg-hover"
                  >
                    <span className="grid size-3.5 place-items-center text-fg">
                      <Select.ItemIndicator className="grid place-items-center">
                        <Check size={14} />
                      </Select.ItemIndicator>
                    </span>
                    <Select.ItemText>{s}</Select.ItemText>
                  </Select.Item>
                ))}
              </Select.List>
            </Select.Popup>
          </Select.Positioner>
        </Select.Portal>
      </Select.Root>
    </div>
  );
}

05Props

TablePagination

PropTypeDefaultDescription
totalnumberTotal rows. Leave it out for cursor pagination: the range shows without “of”, and Last and the page field hide.
page / defaultPagenumber1Current page, 1-based. Out-of-range values read as the nearest real page.
onPageChange(page: number) => voidCalled from the buttons, the page field and page-size changes.
pageSize / defaultPageSizenumber25Rows per page.
onPageSizeChange(size: number) => voidCalled when a size is picked. The page also moves so the first visible row stays on screen.
pageSizesnumber[][10, 25, 50, 100]Sizes offered. An empty array hides the picker.
hasNextPagebooleanWithout a total, whether Next is enabled.
busybooleanfalseThe requested page is loading; the button that asked shows a spinner.
itemLabelstring"rows"Plural noun for the empty state and announcements.
edgesbooleantrueShow First and Last page buttons (hidden on narrow footers).

TablePageTransition

PropTypeDefaultDescription
page*numberKey for the current page; direction comes from whether it went up or down.
as"tbody" | "div""tbody"A tbody for real tables, a div for grid tables.

useTablePagination

PropTypeDefaultDescription
optionsUseTablePaginationOptionsSame state props as the footer. Returns { page, pageSize, pageCount, from, to, setPage, setPageSize, slice }.

06Notes

Behavior

  • Changing rows per page keeps the first visible row on screen: row 231 at 10 per page lands on page 10 at 25 per page, not back on page 1.
  • Type a page and press Enter, or use the arrow keys in the field; empty or out-of-range input settles on the nearest real page.
  • The busy spinner waits 150ms before it shows and then stays at least 300ms. Paging again while loading supersedes the request rather than being ignored.
  • At a width under 384px the range text and the “Page” word drop, then First and Last go under 448px; zero rows shows “No rows” and disables everything.

Motion

  • Rows leave in 90ms drifting 4px against the paging direction and the new page arrives in 160ms from 6px on the side you paged towards; wait mode keeps table layout intact.
  • The range numbers roll digit by digit rather than swapping.
  • Arrow and spinner swap in place with a 140ms scale-and-fade. Reduced motion keeps 100ms fades with no travel or scale.

Accessibility

  • A nav landmark named Pagination; every icon button has a name and a title, and is disabled at the edges so Tab skips it.
  • The page field is a spinbutton named with its page count; rows per page is a labeled select.
  • A polite live region announces “Page 3 of 25, events 21 to 30 of 243” after each change.