FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Pagination

Pagination

pnpm dlx shadcn@latest add …/r/pagination.json

An accessible React pagination for Next.js and Tailwind CSS: page links with ellipses, Previous and Next, and a pill that slides to the current page.

  • More pages
import {
  getPaginationRange,
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination"

// Pages as URLs (?page=3): links work in a server component
export function Pages({ page, pageCount }: { page: number; pageCount: number }) {
  return (
    <Pagination>
      <PaginationContent>
        <PaginationItem>
          <PaginationPrevious href={`?page=${page - 1}`} disabled={page === 1} />
        </PaginationItem>
        {getPaginationRange({ page, pageCount }).map((item, index) => (
          <PaginationItem key={item === "ellipsis" ? `ellipsis-${index}` : item}>
            {item === "ellipsis" ? (
              <PaginationEllipsis />
            ) : (
              <PaginationLink href={`?page=${item}`} isActive={item === page}>
                {item}
              </PaginationLink>
            )}
          </PaginationItem>
        ))}
        <PaginationItem>
          <PaginationNext href={`?page=${page + 1}`} disabled={page === pageCount} />
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  )
}
pages.tsx
"use client";

import {
  getPaginationRange,
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination";
import { useState } from "react";

const pageCount = 10;

/*
 * Ten pages to click through. The pill slides to each new page; Previous and Next turn off at the ends.
 * Buttons, because the page changes in place; give the links an href (or asChild with your router's Link) to
 * move between URLs instead.
 */
export default function PaginationPages() {
  const [page, setPage] = useState(4);

  return (
    <Pagination>
      <PaginationContent>
        <PaginationItem>
          <PaginationPrevious disabled={page === 1} onClick={() => setPage((current) => current - 1)} />
        </PaginationItem>
        {getPaginationRange({ page, pageCount }).map((item, index) => (
          <PaginationItem key={item === "ellipsis" ? `ellipsis-${index}` : item}>
            {item === "ellipsis" ? (
              <PaginationEllipsis />
            ) : (
              <PaginationLink
                isActive={item === page}
                aria-label={`Page ${item}`}
                onClick={() => setPage(item)}
              >
                {item}
              </PaginationLink>
            )}
          </PaginationItem>
        ))}
        <PaginationItem>
          <PaginationNext disabled={page === pageCount} onClick={() => setPage((current) => current + 1)} />
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add lucide-react motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Add Button first.

  5. 5

    Copy the source into your project.

    components/ui/pagination.tsx
    import { type ButtonProps, buttonVariants } from "@/components/ui/button";
    import { PaginationGroup, PaginationPill } from "@/components/ui/pagination-pill";
    import { cn } from "@/lib/utils";
    import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
    import { Slot } from "radix-ui";
    import type { ComponentProps, ReactNode } from "react";
    
    /*
     * Pagination (owner's roadmap, 2026-10-16: "Move through pages of results"), with shadcn's parts: Pagination,
     * PaginationContent, PaginationItem, PaginationLink, PaginationPrevious, PaginationNext and PaginationEllipsis.
     * - A nav landmark ("Pagination") holding a list. The current page has aria-current="page".
     * - Links are ghost Buttons (buttonVariants), muted until hovered. A pill in Button's outline look marks the
     *   current page and slides to the new one when the page changes (pagination-pill.tsx, the one client part).
     * - PaginationLink is a link with `href`, a button without one (for pages changed in place, such as a table's),
     *   or with `asChild` your router's Link. At the first and last pages, Previous and Next are `disabled`:
     *   aria-disabled, faded and inert. A button keeps its focus (so pressing Next onto the last page doesn't drop
     *   the keyboard to the top of the page); a link loses its href.
     * - getPaginationRange works out which pages to show, with ellipses for the gaps, always the same number of
     *   items, so the links don't jump about as you move through them.
     */
    
    /** A page number, or a gap of pages not shown */
    export type PaginationRangeItem = number | "ellipsis";
    
    /**
     * The pages to show around `page` (1-based) of `pageCount`: the first and last `boundaryCount` pages,
     * `siblingCount` pages either side of the current one, and "ellipsis" for each gap. When there are enough pages,
     * the list is always the same length (7 with the defaults), so the links keep their places.
     */
    function getPaginationRange({
      page,
      pageCount,
      siblingCount = 1,
      boundaryCount = 1,
    }: {
      page: number;
      pageCount: number;
      siblingCount?: number;
      boundaryCount?: number;
    }): PaginationRangeItem[] {
      const range = (from: number, to: number) =>
        Array.from({ length: Math.max(0, to - from + 1) }, (_, index) => from + index);
      if (pageCount <= boundaryCount * 2 + siblingCount * 2 + 3) return range(1, pageCount);
    
      const current = Math.min(Math.max(page, 1), pageCount);
      const start = range(1, boundaryCount);
      const end = range(pageCount - boundaryCount + 1, pageCount);
      // The siblings slide along between the ends, keeping the same count as they near either end
      const siblingsFrom = Math.max(
        Math.min(current - siblingCount, pageCount - boundaryCount - siblingCount * 2 - 1),
        boundaryCount + 2,
      );
      const siblingsTo = Math.min(
        Math.max(current + siblingCount, boundaryCount + siblingCount * 2 + 2),
        pageCount - boundaryCount - 1,
      );
      // A gap of one page shows that page instead of an ellipsis
      return [
        ...start,
        ...(siblingsFrom > boundaryCount + 2 ? (["ellipsis"] as const) : [boundaryCount + 1]),
        ...range(siblingsFrom, siblingsTo),
        ...(siblingsTo < pageCount - boundaryCount - 1 ? (["ellipsis"] as const) : [pageCount - boundaryCount]),
        ...end,
      ];
    }
    
    function Pagination({ className, "aria-label": label = "Pagination", ...props }: ComponentProps<"nav">) {
      return (
        <nav
          aria-label={label}
          data-slot="pagination"
          className={cn("mx-auto flex w-full justify-center", className)}
          {...props}
        />
      );
    }
    
    function PaginationContent({ className, children, ...props }: ComponentProps<"ul">) {
      return (
        <ul
          data-slot="pagination-content"
          // The list is the pill's layer: it slides behind every link
          className={cn("relative isolate flex flex-row items-center gap-1", className)}
          {...props}
        >
          <PaginationGroup>{children}</PaginationGroup>
        </ul>
      );
    }
    
    function PaginationItem({ className, ...props }: ComponentProps<"li">) {
      return <li data-slot="pagination-item" className={cn("relative", className)} {...props} />;
    }
    
    export type PaginationLinkProps = ComponentProps<"a"> &
      Pick<ButtonProps, "size"> & {
        /** The current page: it gets the pill and aria-current="page" */
        isActive?: boolean;
        /** Render the single child (your router's Link) with the link's styles */
        asChild?: boolean;
        /**
         * Can't be followed (Previous on the first page). It's marked aria-disabled and its onClick isn't called; a
         * link also loses its href. A button keeps its focus, so the keyboard isn't thrown out at the last page.
         */
        disabled?: boolean;
      };
    
    function PaginationLink({
      className,
      isActive = false,
      size = "icon",
      asChild = false,
      disabled = false,
      href,
      onClick,
      children,
      ...props
    }: PaginationLinkProps) {
      const shared = {
        "data-slot": "pagination-link",
        "data-active": isActive || undefined,
        "aria-current": isActive ? ("page" as const) : undefined,
        "aria-disabled": disabled || undefined,
        // Left out rather than wrapped, so nothing new is created here and links still render on the server
        onClick: disabled ? undefined : onClick,
        className: cn(
          buttonVariants({ variant: "ghost", size }),
          "text-muted-foreground hover:text-foreground data-active:text-foreground",
          "aria-disabled:pointer-events-none aria-disabled:opacity-(--opacity-disabled)",
          className,
        ),
      };
    
      let control: ReactNode;
      if (asChild) {
        // The href, if any, goes onto your Link along with the styles
        control = (
          <Slot.Root {...shared} {...(href === undefined ? null : { href })} {...props}>
            {children}
          </Slot.Root>
        );
      } else if (href === undefined) {
        // No href: the page changes in place, so it's a button
        control = (
          <button
            type="button"
            {...(shared as ComponentProps<"button">)}
            {...(props as ComponentProps<"button">)}
          >
            {children}
          </button>
        );
      } else {
        control = (
          <a {...shared} {...(disabled ? { role: "link" } : { href })} {...props}>
            {children}
          </a>
        );
      }
    
      return (
        <>
          {isActive ? (
            <PaginationPill className={size === "sm" || size === "icon-sm" ? "rounded-md" : undefined} />
          ) : null}
          {control}
        </>
      );
    }
    
    /** Previous and Next: a chevron, and a label from the sm breakpoint. With asChild, the label goes inside your Link. */
    function edgeContent(asChild: boolean, children: ReactNode, parts: ReactNode[]) {
      return asChild ? [<Slot.Slottable key="link">{children}</Slot.Slottable>, ...parts] : parts;
    }
    
    function PaginationPrevious({ className, asChild = false, children, ...props }: PaginationLinkProps) {
      return (
        <PaginationLink
          aria-label="Go to previous page"
          size="md"
          asChild={asChild}
          className={cn("gap-1 px-2.5", className)}
          {...props}
        >
          {edgeContent(asChild, children, [
            <ChevronLeftIcon key="icon" aria-hidden className="rtl:rotate-180" />,
            <span key="label" className="hidden sm:block">
              Previous
            </span>,
          ])}
        </PaginationLink>
      );
    }
    
    function PaginationNext({ className, asChild = false, children, ...props }: PaginationLinkProps) {
      return (
        <PaginationLink
          aria-label="Go to next page"
          size="md"
          asChild={asChild}
          className={cn("gap-1 px-2.5", className)}
          {...props}
        >
          {edgeContent(asChild, children, [
            <span key="label" className="hidden sm:block">
              Next
            </span>,
            <ChevronRightIcon key="icon" aria-hidden className="rtl:rotate-180" />,
          ])}
        </PaginationLink>
      );
    }
    
    /** Stands for pages that aren't shown. Screen readers hear "More pages". */
    function PaginationEllipsis({ className, ...props }: ComponentProps<"span">) {
      return (
        <span
          data-slot="pagination-ellipsis"
          className={cn(
            "flex size-(--size-control-md) items-center justify-center text-muted-foreground",
            className,
          )}
          {...props}
        >
          <MoreHorizontalIcon aria-hidden className="size-(--size-icon-md)" />
          <span className="sr-only">More pages</span>
        </span>
      );
    }
    
    export {
      getPaginationRange,
      Pagination,
      PaginationContent,
      PaginationEllipsis,
      PaginationItem,
      PaginationLink,
      PaginationNext,
      PaginationPrevious,
    };
    components/ui/pagination-pill.tsx
    "use client";
    
    import { spring } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { type ReactNode, useId, useLayoutEffect, useRef, useState } from "react";
    
    /*
     * The client parts of Pagination, so the rest stays a server component (links rendered on the server work).
     * - One pill marks the current page and slides to the new one when the page changes (layoutId on
     *   spring.snappy, .agents/rules/motion.md), scoped to each Pagination, so two never trade a pill.
     * - It lies behind the link, inside the list item, rather than inside the link: the link keeps Button's own
     *   hover tint, which is clipped to it, and the pill isn't clipped while it slides.
     * - It starts where it is on first paint. With reduced motion it jumps.
     */
    
    /** One pill per Pagination */
    function PaginationGroup({ children }: { children: ReactNode }) {
      const id = useId();
      return <LayoutGroup id={id}>{children}</LayoutGroup>;
    }
    
    /** The pill behind the current page's link */
    function PaginationPill({ className }: { className?: string }) {
      const reduce = useReducedMotion() ?? false;
      const node = useRef<HTMLSpanElement>(null);
      const [radius, setRadius] = useState<number>();
    
      // Its corners follow the link's own (read from it), as a number, so motion keeps them true if the pill changes
      // width between pages. The class below draws them until then, including on the server.
      useLayoutEffect(() => {
        const link = node.current?.parentElement?.querySelector("[data-slot=pagination-link]");
        if (link) setRadius(Number.parseFloat(getComputedStyle(link).borderTopLeftRadius) || 0);
      }, []);
    
      return (
        <motion.span
          ref={node}
          layoutId="pagination-pill"
          aria-hidden
          data-slot="pagination-pill"
          className={cn(
            "pointer-events-none absolute inset-0 -z-10 rounded-lg border border-input bg-background",
            className,
          )}
          style={radius === undefined ? undefined : { borderRadius: radius }}
          transition={reduce ? { duration: 0 } : spring.snappy}
        />
      );
    }
    
    export { PaginationGroup, PaginationPill };
  6. 6

    Update the import paths to match your project.

API reference

Pagination

The nav landmark. Also accepts every native nav prop.

PropTypeDefaultDescription
aria-label?string"Pagination"The landmark's name. Give each one its own name when a page has two.

PaginationContent

The list of items (ul). The pill slides within it.

PropTypeDefaultDescription

PaginationItem

One item (li), holding a link or an ellipsis.

PropTypeDefaultDescription

PaginationLink

A page. A link with href, a button without one, or your router's Link with asChild. Styled with buttonVariants (ghost); also accepts every native a prop.

PropTypeDefaultDescription
isActive?booleanfalseThe current page: it gets the pill and aria-current="page".
href?string–The page's URL. Leave it out for a button that changes the page in place (use onClick).
asChild?booleanfalseRender the single child, for example Next.js Link, with the link's styles. When it's disabled, leave out the child's href too.
disabled?booleanfalseMarks it aria-disabled, fades it and stops it: onClick isn't called and a link loses its href. A button keeps its focus.
size?"sm" | "md" | "lg" | "icon" | "icon-sm" | "icon-lg""icon"Button's sizes. Page numbers are square icon sizes.

PaginationPrevious

A link to the previous page: a chevron, with the word Previous from the sm breakpoint. Named "Go to previous page". Takes PaginationLink's props.

PropTypeDefaultDescription

PaginationNext

A link to the next page, named "Go to next page". Takes PaginationLink's props.

PropTypeDefaultDescription

PaginationEllipsis

Stands for pages that aren't shown. Screen readers hear "More pages".

PropTypeDefaultDescription

getPaginationRange

Works out which pages to show: returns page numbers and "ellipsis" for each gap. With enough pages the list is always the same length, so the links keep their places.

PropTypeDefaultDescription
pagenumber–The current page, from 1.
pageCountnumber–How many pages there are.
siblingCount?number1Pages shown either side of the current one.
boundaryCount?number1Pages always shown at the start and the end.

Related components

  • InvoiceStatusAmount
    INV-1048Paid$1,250.00
    INV-1047Pending$480.00
    INV-1046Overdue$2,900.00
    Table

    An accessible React table for Next.js and Tailwind CSS: rows and columns with sortable headers, selectable rows and rows that glide when sorted.

    1. Home
    2. Components
    3. Breadcrumb
    Breadcrumb

    An accessible React breadcrumb for Next.js and Tailwind CSS: a trail of links whose levels animate as you move, with custom separators and a collapsed middle.

  • Switcher

    An accessible React segmented control for Next.js: one choice from a few options, with a pill that slides to the one you pick.