Presentational pagination landmark with content, item, link (button-styled anchors with active page marking), previous, next and ellipsis builders.

Preview
Style
Basic
Simple
Icons Only
Rows per page: 25
src/demo/views/pagination.ts98 lines
// Sections mirror apps/v4/examples/base/pagination-{demo,simple,icons-only}.tsx.
// Pagination is fully presentational — no state, no messages; the slice is
// empty but keeps the harness contract (fields/init/messages/handlers).

import type { Html, HtmlBuilder } from 'foldkit/html'

import { Pagination } from '../../generated/registry/ui/pagination'

import { defineSlice } from '../slice'
import type { Message, Model } from '../assemble'

const section = (h: HtmlBuilder<Message>, label: string, content: Html): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-2')],
    [h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], [label]), content],
  )

export const paginationView = (_model: Model, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      section(
        h,
        'Basic',
        Pagination(
          {},
          [
            Pagination.content(
              {},
              [
                Pagination.item({}, [Pagination.previous({}, [], h)], h),
                Pagination.item({}, [Pagination.link({}, ['1'], h)], h),
                Pagination.item({}, [Pagination.link({ isActive: true }, ['2'], h)], h),
                Pagination.item({}, [Pagination.link({}, ['3'], h)], h),
                Pagination.item({}, [Pagination.ellipsis({}, [], h)], h),
                Pagination.item({}, [Pagination.next({}, [], h)], h),
              ],
              h,
            ),
          ],
          h,
        ),
      ),
      section(
        h,
        'Simple',
        Pagination(
          {},
          [
            Pagination.content(
              {},
              [
                Pagination.item({}, [Pagination.link({}, ['1'], h)], h),
                Pagination.item({}, [Pagination.link({ isActive: true }, ['2'], h)], h),
                Pagination.item({}, [Pagination.link({}, ['3'], h)], h),
                Pagination.item({}, [Pagination.link({}, ['4'], h)], h),
                Pagination.item({}, [Pagination.link({}, ['5'], h)], h),
              ],
              h,
            ),
          ],
          h,
        ),
      ),
      section(
        h,
        'Icons Only',
        h.div(
          [h.Class('flex items-center justify-between gap-4')],
          [
            h.div([h.Class('text-sm text-muted-foreground')], ['Rows per page: 25']),
            Pagination(
              { className: 'mx-0 w-auto' },
              [
                Pagination.content(
                  {},
                  [
                    Pagination.item({}, [Pagination.previous({}, [], h)], h),
                    Pagination.item({}, [Pagination.next({}, [], h)], h),
                  ],
                  h,
                ),
              ],
              h,
            ),
          ],
        ),
      ),
    ],
  )

export const slice = defineSlice({
  fields: {},
  init: {},
  messages: [],
  handlers: () => ({}),
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/pagination

Source

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,
})