Skip to content

Pagination

A list of buttons or links to navigate through pages.

Demo

This requires the following components to be installed:

This requires the following theme to be installed:

Component

Pagination.vue
vue
<script lang="ts">
import type { ButtonProps } from '@/ui/components/Button.vue'
import type { PaginationRootEmits, PaginationRootProps } from 'reka-ui'
import Button from '@/ui/components/Button.vue'
import { chevronDoubleLeftIcon, chevronDoubleRightIcon, chevronLeftIcon, chevronRightIcon, ellipsisIcon } from '@/ui/icons'
import theme from '@/ui/theme/pagination'
import { reactivePick } from '@vueuse/core'
import { PaginationEllipsis, PaginationFirst, PaginationLast, PaginationList, PaginationListItem, PaginationNext, PaginationPrev, PaginationRoot, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'

const pagination = tv(theme)

export interface PaginationProps extends Partial<Pick<PaginationRootProps, 'defaultPage' | 'disabled' | 'itemsPerPage' | 'page' | 'showEdges' | 'siblingCount' | 'total'>> {
  as?: any
  color?: ButtonProps['color']
  variant?: ButtonProps['variant']
  activeColor?: ButtonProps['color']
  activeVariant?: ButtonProps['variant']
  showControls?: boolean
  size?: ButtonProps['size']
  class?: any
  ui?: Partial<typeof pagination.slots>
}

export interface PaginationEmits extends PaginationRootEmits {}

export interface PaginationSlots {
  first: (props?: object) => any
  prev: (props?: object) => any
  next: (props?: object) => any
  last: (props?: object) => any
  ellipsis: (props?: object) => any
  item: (props: {
    page: number
    pageCount: number
    item: {
      type: 'ellipsis'
    } | {
      type: 'page'
      value: number
    }
    index: number
  }) => any
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<PaginationProps>(), {
  size: 'md',
  color: 'neutral',
  variant: 'outline',
  activeColor: 'primary',
  activeVariant: 'solid',
  showControls: true,
  showEdges: false,
  itemsPerPage: 10,
  siblingCount: 2,
  total: 0,
})
const emits = defineEmits<PaginationEmits>()
const slots = defineSlots<PaginationSlots>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultPage', 'disabled', 'itemsPerPage', 'page', 'showEdges', 'siblingCount', 'total'), emits)

const ui = pagination()
</script>

<template>
  <PaginationRoot v-slot="{ page, pageCount }" v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <PaginationList v-slot="{ items }" :class="ui.list({ class: props.ui?.list })">
      <PaginationFirst v-if="showControls || !!slots.first" as-child>
        <slot name="first">
          <Button :color="color" :variant="variant" :size="size" :icon="chevronDoubleLeftIcon" />
        </slot>
      </PaginationFirst>
      <PaginationPrev v-if="showControls || !!slots.prev" as-child>
        <slot name="prev">
          <Button :color="color" :variant="variant" :size="size" :icon="chevronLeftIcon" />
        </slot>
      </PaginationPrev>

      <template v-for="(item, index) in items">
        <PaginationListItem v-if="item.type === 'page'" :key="index" as-child :value="item.value">
          <slot name="item" v-bind="{ item, index, page, pageCount }">
            <Button
              :color="page === item.value ? activeColor : color"
              :variant="page === item.value ? activeVariant : variant"
              :size="size"
              :label="String(item.value)"
              :ui="{ label: ui.label() }"
              square
            />
          </slot>
        </PaginationListItem>

        <PaginationEllipsis v-else :key="item.type" :index="index" as-child>
          <slot name="ellipsis">
            <Button :color="color" :variant="variant" :size="size" :icon="ellipsisIcon" :class="ui.ellipsis({ class: props.ui?.ellipsis })" />
          </slot>
        </PaginationEllipsis>
      </template>

      <PaginationNext v-if="showControls || !!slots.next" as-child>
        <slot name="next">
          <Button :color="color" :variant="variant" :size="size" :icon="chevronRightIcon" />
        </slot>
      </PaginationNext>
      <PaginationLast v-if="showControls || !!slots.last" as-child>
        <slot name="last">
          <Button :color="color" :variant="variant" :size="size" :icon="chevronDoubleRightIcon" />
        </slot>
      </PaginationLast>
    </PaginationList>
  </PaginationRoot>
</template>
Pagination.vue
vue
<script lang="ts">
import type { ButtonProps } from '@/UI/Components/Button.vue'
import type { PaginationRootEmits, PaginationRootProps } from 'reka-ui'
import Button from '@/UI/Components/Button.vue'
import { chevronDoubleLeftIcon, chevronDoubleRightIcon, chevronLeftIcon, chevronRightIcon, ellipsisIcon } from '@/UI/icons'
import theme from '@/UI/Theme/pagination'
import { reactivePick } from '@vueuse/core'
import { PaginationEllipsis, PaginationFirst, PaginationLast, PaginationList, PaginationListItem, PaginationNext, PaginationPrev, PaginationRoot, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'

const pagination = tv(theme)

export interface PaginationProps extends Partial<Pick<PaginationRootProps, 'defaultPage' | 'disabled' | 'itemsPerPage' | 'page' | 'showEdges' | 'siblingCount' | 'total'>> {
  as?: any
  color?: ButtonProps['color']
  variant?: ButtonProps['variant']
  activeColor?: ButtonProps['color']
  activeVariant?: ButtonProps['variant']
  showControls?: boolean
  size?: ButtonProps['size']
  class?: any
  ui?: Partial<typeof pagination.slots>
}

export interface PaginationEmits extends PaginationRootEmits {}

export interface PaginationSlots {
  first: (props?: object) => any
  prev: (props?: object) => any
  next: (props?: object) => any
  last: (props?: object) => any
  ellipsis: (props?: object) => any
  item: (props: {
    page: number
    pageCount: number
    item: {
      type: 'ellipsis'
    } | {
      type: 'page'
      value: number
    }
    index: number
  }) => any
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<PaginationProps>(), {
  size: 'md',
  color: 'neutral',
  variant: 'outline',
  activeColor: 'primary',
  activeVariant: 'solid',
  showControls: true,
  showEdges: false,
  itemsPerPage: 10,
  siblingCount: 2,
  total: 0,
})
const emits = defineEmits<PaginationEmits>()
const slots = defineSlots<PaginationSlots>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultPage', 'disabled', 'itemsPerPage', 'page', 'showEdges', 'siblingCount', 'total'), emits)

const ui = pagination()
</script>

<template>
  <PaginationRoot v-slot="{ page, pageCount }" v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <PaginationList v-slot="{ items }" :class="ui.list({ class: props.ui?.list })">
      <PaginationFirst v-if="showControls || !!slots.first" as-child>
        <slot name="first">
          <Button :color="color" :variant="variant" :size="size" :icon="chevronDoubleLeftIcon" />
        </slot>
      </PaginationFirst>
      <PaginationPrev v-if="showControls || !!slots.prev" as-child>
        <slot name="prev">
          <Button :color="color" :variant="variant" :size="size" :icon="chevronLeftIcon" />
        </slot>
      </PaginationPrev>

      <template v-for="(item, index) in items">
        <PaginationListItem v-if="item.type === 'page'" :key="index" as-child :value="item.value">
          <slot name="item" v-bind="{ item, index, page, pageCount }">
            <Button
              :color="page === item.value ? activeColor : color"
              :variant="page === item.value ? activeVariant : variant"
              :size="size"
              :label="String(item.value)"
              :ui="{ label: ui.label() }"
              square
            />
          </slot>
        </PaginationListItem>

        <PaginationEllipsis v-else :key="item.type" :index="index" as-child>
          <slot name="ellipsis">
            <Button :color="color" :variant="variant" :size="size" :icon="ellipsisIcon" :class="ui.ellipsis({ class: props.ui?.ellipsis })" />
          </slot>
        </PaginationEllipsis>
      </template>

      <PaginationNext v-if="showControls || !!slots.next" as-child>
        <slot name="next">
          <Button :color="color" :variant="variant" :size="size" :icon="chevronRightIcon" />
        </slot>
      </PaginationNext>
      <PaginationLast v-if="showControls || !!slots.last" as-child>
        <slot name="last">
          <Button :color="color" :variant="variant" :size="size" :icon="chevronDoubleRightIcon" />
        </slot>
      </PaginationLast>
    </PaginationList>
  </PaginationRoot>
</template>

Theme

pagination.ts
ts
export default {
  slots: {
    root: '',
    list: '',
    ellipsis: '',
    label: '',
  },
}
View Nuxt UI theme
pagination.ts
ts
export default {
  slots: {
    root: '',
    list: 'flex items-center gap-1',
    ellipsis: 'pointer-events-none',
    label: 'min-w-5 text-center',
  },
}

Test

To test this component, you can use the following test file:

Pagination.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Pagination from '@/ui/components/Pagination.vue'
import theme from '@/ui/theme/button'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('pagination', () => {
  const sizes = Object.keys(theme.variants.size) as any
  const variants = Object.keys(theme.variants.variant) as any

  const props = { total: 100 }

  it.each<[string, RenderOptions<typeof Pagination>]>([
    // Props
    ['with total', { props }],
    ['with defaultPage', { props: { ...props, defaultPage: 2 } }],
    ['with disabled', { props: { ...props, disabled: true } }],
    ['with itemsPerPage', { props: { ...props, itemsPerPage: 5 } }],
    ['with page', { props: { ...props, page: 2 } }],
    ['with showEdges', { props: { ...props, showEdges: true } }],
    ['with siblingCount', { props: { ...props, siblingCount: 1, showEdges: true, page: 5 } }],
    ['without showControls', { props: { ...props, showControls: false } }],
    ['with firstIcon', { props: { ...props, firstIcon: 'i-lucide-arrow-left' } }],
    ['with prevIcon', { props: { ...props, prevIcon: 'i-lucide-arrow-left' } }],
    ['with nextIcon', { props: { ...props, nextIcon: 'i-lucide-arrow-right' } }],
    ['with lastIcon', { props: { ...props, lastIcon: 'i-lucide-arrow-right' } }],
    ['with ellipsisIcon', { props: { ...props, ellipsisIcon: 'i-lucide-ellipsis-vertical', siblingCount: 1, showEdges: true, page: 5 } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { ...props, size } }]),
    ...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { ...props, variant } }]),
    ...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { ...props, variant, color: 'primary' } }]),
    ...variants.map((activeVariant: string) => [`with primary active variant ${activeVariant}`, { props: { ...props, activeVariant } }]),
    ...variants.map((activeVariant: string) => [`with neutral active variant ${activeVariant}`, { props: { ...props, activeVariant, color: 'neutral' } }]),
    ['with as', { props: { ...props, as: 'div' } }],
    ['with class', { props: { ...props, class: 'relative' } }],
    ['with ui', { props: { ...props, ui: { list: 'gap-3' } } }],
    // Slots
    ['with first slot', { props, slots: { first: () => 'First slot' } }],
    ['with prev slot', { props, slots: { prev: () => 'Prev slot' } }],
    ['with next slot', { props, slots: { next: () => 'Next slot' } }],
    ['with last slot', { props, slots: { last: () => 'Last slot' } }],
    ['with ellipsis slot', { props: { ...props, siblingCount: 1, showEdges: true, page: 5 }, slots: { ellipsis: () => 'Ellipsis slot' } }],
    ['with item slot', { props, slots: { item: () => 'Item slot' } }],
  ])('renders %s correctly', (name, options) => {
    render(Pagination, {
      attrs: {
        'data-testid': 'pagination',
      },
      ...options,
    })

    expect(screen.getByTestId('pagination')).matchSnapshot()
  })
})
Pagination.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Pagination from '@/UI/Components/Pagination.vue'
import theme from '@/UI/Theme/button'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('pagination', () => {
  const sizes = Object.keys(theme.variants.size) as any
  const variants = Object.keys(theme.variants.variant) as any

  const props = { total: 100 }

  it.each<[string, RenderOptions<typeof Pagination>]>([
    // Props
    ['with total', { props }],
    ['with defaultPage', { props: { ...props, defaultPage: 2 } }],
    ['with disabled', { props: { ...props, disabled: true } }],
    ['with itemsPerPage', { props: { ...props, itemsPerPage: 5 } }],
    ['with page', { props: { ...props, page: 2 } }],
    ['with showEdges', { props: { ...props, showEdges: true } }],
    ['with siblingCount', { props: { ...props, siblingCount: 1, showEdges: true, page: 5 } }],
    ['without showControls', { props: { ...props, showControls: false } }],
    ['with firstIcon', { props: { ...props, firstIcon: 'i-lucide-arrow-left' } }],
    ['with prevIcon', { props: { ...props, prevIcon: 'i-lucide-arrow-left' } }],
    ['with nextIcon', { props: { ...props, nextIcon: 'i-lucide-arrow-right' } }],
    ['with lastIcon', { props: { ...props, lastIcon: 'i-lucide-arrow-right' } }],
    ['with ellipsisIcon', { props: { ...props, ellipsisIcon: 'i-lucide-ellipsis-vertical', siblingCount: 1, showEdges: true, page: 5 } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { ...props, size } }]),
    ...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { ...props, variant } }]),
    ...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { ...props, variant, color: 'primary' } }]),
    ...variants.map((activeVariant: string) => [`with primary active variant ${activeVariant}`, { props: { ...props, activeVariant } }]),
    ...variants.map((activeVariant: string) => [`with neutral active variant ${activeVariant}`, { props: { ...props, activeVariant, color: 'neutral' } }]),
    ['with as', { props: { ...props, as: 'div' } }],
    ['with class', { props: { ...props, class: 'relative' } }],
    ['with ui', { props: { ...props, ui: { list: 'gap-3' } } }],
    // Slots
    ['with first slot', { props, slots: { first: () => 'First slot' } }],
    ['with prev slot', { props, slots: { prev: () => 'Prev slot' } }],
    ['with next slot', { props, slots: { next: () => 'Next slot' } }],
    ['with last slot', { props, slots: { last: () => 'Last slot' } }],
    ['with ellipsis slot', { props: { ...props, siblingCount: 1, showEdges: true, page: 5 }, slots: { ellipsis: () => 'Ellipsis slot' } }],
    ['with item slot', { props, slots: { item: () => 'Item slot' } }],
  ])('renders %s correctly', (name, options) => {
    render(Pagination, {
      attrs: {
        'data-testid': 'pagination',
      },
      ...options,
    })

    expect(screen.getByTestId('pagination')).matchSnapshot()
  })
})

Contributors

barbapapazes

Changelog

cf30b - feat: create Pagination component (#93) on 1/9/2025