Skip to content

Pagination

Numbered page buttons with previous/next controls, plus a divider-style load-more row.

Numbered page buttons with previous/next controls, plus a divider-style load-more row. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

Root class newt-pagination
"use client"

import * as React from "react"

import { Button } from "@/registry/bases/newt/ui/button"
import {
  LoadMore,
  Pagination,
  PaginationButton,
} from "@/registry/bases/newt/ui/pagination"

const PAGES = [1, 2, 3]

export default function PaginationDemo() {
  const [page, setPage] = React.useState(1)

  return (
    <div className="flex w-full max-w-sm flex-col gap-6">
      <Pagination>
        <PaginationButton
          aria-label="Previous page"
          disabled={page === 1}
          onClick={() => setPage((p) => Math.max(1, p - 1))}
        >
          &lsaquo;
        </PaginationButton>
        {PAGES.map((n) => (
          <PaginationButton
            key={n}
            active={page === n}
            onClick={() => setPage(n)}
          >
            {n}
          </PaginationButton>
        ))}
        <PaginationButton
          aria-label="Next page"
          disabled={page === PAGES.length}
          onClick={() => setPage((p) => Math.min(PAGES.length, p + 1))}
        >
          &rsaquo;
        </PaginationButton>
      </Pagination>

      <LoadMore>
        <Button variant="secondary" size="sm">
          Load more
        </Button>
      </LoadMore>
    </div>
  )
}
<script setup lang="ts">
import { ref } from "vue"
import { Button } from "~~/registry/bases/newt/ui/button"
import {
  LoadMore,
  Pagination,
  PaginationButton,
} from "~~/registry/bases/newt/ui/pagination"

const pages = [1, 2, 3]
const page = ref(1)
</script>

<template>
  <div class="flex w-full max-w-sm flex-col gap-6">
    <Pagination>
      <PaginationButton
        aria-label="Previous page"
        :disabled="page === 1"
        @click="page = Math.max(1, page - 1)"
      >
        &lsaquo;
      </PaginationButton>
      <PaginationButton
        v-for="n in pages"
        :key="n"
        :active="page === n"
        @click="page = n"
      >
        {{ n }}
      </PaginationButton>
      <PaginationButton
        aria-label="Next page"
        :disabled="page === pages.length"
        @click="page = Math.min(pages.length, page + 1)"
      >
        &rsaquo;
      </PaginationButton>
    </Pagination>

    <LoadMore>
      <Button variant="secondary" size="sm">Load more</Button>
    </LoadMore>
  </div>
</template>

Installation

pnpm dlx newtui@latest add pagination
npx newtui@latest add pagination
yarn dlx newtui@latest add pagination
bunx newtui@latest add pagination

Install the dependencies

pnpm dlx --yes class-variance-authority
npx --yes class-variance-authority
yarn dlx --yes class-variance-authority
bunx --yes class-variance-authority

Add the components it builds on

Copy the source into your project

import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"

import { cn } from "@/lib/utils"

const Pagination = React.forwardRef<HTMLElement, React.ComponentProps<"nav">>(
  ({ className, ...props }, ref) => (
    <nav
      ref={ref}
      role="navigation"
      aria-label="Pagination"
      className={cn("flex items-center gap-1", className)}
      {...props}
    />
  )
)
Pagination.displayName = "Pagination"

const paginationButtonVariants = cva(
  "flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-sm border px-2 text-[13px] font-medium disabled:cursor-not-allowed disabled:opacity-40",
  {
    variants: {
      active: {
        false:
          "border-newt-border bg-newt-bg-elevated text-newt-text-secondary hover:bg-newt-bg-hover hover:text-newt-text-primary",
        true: "border-newt-brand bg-newt-brand text-white",
      },
    },
    defaultVariants: {
      active: false,
    },
  }
)

export interface PaginationButtonProps
  extends
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof paginationButtonVariants> {}

const PaginationButton = React.forwardRef<
  HTMLButtonElement,
  PaginationButtonProps
>(({ className, active, ...props }, ref) => (
  <button
    ref={ref}
    type="button"
    aria-current={active ? "page" : undefined}
    data-active={active ? "" : undefined}
    className={cn(paginationButtonVariants({ active }), className)}
    {...props}
  />
))
PaginationButton.displayName = "PaginationButton"

