# Pagination

> Page navigation that keeps every button in place, glides to the current page and jumps to any page from the ellipsis.

Docs: https://hextaui.com/docs/pagination
Markdown: https://hextaui.com/docs/pagination.md

```tsx title="components/examples/pagination/demo.tsx"
"use client"

import * as React from "react"

import { Pagination } from "@/components/ui/pagination"

const perPage = 10
const total = 248

export function PaginationDemo() {
  const [page, setPage] = React.useState(1)
  const first = (page - 1) * perPage + 1
  const last = Math.min(page * perPage, total)

  return (
    <div className="flex w-full flex-col items-center gap-4">
      <Pagination
        page={page}
        count={Math.ceil(total / perPage)}
        onPageChange={setPage}
      />
      <p className="text-sm text-muted-foreground tabular-nums">
        Showing {first}–{last} of {total} results
      </p>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/pagination.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/pagination.tsx"
"use client"

import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import {
  IconChevronLeft,
  IconChevronRight,
  IconDots,
} from "@tabler/icons-react"
import { cn } from "cn"

import { buttonVariants } from "@/components/ui/button"
import {
  usePagination,
  type PaginationItemData,
  type UsePaginationOptions,
} from "@/hooks/use-pagination"
import { prefersReducedMotion, useSlidingHighlight } from "@/lib/motion"

function clampInt(value: unknown, fallback: number, min: number, max: number) {
  const number = Math.floor(Number(value))
  if (!Number.isFinite(number)) {
    return fallback
  }
  return Math.min(max, Math.max(min, number))
}

const linkClassName =
  "h-9 min-w-[max(--spacing(9),calc(var(--pagination-digits,1)*1ch+--spacing(4)))] px-2 tabular-nums pointer-coarse:h-11 pointer-coarse:min-w-11 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-[current=page]:text-foreground aria-[current=page]:inset-ring-border aria-[current=page]:bg-background dark:aria-[current=page]:bg-input/30 dark:aria-[current=page]:inset-ring-input group-has-[>[data-slot=pagination-indicator][data-visible]]/pagination:aria-[current=page]:bg-transparent group-has-[>[data-slot=pagination-indicator][data-visible]]/pagination:aria-[current=page]:inset-ring-transparent group-has-[>[data-slot=pagination-indicator][data-visible]]/pagination:aria-[current=page]:hover:bg-transparent"

type PaginationProps = React.ComponentProps<"nav"> & {
  page?: number
  defaultPage?: number
  count?: number
  onPageChange?: (page: number) => void
  siblings?: number
  boundaries?: number
  getPageHref?: (page: number) => string
  compact?: boolean | "auto"
  jump?: boolean
}

const indicatorClassName =
  "pointer-events-none absolute top-0 -z-1 rounded-md bg-background opacity-0 inset-ring-(length:--hairline) forced-colors:border inset-ring-border transition-[transform,width,height,opacity] duration-300 ease-out-quint data-instant:transition-opacity data-visible:opacity-100 motion-reduce:transition-opacity dark:bg-input/30 dark:inset-ring-input"

const linkSelector = "[data-slot=pagination-link][aria-current=page]"

function focusableItems(nav: HTMLElement) {
  return Array.from(
    nav.querySelectorAll<HTMLElement>("a[href], a[role=link], button")
  ).filter(
    (item) =>
      item.getClientRects().length > 0 &&
      item.getAttribute("aria-disabled") !== "true" &&
      !item.hasAttribute("disabled") &&
      getComputedStyle(item).visibility !== "hidden"
  )
}

function handleArrowKeys(event: React.KeyboardEvent<HTMLElement>) {
  const keys = ["ArrowLeft", "ArrowRight", "Home", "End"]
  const target = event.target as HTMLElement
  if (
    event.defaultPrevented ||
    !keys.includes(event.key) ||
    event.altKey ||
    event.ctrlKey ||
    event.metaKey ||
    target.closest("input, textarea, [contenteditable=true]")
  ) {
    return
  }
  const items = focusableItems(event.currentTarget)
  const index = items.indexOf(target)
  if (index === -1) {
    return
  }
  const rtl = getComputedStyle(event.currentTarget).direction === "rtl"
  const forward = event.key === "ArrowRight" ? !rtl : rtl
  const next =
    event.key === "Home"
      ? 0
      : event.key === "End"
        ? items.length - 1
        : Math.min(items.length - 1, Math.max(0, index + (forward ? 1 : -1)))
  event.preventDefault()
  items[next]?.focus()
}

function Pagination({
  className,
  children,
  page,
  defaultPage = 1,
  count,
  onPageChange,
  siblings,
  boundaries,
  getPageHref,
  compact = "auto",
  jump = true,
  style,
  onKeyDown,
  ref,
  ...props
}: PaginationProps) {
  const auto = count !== undefined
  const navRef = React.useRef<HTMLElement | null>(null)
  const indicatorRef = React.useRef<HTMLSpanElement | null>(null)
  useSlidingHighlight(navRef, indicatorRef, linkSelector, "aria-current")

  const setRef = React.useCallback(
    (node: HTMLElement | null) => {
      navRef.current = node
      if (typeof ref === "function") {
        return ref(node)
      }
      if (ref) {
        ref.current = node
      }
      return undefined
    },
    [ref]
  )

  return (
    <nav
      ref={setRef}
      aria-label="Pagination"
      data-slot="pagination"
      className={cn(
        "group/pagination relative isolate mx-auto flex w-full min-w-0 justify-center text-sm",
        className
      )}
      style={style}
      onKeyDown={(event) => {
        onKeyDown?.(event)
        handleArrowKeys(event)
      }}
      {...props}
    >
      {auto ? (
        <PaginationAuto
          page={page}
          defaultPage={defaultPage}
          count={count}
          onPageChange={onPageChange}
          siblings={siblings}
          boundaries={boundaries}
          getPageHref={getPageHref}
          compact={compact}
          jump={jump}
        />
      ) : (
        <>
          <span
            ref={indicatorRef}
            aria-hidden="true"
            data-slot="pagination-indicator"
            className={indicatorClassName}
          />
          {children}
        </>
      )}
    </nav>
  )
}

function isPlainClick(event: React.MouseEvent) {
  return (
    event.button === 0 &&
    !event.metaKey &&
    !event.ctrlKey &&
    !event.shiftKey &&
    !event.altKey
  )
}

function measureList(list: HTMLElement) {
  const layouts = list.closest<HTMLElement>("[data-slot=pagination-layouts]")
  if (!layouts) {
    return 0
  }
  const clone = list.cloneNode(true) as HTMLElement
  clone.removeAttribute("id")
  clone.setAttribute("aria-hidden", "true")
  clone.inert = true
  clone.className = cn(list.className, "invisible fixed top-0 left-0 flex")
  const digits = getComputedStyle(layouts).getPropertyValue(
    "--pagination-digits"
  )
  clone.style.setProperty("--pagination-digits", digits)
  clone.style.fontSize = getComputedStyle(list).fontSize
  document.body.appendChild(clone)
  const width = clone.getBoundingClientRect().width
  clone.remove()
  return width
}

function PaginationAuto({
  page: pageProp,
  defaultPage,
  count,
  onPageChange,
  siblings,
  boundaries,
  getPageHref,
  compact,
  jump,
}: Required<Pick<PaginationProps, "defaultPage" | "count" | "compact">> &
  Pick<
    PaginationProps,
    "page" | "onPageChange" | "siblings" | "boundaries" | "getPageHref" | "jump"
  >) {
  const [internalPage, setInternalPage] = React.useState(defaultPage)
  const pagination = usePagination({
    page: pageProp ?? internalPage,
    count,
    siblings,
    boundaries,
  })
  const fullRef = React.useRef<HTMLDivElement | null>(null)
  const indicatorRef = React.useRef<HTMLSpanElement | null>(null)
  const listRef = React.useRef<HTMLUListElement | null>(null)
  const layoutsRef = React.useRef<HTMLDivElement | null>(null)
  const slots = Math.min(
    pagination.count,
    clampInt(boundaries ?? 1, 1, 0, 10) * 2 +
      clampInt(siblings ?? 1, 1, 0, 10) * 2 +
      3
  )
  const digits = String(pagination.count).length
  const vars = {
    "--pagination-digits": digits,
    "--pagination-needed": `calc(${slots} * max(2.25rem, ${digits}ch + 1rem) + ${slots + 1} * 0.25rem + 10.5rem)`,
  } as React.CSSProperties
  useSlidingHighlight(fullRef, indicatorRef, linkSelector, "aria-current")

  React.useLayoutEffect(() => {
    const list = listRef.current
    const layouts = layoutsRef.current
    if (compact !== "auto" || !list || !layouts) {
      return
    }
    const update = () => {
      const width = measureList(list)
      if (width > 0) {
        layouts.style.setProperty(
          "--pagination-needed",
          `${Math.ceil(width)}px`
        )
      }
    }
    update()
    document.fonts?.addEventListener("loadingdone", update)
    return () => document.fonts?.removeEventListener("loadingdone", update)
  })

  const go = (next: number) => {
    const target = clampInt(next, 1, 1, Math.max(1, pagination.count))
    if (target === pagination.page) {
      return
    }
    if (pageProp === undefined) {
      setInternalPage(target)
    }
    onPageChange?.(target)
  }

  const canJump = jump !== false && (!!onPageChange || !getPageHref)
  const jumpTo = (target: number) => {
    if (!onPageChange && getPageHref) {
      window.location.assign(getPageHref(target))
      return
    }
    go(target)
  }

  const linkProps = (target: number, disabled = false) => {
    const href = getPageHref?.(target)
    return {
      disabled,
      href,
      render: href ? undefined : <button type="button" />,
      onClick: (event: React.MouseEvent) => {
        if (href && !isPlainClick(event)) {
          return
        }
        go(target)
      },
    }
  }

  const previous = (iconOnly: boolean) => (
    <PaginationItem>
      <PaginationPrevious
        {...linkProps(pagination.page - 1, !pagination.hasPrevious)}
        text={iconOnly ? null : undefined}
      />
    </PaginationItem>
  )
  const next = (iconOnly: boolean) => (
    <PaginationItem>
      <PaginationNext
        {...linkProps(pagination.page + 1, !pagination.hasNext)}
        text={iconOnly ? null : undefined}
      />
    </PaginationItem>
  )

  const label = (
    <>
      Page {pagination.page} of {pagination.count}
    </>
  )

  const compactList = (
    <PaginationContent
      className={cn(
        compact === "auto" && "@max-[4px]/pagination-compact:hidden"
      )}
    >
      {previous(true)}
      <PaginationItem>
        {canJump && pagination.count > 1 ? (
          <PaginationJump
            count={pagination.count}
            onJump={jumpTo}
            suffix={`of ${pagination.count}`}
            aria-label={`Page ${pagination.page} of ${pagination.count}, go to page`}
            className="px-2.5"
          >
            {label}
          </PaginationJump>
        ) : (
          <span
            data-slot="pagination-status"
            className="flex h-9 items-center px-2 text-sm whitespace-nowrap tabular-nums pointer-coarse:h-11"
          >
            {label}
          </span>
        )}
      </PaginationItem>
      {next(true)}
    </PaginationContent>
  )

  const fullList = (
    <PaginationContent
      ref={listRef}
      className={cn(
        compact === "auto" && "w-max @max-[4px]/pagination-full:hidden"
      )}
    >
      {previous(false)}
      {pagination.items.map((item) =>
        item.type === "page" ? (
          <PaginationItem key={item.page}>
            <PaginationLink
              {...linkProps(item.page)}
              isActive={item.page === pagination.page}
              aria-label={`Page ${item.page}`}
            >
              {item.page}
            </PaginationLink>
          </PaginationItem>
        ) : (
          <PaginationItem key={item.position}>
            <PaginationEllipsis
              count={pagination.count}
              onJump={canJump ? jumpTo : undefined}
            />
          </PaginationItem>
        )
      )}
      {next(false)}
    </PaginationContent>
  )

  const indicator = (
    <span
      ref={indicatorRef}
      aria-hidden="true"
      data-slot="pagination-indicator"
      className={indicatorClassName}
    />
  )

  if (compact !== "auto") {
    return (
      <div
        ref={layoutsRef}
        data-slot="pagination-layouts"
        style={vars}
        className="flex w-full justify-center"
      >
        {compact ? (
          compactList
        ) : (
          <div
            ref={fullRef}
            data-slot="pagination-full"
            className="group/pagination relative isolate"
          >
            {indicator}
            {fullList}
          </div>
        )}
      </div>
    )
  }

  return (
    <div
      ref={layoutsRef}
      data-slot="pagination-layouts"
      style={vars}
      className="grid w-full grid-cols-1"
    >
      <div
        ref={fullRef}
        data-slot="pagination-full"
        className="group/pagination @container/pagination-full relative isolate col-start-1 row-start-1 flex w-full max-w-[max(0px,calc((100%-var(--pagination-needed)+1px)*9999))] justify-center justify-self-center"
      >
        {indicator}
        {fullList}
      </div>
      <div
        data-slot="pagination-compact"
        className="@container/pagination-compact col-start-1 row-start-1 flex w-full max-w-[max(0px,calc((var(--pagination-needed)-100%)*9999))] justify-center justify-self-center"
      >
        {compactList}
      </div>
    </div>
  )
}

function PaginationContent({
  className,
  ...props
}: React.ComponentProps<"ul">) {
  return (
    <ul
      data-slot="pagination-content"
      className={cn("flex items-center gap-1", className)}
      {...props}
    />
  )
}

function PaginationItem(props: React.ComponentProps<"li">) {
  return <li data-slot="pagination-item" {...props} />
}

type PaginationLinkProps = useRender.ComponentProps<"a"> & {
  isActive?: boolean
  disabled?: boolean
}

function PaginationLink({
  className,
  isActive = false,
  disabled = false,
  render,
  href,
  onClick,
  ...props
}: PaginationLinkProps) {
  return useRender({
    defaultTagName: "a",
    render,
    props: mergeProps<"a">(
      {
        className: cn(
          buttonVariants({ variant: "ghost" }),
          linkClassName,
          className
        ),
        href: disabled ? undefined : href,
        ...(disabled && !render ? { role: "link", tabIndex: 0 } : {}),
      },
      props,
      {
        "data-slot": "pagination-link",
        "aria-current": isActive ? "page" : undefined,
        "aria-disabled": disabled || undefined,
        "data-active": isActive ? "" : undefined,
        onClick: (event: React.MouseEvent<HTMLAnchorElement>) => {
          if (disabled) {
            event.preventDefault()
            return
          }
          onClick?.(event)
        },
      } as React.ComponentProps<"a">
    ),
  })
}

type PaginationStepProps = PaginationLinkProps & {
  text?: React.ReactNode
}

function PaginationPrevious({
  text = "Previous",
  className,
  ...props
}: PaginationStepProps) {
  return (
    <PaginationLink
      aria-label="Go to previous page"
      className={cn(text ? "ps-2" : "px-0", className)}
      {...props}
    >
      <IconChevronLeft
        data-icon={text ? "inline-start" : undefined}
        className="rtl:-scale-x-100"
      />
      {text ? <span className="max-sm:hidden">{text}</span> : null}
    </PaginationLink>
  )
}

function PaginationNext({
  text = "Next",
  className,
  ...props
}: PaginationStepProps) {
  return (
    <PaginationLink
      aria-label="Go to next page"
      className={cn(text ? "pe-2" : "px-0", className)}
      {...props}
    >
      {text ? <span className="max-sm:hidden">{text}</span> : null}
      <IconChevronRight
        data-icon={text ? "inline-end" : undefined}
        className="rtl:-scale-x-100"
      />
    </PaginationLink>
  )
}

type PaginationJumpProps = Omit<React.ComponentProps<"button">, "onClick"> & {
  count: number
  onJump: (page: number) => void
  suffix?: React.ReactNode
}

function PaginationJump({
  count,
  onJump,
  suffix,
  className,
  children,
  ...props
}: PaginationJumpProps) {
  const [editing, setEditing] = React.useState(false)
  const [value, setValue] = React.useState("")
  const [invalid, setInvalid] = React.useState(false)
  const [width, setWidth] = React.useState(0)
  const triggerRef = React.useRef<HTMLButtonElement | null>(null)
  const inputRef = React.useRef<HTMLInputElement | null>(null)
  const returnFocus = React.useRef(false)
  const digits = String(count).length

  React.useLayoutEffect(() => {
    if (editing) {
      inputRef.current?.focus()
    } else if (returnFocus.current) {
      returnFocus.current = false
      triggerRef.current?.focus()
    }
  }, [editing])

  const shake = () => {
    const input = inputRef.current
    setInvalid(true)
    if (!input || prefersReducedMotion()) {
      return
    }
    input.removeAttribute("data-shake")
    void input.offsetWidth
    input.setAttribute("data-shake", "")
  }

  const commit = () => {
    const text = value
      .trim()
      .replace(/[\u0660-\u0669\u06f0-\u06f9\uff10-\uff19]/g, (digit) =>
        String(digit.charCodeAt(0) & 0xf)
      )
    const target = Number(text)
    if (!/^\d+$/.test(text) || target < 1 || target > count) {
      shake()
      return
    }
    const nav = inputRef.current?.closest("[data-slot=pagination]")
    setEditing(false)
    setValue("")
    setInvalid(false)
    onJump(target)
    requestAnimationFrame(() => {
      const focusTarget =
        nav?.querySelector<HTMLElement>(linkSelector) ??
        nav?.querySelector<HTMLElement>("[data-slot=pagination-jump]")
      focusTarget?.focus()
    })
  }

  if (editing) {
    return (
      <span
        data-slot="pagination-jump-field"
        style={
          { "--pagination-jump-width": `${width}px` } as React.CSSProperties
        }
        className="flex h-9 min-w-(--pagination-jump-width) items-center justify-center gap-1.5 text-sm whitespace-nowrap tabular-nums pointer-coarse:h-11"
      >
        <input
          ref={inputRef}
          type="text"
          inputMode="numeric"
          enterKeyHint="go"
          autoComplete="off"
          placeholder="…"
          aria-label={`Go to page, 1 to ${count}`}
          aria-invalid={invalid || undefined}
          value={value}
          style={{ "--pagination-digits": digits } as React.CSSProperties}
          className="h-full w-[max(--spacing(9),calc(var(--pagination-digits)*1ch+--spacing(4)))] rounded-md bg-background text-center text-sm tabular-nums ring-3 inset-ring-(length:--hairline) ring-focus-ring inset-ring-ring outline-none placeholder:text-muted-foreground focus-visible:outline-hidden aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:animate-in motion-safe:animation-duration-150 motion-safe:fade-in-0 motion-safe:zoom-in-95 data-shake:motion-safe:animate-button-shake dark:bg-input/30 forced-colors:border pointer-coarse:text-[max(16px,1rem)]"
          onChange={(event) => {
            setValue(event.target.value)
            setInvalid(false)
          }}
          onAnimationEnd={(event) => {
            event.currentTarget.removeAttribute("data-shake")
          }}
          onKeyDown={(event) => {
            if (event.key === "Enter") {
              event.preventDefault()
              commit()
            } else if (event.key === "Escape") {
              event.preventDefault()
              event.stopPropagation()
              returnFocus.current = true
              setEditing(false)
              setValue("")
              setInvalid(false)
            }
          }}
          onBlur={() => {
            setEditing(false)
            setValue("")
            setInvalid(false)
          }}
        />
        {suffix ? (
          <span className="text-muted-foreground">{suffix}</span>
        ) : null}
      </span>
    )
  }

  return (
    <button
      ref={triggerRef}
      type="button"
      data-slot="pagination-jump"
      className={cn(
        buttonVariants({ variant: "ghost" }),
        "h-9 min-w-[max(--spacing(9),calc(var(--pagination-digits,1)*1ch+--spacing(4)))] px-0 tabular-nums pointer-coarse:h-11 pointer-coarse:min-w-11",
        className
      )}
      onClick={(event) => {
        setWidth(event.currentTarget.getBoundingClientRect().width)
        setEditing(true)
      }}
      {...props}
    >
      {children}
    </button>
  )
}

type PaginationEllipsisProps = React.ComponentProps<"span"> & {
  count?: number
  onJump?: (page: number) => void
}

function PaginationEllipsis({
  className,
  count,
  onJump,
  ...props
}: PaginationEllipsisProps) {
  if (onJump && count && count > 0) {
    return (
      <PaginationJump
        count={count}
        onJump={onJump}
        aria-label="More pages, go to page"
        className="text-muted-foreground hover:text-foreground"
      >
        <IconDots />
      </PaginationJump>
    )
  }

  return (
    <span
      data-slot="pagination-ellipsis"
      className={cn(
        "flex h-9 min-w-[max(--spacing(9),calc(var(--pagination-digits,1)*1ch+--spacing(4)))] items-center justify-center text-muted-foreground [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      <IconDots aria-hidden="true" />
      <span className="sr-only">More pages</span>
    </span>
  )
}

export {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
  usePagination,
}
export type {
  PaginationEllipsisProps,
  PaginationItemData,
  PaginationLinkProps,
  PaginationProps,
  UsePaginationOptions,
}
```

