Pagination
Presentational pagination landmark with content, item, link (button-styled anchors with active page marking), previous, next and ellipsis builders.
Installation
Add this component to your project:
pnpm dlx shadcn@latest add @foldcn/paginationSource
The component ships as plain source — no build step, no wrapper. Copy it and make it yours.
registry/default/ui/pagination.ts199 lines// Pagination is a pure presentational landmark (a `nav`), mirroring the
// upstream base registry: `Pagination` is the container and sub-builders are
// attached as properties: Pagination.content, Pagination.item, Pagination.link,
// Pagination.previous, Pagination.next, Pagination.ellipsis.
//
// PaginationLink reproduces upstream's anchor-styled-as-button pattern
// (Button with `render={<a>}` / `nativeButton={false}`): it emits the button
// token classes on an `a` element directly, with `aria-current="page"` and
// `data-active` marking the active page.
import type { Attribute, Html, HtmlBuilder } from 'foldkit/html'
import { cn } from '@/lib/utils'
import { icon } from '@/lib/icons'
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide'
import { buttonSizes, buttonVariants, type ButtonSize } from './button'
type Child = Html | string
export const paginationClass = 'mx-auto flex w-full justify-center'
export const paginationContentClass = 'gap-0.5 flex items-center'
export const paginationLinkClass = ''
export const paginationPreviousClass = 'pl-1.5!'
export const paginationNextClass = 'pr-1.5!'
export const paginationEllipsisClass = 'size-8 [&_svg:not([class*=\'size-\'])]:size-4 flex items-center justify-center'
type StyleConfig = Readonly<{ className?: string }>
export type PaginationLinkConfig<M> = Readonly<{
className?: string
/** Marks the link as the current page: outline variant, `aria-current="page"`
* and `data-active="true"`. */
isActive?: boolean
/** Button size tokens; defaults to `icon` like upstream. */
size?: ButtonSize
href?: string
/** Extra attributes merged onto the anchor (ids, click handlers, …). */
attributes?: ReadonlyArray<Attribute<M>>
}>
const paginationContainer = <M>(
config: StyleConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html =>
h.nav(
[
h.Role('navigation'),
h.AriaLabel('pagination'),
h.Class(cn(paginationClass, config.className)),
h.DataAttribute('slot', 'pagination'),
],
children,
)
const paginationContent = <M>(
config: StyleConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html =>
h.ul(
[
h.Class(cn(paginationContentClass, config.className)),
h.DataAttribute('slot', 'pagination-content'),
],
children,
)
const paginationItem = <M>(
config: StyleConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html => h.li([h.DataAttribute('slot', 'pagination-item')], children)
const paginationLink = <M>(
config: PaginationLinkConfig<M>,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html => {
const variant = config.isActive === true ? 'outline' : 'ghost'
return h.a(
[
...(config.isActive === true ? [h.AriaCurrent('page')] : []),
h.Href(config.href ?? '#'),
h.DataAttribute('slot', 'pagination-link'),
h.DataAttribute('active', config.isActive === true ? 'true' : 'false'),
h.Class(
cn(
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=\'size-\'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50',
buttonVariants[variant],
buttonSizes[config.size ?? 'icon'],
paginationLinkClass,
config.className,
),
),
...(config.attributes ?? []),
],
children,
)
}
const paginationPrevious = <M>(
config: PaginationLinkConfig<M> & Readonly<{ text?: string }>,
children: ReadonlyArray<Child> = [],
h: HtmlBuilder<M>,
): Html =>
h.a(
[
h.AriaLabel('Go to previous page'),
h.Href(config.href ?? '#'),
h.DataAttribute('slot', 'pagination-link'),
h.DataAttribute('active', config.isActive === true ? 'true' : 'false'),
h.Class(
cn(
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=\'size-\'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50',
buttonVariants[config.isActive === true ? 'outline' : 'ghost'],
buttonSizes[config.size ?? 'default'],
paginationPreviousClass,
config.className,
),
),
...(config.attributes ?? []),
],
children.length > 0
? children
: [
icon(h, ChevronLeft, 'rtl:rotate-180', 'inline-start'),
h.span(
[h.Class('hidden sm:block')],
[config.text ?? 'Previous'],
),
],
)
const paginationNext = <M>(
config: PaginationLinkConfig<M> & Readonly<{ text?: string }>,
children: ReadonlyArray<Child> = [],
h: HtmlBuilder<M>,
): Html =>
h.a(
[
h.AriaLabel('Go to next page'),
h.Href(config.href ?? '#'),
h.DataAttribute('slot', 'pagination-link'),
h.DataAttribute('active', config.isActive === true ? 'true' : 'false'),
h.Class(
cn(
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=\'size-\'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50',
buttonVariants[config.isActive === true ? 'outline' : 'ghost'],
buttonSizes[config.size ?? 'default'],
paginationNextClass,
config.className,
),
),
...(config.attributes ?? []),
],
children.length > 0
? children
: [
h.span([h.Class('hidden sm:block')], [config.text ?? 'Next']),
icon(h, ChevronRight, 'rtl:rotate-180', 'inline-end'),
],
)
/** Ellipsis — collapsed middle pages. */
const paginationEllipsis = <M>(
config: StyleConfig,
children: ReadonlyArray<Child> = [],
h: HtmlBuilder<M>,
): Html =>
h.span(
[
h.AriaHidden(true),
h.Class(cn(paginationEllipsisClass, config.className)),
h.DataAttribute('slot', 'pagination-ellipsis'),
],
children.length > 0
? children
: [icon(h, MoreHorizontal), h.span([h.Class('sr-only')], ['More pages'])],
)
/** Composable pagination — `Pagination` is the container, with sub-builders
* as properties: `Pagination.content`, `Pagination.item`, `Pagination.link`,
* `Pagination.previous`, `Pagination.next`, `Pagination.ellipsis`. */
export const Pagination = Object.assign(paginationContainer, {
content: paginationContent,
item: paginationItem,
link: paginationLink,
previous: paginationPrevious,
next: paginationNext,
ellipsis: paginationEllipsis,
})