组件
分页 Pagination
用于在海量数据或长列表中实现按页加载与分段导航,清晰传达当前页码、总页数及折叠区间。
基础用法
标准的分页导航结构。当前页的高亮底块会以弹簧动效在页码之间滑动,首末页时上一页 / 下一页自动置灰:
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)"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-label | string | "分页导航" | 用于屏幕阅读器识别当前导航区域的可访问地标名称。 |
| className | string | — | 应用于分页根容器的额外 CSS 类名。 |
PaginationLink
继承原生 <a> 元素的全部 HTML 属性,并结合 Button 的样式变体:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| isActive | boolean | false | 当前页码是否处于选中激活态。开启后自动挂载 aria-current="page" 并禁用重复点击;同一 PaginationContent 内的激活底块会在页码间滑动过渡。 |
| disabled | boolean | false | 禁用链接:自动添加 aria-disabled、移出 Tab 序列、阻止点击并置灰。常用于首页的上一页与末页的下一页。 |
| size | "default" | "sm" | "lg" | "icon" | "icon" | 分页按钮的尺寸密度。 |
| asChild | boolean | false | 启用后将样式合并到唯独的子组件(如 Next.js `<Link>`)。 |
| href | string | — | 页面跳转的目标 URL 地址(服务端渲染或支持新标签页打开)。 |
PaginationPrevious / PaginationNext
上一页与下一页快捷按钮,继承 PaginationLink 的所有属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 自定义按钮文本,在小屏下内置响应式隐藏文字只显示箭头图标。 |
| disabled | boolean | false | 到达边界(如第 1 页或最后一页)时禁用按钮。仍兼容直接传入 `aria-disabled`,组件会自动应用置灰样式。 |
PaginationItem
列表项容器,继承原生 <li> 属性。页码窗口平移时,保留下来的项目会平滑滑动到新位置,新出现的项目淡入;建议以页码值作为 key,让动画能够正确追踪同一页码。
PaginationCounter
紧凑的「当前页 / 总页数」读数,页码变化时数字按翻页方向上下滚动:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| page | number | — | 当前页码,从 1 开始。 |
| total | number | — | 总页数。 |
| className | string | — | 应用于读数容器的额外 CSS 类名。 |
PaginationEllipsis
用于表示中间被折叠的连续页码区间:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于省略号图标容器的额外 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 激活。