```tsx title="components/ui/button.tsx"
"use client"

import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Spinner } from "@/components/ui/spinner"
import {
  useButtonFeedback,
  type ButtonFeedbackOptions,
  type ButtonStatus,
} from "@/hooks/use-button-feedback"

const buttonVariants = cva(
  "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/80",
        outline:
          "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
        ghost:
          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0",
        destructive:
          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70",
        link: "text-primary underline-offset-4 hover:underline forced-colors:border-0",
        success:
          "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40",
        error:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake",
      },
      size: {
        default:
          "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5",
        lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        icon: "size-9 shrink-0",
        "icon-xs":
          "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3",
        "icon-sm":
          "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden",
        "icon-lg": "size-10 shrink-0",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

type ButtonVariant = Exclude<
  NonNullable<VariantProps<typeof buttonVariants>["variant"]>,
  "success" | "error"
>

type ButtonClickEvent = Parameters<
  NonNullable<ButtonPrimitive.Props["onClick"]>
>[0]

type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> &
  Omit<VariantProps<typeof buttonVariants>, "variant"> &
  ButtonFeedbackOptions & {
    variant?: ButtonVariant | null
    onClick?: (event: ButtonClickEvent) => unknown
    feedback?: boolean
    loading?: boolean
    status?: ButtonStatus
    loadingLabel?: React.ReactNode
    successLabel?: React.ReactNode
    errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode)
  }

const stateTransitionTime = 220
const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"]
const quietVariants: ButtonVariant[] = ["ghost", "link"]
const quietStatusClasses: Partial<Record<ButtonStatus, string>> = {
  success: "text-success hover:text-success",
  error:
    "text-destructive hover:text-destructive motion-safe:animate-button-shake",
}

function isThenable(value: unknown): value is PromiseLike<unknown> {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as PromiseLike<unknown>).then === "function"
  )
}

function useStatusTransition(status: ButtonStatus) {
  const [shown, setShown] = React.useState(status)
  const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null)
  const [transitionKey, setTransitionKey] = React.useState(0)

  if (status !== shown) {
    setLeaving(shown)
    setShown(status)
    setTransitionKey((key) => key + 1)
  }

  React.useEffect(() => {
    if (leaving === null) {
      return
    }
    const timer = setTimeout(() => setLeaving(null), stateTransitionTime)
    return () => clearTimeout(timer)
  }, [leaving, transitionKey])

  return { leaving, transitionKey }
}

function useLayerWidth(status: ButtonStatus, active: boolean) {
  const contentRef = React.useRef<HTMLSpanElement>(null)
  const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>())
  const observerRef = React.useRef<ResizeObserver | null>(null)
  const statusRef = React.useRef(status)

  const applyWidth = React.useCallback(() => {
    const content = contentRef.current
    const layer = layersRef.current.get(statusRef.current)
    if (!content || !layer) {
      return
    }
    const natural = parseFloat(getComputedStyle(layer).width)
    if (!Number.isFinite(natural)) {
      return
    }
    content.style.width = `${natural}px`
    if (!content.hasAttribute("data-measured")) {
      requestAnimationFrame(() => content.setAttribute("data-measured", ""))
    }
  }, [])

  React.useLayoutEffect(() => {
    statusRef.current = status
    if (active) {
      applyWidth()
    }
  }, [active, applyWidth, status])

  React.useLayoutEffect(() => {
    if (!active || typeof ResizeObserver === "undefined") {
      return
    }
    const observer = new ResizeObserver(applyWidth)
    observerRef.current = observer
    layersRef.current.forEach((layer) => observer.observe(layer))
    document.fonts?.addEventListener?.("loadingdone", applyWidth)
    return () => {
      observer.disconnect()
      observerRef.current = null
      document.fonts?.removeEventListener?.("loadingdone", applyWidth)
    }
  }, [active, applyWidth])

  const layerRef = React.useCallback((node: HTMLElement | null) => {
    if (!node) {
      return
    }
    const layer = node.dataset.layer as ButtonStatus
    layersRef.current.set(layer, node)
    observerRef.current?.observe(node)
    return () => {
      observerRef.current?.unobserve(node)
      if (layersRef.current.get(layer) === node) {
        layersRef.current.delete(layer)
      }
    }
  }, [])

  return { contentRef, layerRef }
}

function composeHandlers<Event>(
  first: ((event: Event) => void) | undefined,
  second: (event: Event) => void
) {
  return (event: Event) => {
    first?.(event)
    second(event)
  }
}

function Button({
  className,
  variant = "default",
  size = "default",
  children,
  onClick,
  disabled,
  focusableWhenDisabled,
  feedback,
  loading,
  status: statusProp,
  onStatusChange,
  onError,
  resetAfter,
  loadingLabel,
  successLabel = "Done",
  errorLabel = "Failed",
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: ButtonProps) {
  const internal = useButtonFeedback({ resetAfter, onStatusChange, onError })
  const status = statusProp ?? (loading ? "loading" : internal.status)
  const requested =
    Boolean(feedback) || loading !== undefined || statusProp !== undefined
  const [enabled, setEnabled] = React.useState(requested)
  if (requested && !enabled) {
    setEnabled(true)
  }

  const { leaving, transitionKey } = useStatusTransition(status)
  const iconOnly = size?.startsWith("icon") ?? false
  const widthStatus =
    status === "loading" && loadingLabel === undefined ? "idle" : status
  const { contentRef, layerRef } = useLayerWidth(
    widthStatus,
    enabled && !iconOnly
  )
  const isLoading = status === "loading"
  const quiet = quietVariants.includes(variant ?? "default")
  const resolvedVariant =
    !quiet && status === "success"
      ? "success"
      : !quiet && status === "error"
        ? "error"
        : variant
  const resolvedErrorLabel =
    typeof errorLabel !== "function"
      ? errorLabel
      : internal.error !== undefined
        ? errorLabel(internal.error)
        : "Failed"

  const handleClick = (event: ButtonClickEvent) => {
    if (feedback && internal.isPending()) {
      return
    }
    const result = onClick?.(event)
    if (feedback && isThenable(result)) {
      internal.track(result)
    }
  }

  const layerContent = (layer: ButtonStatus, animated: boolean) => {
    if (layer === "idle") {
      return children
    }
    if (layer === "loading") {
      return (
        <>
          <Spinner aria-hidden size={null} animated={animated} />
          {iconOnly ? null : loadingLabel}
        </>
      )
    }
    if (layer === "success") {
      return (
        <>
          <IconCircleCheck
            className={cn(
              animated &&
                "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]"
            )}
          />
          {iconOnly ? null : successLabel}
        </>
      )
    }
    return (
      <>
        <IconAlertCircle />
        {iconOnly ? null : resolvedErrorLabel}
      </>
    )
  }

  const button = (
    <ButtonPrimitive
      data-slot="button"
      data-status={enabled ? status : undefined}
      aria-busy={isLoading || undefined}
      disabled={disabled || isLoading}
      focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)}
      className={cn(
        buttonVariants({ variant: resolvedVariant, size }),
        quiet && quietStatusClasses[status],
        className
      )}
      onClick={handleClick}
      onPointerEnter={composeHandlers(
        onPointerEnter,
        internal.buttonProps.onPointerEnter
      )}
      onPointerLeave={composeHandlers(
        onPointerLeave,
        internal.buttonProps.onPointerLeave
      )}
      onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)}
      onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)}
      {...props}
    >
      {enabled ? (
        <span
          ref={contentRef}
          data-slot="button-content"
          className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]"
        >
          {statuses.map((layer) => {
            const active = layer === status
            const exiting = layer === leaving
            const namesButton =
              layer === "idle" && isLoading && loadingLabel === undefined

            return (
              <span
                key={active ? `${layer}-${transitionKey}` : layer}
                ref={layerRef}
                data-slot="button-layer"
                data-layer={layer}
                aria-hidden={active || namesButton ? undefined : true}
                className={cn(
                  "inline-flex items-center justify-center gap-[inherit] backface-hidden",
                  !active &&
                    !exiting &&
                    "pointer-events-none absolute opacity-0",
                  active &&
                    transitionKey > 0 &&
                    "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0",
                  exiting &&
                    "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out"
                )}
              >
                {layerContent(layer, active || exiting)}
              </span>
            )
          })}
        </span>
      ) : (
        children
      )}
    </ButtonPrimitive>
  )

  if (!enabled) {
    return button
  }

  const announcement =
    status === "loading"
      ? typeof loadingLabel === "string"
        ? loadingLabel
        : "Loading"
      : status === "success"
        ? typeof successLabel === "string"
          ? successLabel
          : "Done"
        : status === "error"
          ? typeof resolvedErrorLabel === "string"
            ? resolvedErrorLabel
            : "Failed"
          : ""

  return (
    <>
      {button}
      <span data-slot="button-status" role="status" className="sr-only">
        {announcement}
      </span>
    </>
  )
}

export { Button, buttonVariants, useButtonFeedback }
export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }
```

