wui
组件

分页 Pagination

用于在海量数据或长列表中实现按页加载与分段导航,清晰传达当前页码、总页数及折叠区间。

第三方依赖 · lucide-react第三方依赖 · radix-ui第三方依赖 · motion

基础用法

标准的分页导航结构。当前页的高亮底块会以弹簧动效在页码之间滑动,首末页时上一页 / 下一页自动置灰:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/pagination
安装基础依赖与图标库
pnpm add radix-ui motion lucide-react clsx tailwind-merge
复制组件源码到 components/ui/pagination.tsx(依赖 Button 的 buttonVariants)
components/ui/pagination.tsx
"use client"

import * as React from "react"
import {
  ChevronLeftIcon,
  ChevronRightIcon,
  MoreHorizontalIcon,
} from "lucide-react"
import { Slot } from "radix-ui"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"

const INDICATOR_SPRING = {
  type: "spring",
  stiffness: 520,
  damping: 38,
  mass: 0.7,
} as const

const EASE_OUT = [0.22, 1, 0.36, 1] as const

type PaginationContextValue = {
  /** Shared-layout id for the sliding active-page indicator. */
  layoutId: string
  /** Items mounted after the first paint animate in; the initial set does not. */
  ready: boolean
}

const PaginationContext = React.createContext<PaginationContextValue | null>(
  null
)

/** 分页导航容器。 */
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
  return (
    <nav
      aria-label="分页导航"
      data-slot="pagination"
      className={cn("mx-auto flex w-full justify-center", className)}
      {...props}
    />
  )
}

/** 分页项目列表,同时为当前页指示器提供独立的共享布局作用域。 */
function PaginationContent({
  className,
  ...props
}: React.ComponentProps<"ul">) {
  const layoutId = React.useId()
  const [ready, setReady] = React.useState(false)

  React.useEffect(() => setReady(true), [])

  return (
    <PaginationContext.Provider value={{ layoutId, ready }}>
      <ul
        data-slot="pagination-content"
        className={cn("flex flex-row items-center gap-1", className)}
        {...props}
      />
    </PaginationContext.Provider>
  )
}

/**
 * 单个分页项目。页码窗口滚动(如 4 5 [6] 7 8 → 5 6 [7] 8 9)时,
 * 保留下来的项目会平滑滑到新位置,新加入的项目淡入。
 */
function PaginationItem({
  className,
  ...props
}: React.ComponentProps<"li">) {
  const context = React.useContext(PaginationContext)
  const reduceMotion = useReducedMotion()
  const animateIn = Boolean(context?.ready) && !reduceMotion

  return (
    <motion.li
      data-slot="pagination-item"
      layout={reduceMotion ? false : "position"}
      initial={animateIn ? { opacity: 0, scale: 0.85 } : false}
      animate={{ opacity: 1, scale: 1 }}
      transition={{
        layout: INDICATOR_SPRING,
        default: { duration: 0.2, ease: EASE_OUT },
      }}
      className={className}
      {...(props as React.ComponentProps<typeof motion.li>)}
    />
  )
}

export interface PaginationLinkProps extends React.ComponentProps<"a"> {
  /** 当前链接是否对应正在查看的页面。 */
  isActive?: boolean
  /** 分页按钮尺寸。 */
  size?: "default" | "sm" | "lg" | "icon"
  /** 将样式与属性合并到唯一子元素,适合组合路由链接。 */
  asChild?: boolean
  /** 禁用链接:移出 Tab 序列、阻止点击并标记 `aria-disabled`。 */
  disabled?: boolean
}

/** 页码链接,激活时自动添加当前页语义,并由滑动指示器标出当前页。 */
function PaginationLink({
  className,
  isActive,
  size = "icon",
  asChild = false,
  disabled = false,
  children,
  onClick,
  ...props
}: PaginationLinkProps) {
  const context = React.useContext(PaginationContext)
  const reduceMotion = useReducedMotion()
  const Comp = asChild ? Slot.Root : "a"

  return (
    <Comp
      aria-current={isActive ? "page" : undefined}
      aria-disabled={disabled || undefined}
      tabIndex={disabled ? -1 : undefined}
      data-slot="pagination-link"
      data-active={isActive || undefined}
      className={cn(
        buttonVariants({ variant: "ghost", size }),
        // The indicator travels outside the link box while it slides between
        // pages, so this link must not clip it.
        "isolate overflow-visible tabular-nums aria-disabled:pointer-events-none aria-disabled:opacity-50",
        isActive && "pointer-events-none text-foreground",
        className
      )}
      onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
        if (disabled) {
          event.preventDefault()
          return
        }
        onClick?.(event)
      }}
      {...props}
    >
      {isActive ? (
        <motion.span
          aria-hidden
          data-slot="pagination-indicator"
          layoutId={context ? `${context.layoutId}-indicator` : undefined}
          className="absolute inset-0 -z-10 rounded-md border bg-background shadow-xs dark:border-input dark:bg-input/30"
          transition={reduceMotion ? { duration: 0 } : INDICATOR_SPRING}
        />
      ) : null}
      <Slot.Slottable>{children}</Slot.Slottable>
    </Comp>
  )
}