const PaginationEllipsis = React.forwardRef<
  HTMLSpanElement,
  React.ComponentProps<"span">
>(({ className, children, ...props }, ref) => (
  <span
    ref={ref}
    aria-hidden="true"
    className={cn("px-1 text-[13px] text-newt-text-muted", className)}
    {...props}
  >
    {children ?? "…"}
  </span>
))
PaginationEllipsis.displayName = "PaginationEllipsis"

/** A "load more" row: centred content flanked by hairline rules. */
const LoadMore = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn(
        "flex items-center justify-center gap-2.5 p-3 text-[13px] text-newt-text-muted before:h-px before:flex-1 before:bg-newt-border before:content-[''] after:h-px after:flex-1 after:bg-newt-border after:content-['']",
        className
      )}
      {...props}
    />
  )
)
LoadMore.displayName = "LoadMore"

export {
  Pagination,
  PaginationButton,
  PaginationEllipsis,
  LoadMore,
  paginationButtonVariants,
}
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{
  class?: HTMLAttributes["class"]
}>()
</script>

<template>
  <nav
    role="navigation"
    aria-label="Pagination"
    :class="cn('flex items-center gap-1', props.class)"
  >
    <slot />
  </nav>
</template>
<script setup lang="ts">
import type { ButtonHTMLAttributes, HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { paginationButtonVariants } from "."

const props = withDefaults(
  defineProps<{
    active?: boolean
    disabled?: ButtonHTMLAttributes["disabled"]
    class?: HTMLAttributes["class"]
  }>(),
  { active: false }
)
</script>

<template>
  <button
    type="button"
    :disabled="props.disabled"
    :aria-current="props.active ? 'page' : undefined"
    :data-active="props.active ? '' : undefined"
    :class="cn(paginationButtonVariants({ active: props.active }), props.class)"
  >
    <slot />
  </button>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{
  class?: HTMLAttributes["class"]
}>()
</script>

<template>
  <span
    aria-hidden="true"
    :class="cn('px-1 text-[13px] text-newt-text-muted', props.class)"
  >
    <slot>&hellip;</slot>
  </span>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{
  class?: HTMLAttributes["class"]
}>()
</script>

<template>
  <div
    :class="
      cn(
        'flex items-center justify-center gap-2.5 p-3 text-[13px] text-newt-text-muted before:h-px before:flex-1 before:bg-newt-border before:content-[\'\'] after:h-px after:flex-1 after:bg-newt-border after:content-[\'\']',
        props.class
      )
    "
  >
    <slot />
  </div>
</template>
import { cva, type VariantProps } from "class-variance-authority"

export { default as Pagination } from "./Pagination.vue"
export { default as PaginationButton } from "./PaginationButton.vue"
export { default as PaginationEllipsis } from "./PaginationEllipsis.vue"
export { default as LoadMore } from "./LoadMore.vue"

export const paginationButtonVariants = cva(
  "flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-sm border px-2 text-[13px] font-medium disabled:cursor-not-allowed disabled:opacity-40",
  {
    variants: {
      active: {
        false:
          "border-newt-border bg-newt-bg-elevated text-newt-text-secondary hover:bg-newt-bg-hover hover:text-newt-text-primary",
        true: "border-newt-brand bg-newt-brand text-white",
      },
    },
    defaultVariants: {
      active: false,
    },
  }
)

export type PaginationButtonVariants = VariantProps<
  typeof paginationButtonVariants
>

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

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

export function Example() {
  return <Pagination />
}
<script setup lang="ts">
import { Pagination, PaginationButton, PaginationEllipsis, LoadMore } from "@/components/ui/pagination"
</script>

<template>
  <Pagination />
</template>

API reference

Pagination

PropType
children / default slot?React.ReactNode / Vue slot

Content rendered inside the component.

TypeReact.ReactNode / Vue slot
className / class?string

Additional classes merged with the component defaults.

Typestring

The registry exports Pagination, PaginationButton, PaginationEllipsis, LoadMore. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.

Accessibility

Label the navigation region and mark the current page with aria-current="page". Previous and next controls need descriptive accessible names.

Tokens

Pagination reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-pagination.