```ts title="lib/motion.ts"
import * as React from "react"

const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"
const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)"
const easeSpring =
  "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)"

const duration = {
  press: 100,
  release: 200,
  hover: 150,
  enter: 200,
  exit: 150,
  morph: 300,
} as const

function prefersReducedMotion() {
  return (
    typeof window === "undefined" ||
    typeof window.matchMedia !== "function" ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type SizeAxis = "width" | "height"

type SizeMorphOptions = {
  axis: SizeAxis
  enabled?: boolean
  duration?: number
  easing?: string
}

function readSize(element: HTMLElement, axis: SizeAxis) {
  const value = parseFloat(getComputedStyle(element)[axis])
  if (Number.isFinite(value)) {
    return value
  }
  const rect = element.getBoundingClientRect()
  return axis === "width" ? rect.width : rect.height
}

function attachSizeMorph(
  element: HTMLElement,
  axis: SizeAxis,
  time: number,
  easing: string
) {
  if (
    typeof MutationObserver === "undefined" ||
    typeof element.animate !== "function"
  ) {
    return undefined
  }

  let settled = readSize(element, axis)
  let animation: Animation | null = null

  const morph = () => {
    const from = animation ? readSize(element, axis) : settled
    animation?.cancel()
    animation = null
    const to = readSize(element, axis)
    settled = to

    if (
      Math.abs(from - to) < 0.5 ||
      prefersReducedMotion() ||
      !element.isConnected ||
      element.getClientRects().length === 0
    ) {
      element.removeAttribute("data-morphing")
      return
    }

    element.setAttribute("data-morphing", "")
    const running = element.animate(
      [{ [axis]: `${from}px` }, { [axis]: `${to}px` }],
      { duration: time, easing }
    )
    animation = running
    running.onfinish = () => {
      if (animation === running) {
        animation = null
        element.removeAttribute("data-morphing")
        settled = readSize(element, axis)
      }
    }
  }

  const mutations = new MutationObserver(morph)
  mutations.observe(element, {
    childList: true,
    subtree: true,
    characterData: true,
  })

  const resize =
    typeof ResizeObserver === "undefined"
      ? null
      : new ResizeObserver(() => {
          if (!animation) {
            settled = readSize(element, axis)
          }
        })
  resize?.observe(element)

  return () => {
    mutations.disconnect()
    resize?.disconnect()
    animation?.cancel()
    element.removeAttribute("data-morphing")
  }
}

function useSizeMorph<T extends HTMLElement>({
  axis,
  enabled = true,
  duration: time = duration.morph,
  easing = easeOut,
}: SizeMorphOptions): React.RefCallback<T> {
  return React.useCallback(
    (element: T | null) => {
      if (!element || !enabled) {
        return undefined
      }
      return attachSizeMorph(element, axis, time, easing)
    },
    [axis, enabled, time, easing]
  )
}

function useSlidingHighlight(
  barRef: React.RefObject<HTMLElement | null>,
  highlightRef: React.RefObject<HTMLElement | null>,
  selector: string,
  attribute = "data-popup-open"
) {
  React.useLayoutEffect(() => {
    const bar = barRef.current
    const highlight = highlightRef.current
    if (!bar || !highlight) {
      return
    }

    let current: HTMLElement | null = null

    const place = (trigger: HTMLElement, instant: boolean) => {
      if (instant || prefersReducedMotion()) {
        highlight.setAttribute("data-instant", "")
      } else {
        highlight.removeAttribute("data-instant")
      }
      const frame = bar.getBoundingClientRect()
      const box = trigger.getBoundingClientRect()
      const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1
      highlight.style.left = "0px"
      highlight.style.width = `${box.width / scale}px`
      highlight.style.height = `${box.height / scale}px`
      highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)`
    }

    const sync = () => {
      const trigger = bar.querySelector<HTMLElement>(selector)
      if (trigger === current) {
        if (trigger) {
          place(trigger, true)
        }
        return
      }
      const appearing = current === null
      current = trigger
      if (!trigger) {
        highlight.removeAttribute("data-visible")
        return
      }
      place(trigger, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    }

    sync()
    const mutations = new MutationObserver(sync)
    mutations.observe(bar, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [attribute],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(bar)
    const onScroll = () => {
      if (current) {
        place(current, true)
      }
    }
    bar.addEventListener("scroll", onScroll, { capture: true, passive: true })

    return () => {
      mutations.disconnect()
      resize?.disconnect()
      bar.removeEventListener("scroll", onScroll, { capture: true })
    }
  }, [barRef, highlightRef, selector, attribute])
}