/** 上一页链接。 */
function PaginationPrevious({
  className,
  children,
  ...props
}: React.ComponentProps<typeof PaginationLink>) {
  return (
    <PaginationLink
      aria-label="前往上一页"
      size="default"
      className={cn("gap-1 px-2.5 sm:pr-3", className)}
      {...props}
    >
      <ChevronLeftIcon />
      {children ?? <span className="hidden sm:inline">上一页</span>}
    </PaginationLink>
  )
}

/** 下一页链接。 */
function PaginationNext({
  className,
  children,
  ...props
}: React.ComponentProps<typeof PaginationLink>) {
  return (
    <PaginationLink
      aria-label="前往下一页"
      size="default"
      className={cn("gap-1 px-2.5 sm:pl-3", className)}
      {...props}
    >
      {children ?? <span className="hidden sm:inline">下一页</span>}
      <ChevronRightIcon />
    </PaginationLink>
  )
}

/** 表示一段页码被折叠。 */
function PaginationEllipsis({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="pagination-ellipsis"
      className={cn(
        "text-muted-foreground flex size-9 items-center justify-center",
        className
      )}
      {...props}
    >
      <MoreHorizontalIcon aria-hidden="true" className="size-4" />
      <span className="sr-only">更多页面</span>
    </span>
  )
}

export interface PaginationCounterProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** 当前页码,从 1 开始。 */
  page: number
  /** 总页数。 */
  total: number
}

/**
 * 紧凑的「当前页 / 总页数」读数。页码变化时数字按翻页方向滚动,
 * 适合移动端或空间受限的简洁分页。
 */
function PaginationCounter({
  className,
  page,
  total,
  ...props
}: PaginationCounterProps) {
  const reduceMotion = useReducedMotion()
  const [previous, setPrevious] = React.useState(page)
  const [direction, setDirection] = React.useState(1)

  if (previous !== page) {
    setDirection(page > previous ? 1 : -1)
    setPrevious(page)
  }

  return (
    <span
      data-slot="pagination-counter"
      aria-live="polite"
      className={cn(
        "text-muted-foreground inline-flex h-9 items-center gap-1 px-2 text-sm tabular-nums",
        className
      )}
      {...props}
    >
      <span className="sr-only">
        第 {page} 页,共 {total} 页
      </span>
      <span
        aria-hidden="true"
        className="text-foreground relative inline-flex h-5 min-w-[1ch] items-center justify-end overflow-hidden font-medium"
      >
        <AnimatePresence mode="popLayout" initial={false} custom={direction}>
          <motion.span
            key={page}
            custom={direction}
            variants={{
              enter: (dir: number) => ({ y: reduceMotion ? 0 : dir * 14, opacity: 0 }),
              center: { y: 0, opacity: 1 },
              exit: (dir: number) => ({ y: reduceMotion ? 0 : dir * -14, opacity: 0 }),
            }}
            initial="enter"
            animate="center"
            exit="exit"
            transition={
              reduceMotion ? { duration: 0 } : { duration: 0.24, ease: EASE_OUT }
            }
            className="block"
          >
            {page}
          </motion.span>
        </AnimatePresence>
      </span>
      <span aria-hidden="true">/</span>
      <span aria-hidden="true">{total}</span>
    </span>
  )
}

export {
  Pagination,
  PaginationContent,
  PaginationCounter,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
}

属性 Props

Pagination (根组件)

继承原生 <nav> 元素的全部 HTML 属性:

属性类型默认值说明
aria-labelstring"分页导航"用于屏幕阅读器识别当前导航区域的可访问地标名称。
classNamestring—应用于分页根容器的额外 CSS 类名。

继承原生 <a> 元素的全部 HTML 属性,并结合 Button 的样式变体:

属性类型默认值说明
isActivebooleanfalse当前页码是否处于选中激活态。开启后自动挂载 aria-current="page" 并禁用重复点击;同一 PaginationContent 内的激活底块会在页码间滑动过渡。
disabledbooleanfalse禁用链接:自动添加 aria-disabled、移出 Tab 序列、阻止点击并置灰。常用于首页的上一页与末页的下一页。
size"default" | "sm" | "lg" | "icon""icon"分页按钮的尺寸密度。
asChildbooleanfalse启用后将样式合并到唯独的子组件(如 Next.js `<Link>`)。
hrefstring—页面跳转的目标 URL 地址(服务端渲染或支持新标签页打开)。

PaginationPrevious / PaginationNext

上一页与下一页快捷按钮,继承 PaginationLink 的所有属性:

属性类型默认值说明
childrenReact.ReactNode—自定义按钮文本,在小屏下内置响应式隐藏文字只显示箭头图标。
disabledbooleanfalse到达边界(如第 1 页或最后一页)时禁用按钮。仍兼容直接传入 `aria-disabled`,组件会自动应用置灰样式。

PaginationItem

列表项容器,继承原生 <li> 属性。页码窗口平移时,保留下来的项目会平滑滑动到新位置,新出现的项目淡入;建议以页码值作为 key,让动画能够正确追踪同一页码。

PaginationCounter

紧凑的「当前页 / 总页数」读数,页码变化时数字按翻页方向上下滚动:

属性类型默认值说明
pagenumber—当前页码,从 1 开始。
totalnumber—总页数。
classNamestring—应用于读数容器的额外 CSS 类名。

PaginationEllipsis

用于表示中间被折叠的连续页码区间:

属性类型默认值说明
classNamestring—应用于省略号图标容器的额外 CSS 类名。

事件 Events

属性类型默认值说明
onClick(event: React.MouseEvent<HTMLAnchorElement>) => void—用户点击页码或翻页按钮时触发。若用于客户端受控状态,需调用 event.preventDefault() 阻止默认跳转。
onKeyDown(event: React.KeyboardEvent<HTMLAnchorElement>) => void—在页码按钮获得焦点并按下键盘按键时触发。

使用场景与设计规范

Pagination 适用于数据量明确、用户需要精准跳页或在长列表中定位特定批次数据的场景:

  • URL 驱动优先:若页面支持 SSR 或需要保留搜索条件与页码在 URL query 中(例如 /users?page=3&size=20),优先使用真实的 href 链接,方便用户分享链接或前进后退。
  • 无限滚动 vs 分页:
    • 分页(Pagination):适合后台表格、交易明细、搜索结果等需要强定位与对比的场景。
    • 无限滚动(Infinite Scroll):适合社交动态 Feed、即时消息流等娱乐与探索类内容。
  • 边界禁用处理:处于第 1 页时为上一页传入 disabled(自动添加 aria-disabled 与 tabIndex={-1});处于末页时下一页同理。
  • 大页码智能折叠:当总页数超过 7 页时,应动态保留首尾页与当前页周围的页码,中间插入 PaginationEllipsis。

场景示例

完整业务表格分页条

结合数据总量统计、每页条数下拉选择框(10 / 20 / 50 条)、动态双侧省略号与快速跳页输入框:

Loading…

页码窗口平移

总页数较多时只保留首尾页与当前页两侧的页码。翻页时页码窗口整体平移,以页码为 key 的项目会滑到新位置,当前页指示器跟随目标页码:

Loading…

尺寸预设

提供 sm(紧凑)、default(标准)与 lg(触控友好)三种尺寸变体:

Loading…

简洁分页

移动端或卡片底部空间有限时,用 PaginationCounter 替代完整页码列表,数字随翻页方向滚动:

Loading…

无障碍与交互 Accessibility

  • 地标标识:根组件输出 <nav aria-label="分页导航">,读屏软件能快速定位整个分页区块。
  • 当前页播报:激活的 PaginationLink 自动挂载 aria-current="page",读屏器会自动播报“当前页,第 X 页”。
  • 折叠项播报:PaginationEllipsis 的图标对读屏隐藏,仅保留 <span className="sr-only">更多页面</span> 供辅助技术识别。
  • 动效减弱:系统开启「减弱动态效果」时,指示器滑动、页码平移与数字滚动全部改为即时切换。
  • 键盘遍历支持:所有页码项均支持标准的 Tab 聚焦与 Enter 激活。