export {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
  useSizeMorph,
  useSlidingHighlight,
}
export type { SizeMorphOptions }
```

Update the import paths to match your project setup.

## Usage

```tsx
import { Pagination } from "@/components/ui/pagination"
```

```tsx
const [page, setPage] = React.useState(1)

<Pagination page={page} count={25} onPageChange={setPage} />
```

Give `Pagination` a `count` and it builds the whole bar. It always shows the same number of slots, so Previous, Next and the numbers never shift under your cursor while you click through pages. Leave `count` out to compose the parts yourself.

## Composition

```text
Pagination
└── PaginationContent
    ├── PaginationItem
    │   └── PaginationPrevious
    ├── PaginationItem
    │   └── PaginationLink
    ├── PaginationItem
    │   └── PaginationEllipsis
    └── PaginationItem
        └── PaginationNext
```

## Examples

### Jump to a page

Click an ellipsis to type a page number. Enter jumps, an out-of-range number shakes the field, and Esc puts the ellipsis back. Turn it off with `jump={false}`.

```tsx title="components/examples/pagination/long.tsx"
"use client"

import * as React from "react"

import { Pagination } from "@/components/ui/pagination"

export function PaginationLong() {
  const [page, setPage] = React.useState(480)

  return <Pagination page={page} count={1000} onPageChange={setPage} />
}
```

### Compact

When the bar doesn't fit its container, it switches to Previous, “Page 4 of 24” and Next, and switches back when there's room. The label is a jump field too. Force either layout with `compact`.

```tsx title="components/examples/pagination/compact.tsx"
"use client"

import * as React from "react"

import { Pagination } from "@/components/ui/pagination"

export function PaginationCompact() {
  const [page, setPage] = React.useState(4)

  return (
    <div className="w-full max-w-72 rounded-xl border p-2">
      <Pagination page={page} count={24} onPageChange={setPage} />
    </div>
  )
}
```

### Links

With `getPageHref`, every page is a real link, so it can be opened in a new tab. Plain clicks still call `onPageChange`.

```tsx title="components/examples/pagination/links.tsx"
"use client"

import * as React from "react"

import { Pagination } from "@/components/ui/pagination"

export function PaginationLinks() {
  const [page, setPage] = React.useState(3)

  return (
    <Pagination
      page={page}
      count={12}
      getPageHref={(target) => `#page-${target}`}
      onPageChange={setPage}
    />
  )
}
```

### Composable

Without `count`, build the bar from the parts, as in shadcn/ui. Pass `render` to use your router's link.

```tsx title="components/examples/pagination/composable.tsx"
"use client"

import * as React from "react"

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

const pages = [1, 2, 3]

export function PaginationComposable() {
  const [page, setPage] = React.useState(2)

  return (
    <Pagination>
      <PaginationContent>
        <PaginationItem>
          <PaginationPrevious
            render={<button type="button" />}
            disabled={page === 1}
            onClick={() => setPage(page - 1)}
          />
        </PaginationItem>
        {pages.map((item) => (
          <PaginationItem key={item}>
            <PaginationLink
              render={<button type="button" />}
              isActive={item === page}
              onClick={() => setPage(item)}
            >
              {item}
            </PaginationLink>
          </PaginationItem>
        ))}
        <PaginationItem>
          <PaginationEllipsis />
        </PaginationItem>
        <PaginationItem>
          <PaginationNext
            render={<button type="button" />}
            disabled={page === pages.length}
            onClick={() => setPage(page + 1)}
          />
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  )
}
```

### Right to left

`usePagination` returns the page and ellipsis items for custom layouts. Chevrons flip in right-to-left text.

```tsx title="components/examples/pagination/rtl.tsx"
"use client"

import * as React from "react"

import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
  usePagination,
} from "@/components/ui/pagination"

export function PaginationRtl() {
  const [page, setPage] = React.useState(2)
  const { items, hasPrevious, hasNext } = usePagination({ page, count: 3 })

  return (
    <div dir="rtl">
      <Pagination aria-label="التنقل بين الصفحات">
        <PaginationContent>
          <PaginationItem>
            <PaginationPrevious
              text="السابق"
              aria-label="الصفحة السابقة"
              render={<button type="button" />}
              disabled={!hasPrevious}
              onClick={() => setPage(page - 1)}
            />
          </PaginationItem>
          {items.map((item) =>
            item.type === "page" ? (
              <PaginationItem key={item.page}>
                <PaginationLink
                  render={<button type="button" />}
                  isActive={item.page === page}
                  onClick={() => setPage(item.page)}
                >
                  {item.page.toLocaleString("ar-EG")}
                </PaginationLink>
              </PaginationItem>
            ) : null
          )}
          <PaginationItem>
            <PaginationNext
              text="التالي"
              aria-label="الصفحة التالية"
              render={<button type="button" />}
              disabled={!hasNext}
              onClick={() => setPage(page + 1)}
            />
          </PaginationItem>
        </PaginationContent>
      </Pagination>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves into and out of the bar. Previous and Next stay focusable at the ends. |
| `←` `→` | Moves between Previous, the pages and Next. Flips in right-to-left layouts. |
| `Home` `End` | Moves to the first or last item. |
| `Enter` | Opens the focused page or the page field. In the field, jumps to that page and focuses it. |
| `Esc` | Closes the page field and returns focus to the ellipsis. |

## Accessibility

- The root is a `<nav>` labelled “Pagination”, and the current page has `aria-current="page"`.
- At the first and last page, Previous and Next use `aria-disabled` instead of `disabled`, so focus isn't lost when you reach the end.
- The sliding indicator becomes a fade when reduced motion is on.

## API reference

### Pagination

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `count` | `number` | – | Total pages. When set, the bar is built for you; leave it out to compose the parts. |
| `page` | `number` | – |  |
| `defaultPage` | `number` | `1` |  |
| `onPageChange` | `(page: number) => void` | – |  |
| `getPageHref` | `(page: number) => string` | – | Renders pages as links instead of buttons. |
| `siblings` | `number` | `1` | Pages shown on each side of the current one. |
| `boundaries` | `number` | `1` | Pages always shown at the start and end. |
| `compact` | `boolean \| "auto"` | `"auto"` | auto switches to Previous, “Page x of y” and Next when the bar doesn't fit. |
| `jump` | `boolean` | `true` | Lets the ellipsis open a page field. |

| Attribute | Description |
| --- | --- |
| `data-slot="pagination"` | The nav. |
| `data-slot="pagination-indicator"` | The background that glides to the current page. |

### PaginationLink

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `isActive` | `boolean` | `false` | Marks the current page with aria-current="page". |
| `disabled` | `boolean` | `false` | Sets aria-disabled and ignores clicks. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<a>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="pagination-link"` | Target page links in CSS. |
| `data-active` | Present on the current page. |

### PaginationPrevious / PaginationNext

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `ReactNode` | `"Previous" / "Next"` | Pass null for an icon-only button. |
| `disabled` | `boolean` | `false` |  |

### PaginationEllipsis

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onJump` | `(page: number) => void` | – | Makes the ellipsis a page field. Without it, the ellipsis is decorative. |
| `count` | `number` | – | The highest page the field accepts. |

### usePagination

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `page` | `number` | – |  |
| `count` | `number` | – |  |
| `siblings` | `number` | `1` |  |
| `boundaries` | `number` | `1` |  |

Returns `items`, the clamped `page` and `count`, and `hasPrevious` and `hasNext`. See the [usePagination guide](https://hextaui.com/docs/use-pagination) for building your own pager.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
