Keyboard shortcut: Ctrl + K
Get started

Pagination

A row of page buttons. Every pill is a VButton, so nothing about their states is redefined here, and the row can shed pages as the space narrows without a breakpoint.

Usage

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VPagination } from 'vectis-ui'

const page = ref(1)
</script>

<template>
  <VPagination v-model="page" :length="8" />
</template>

Examples

Variants and tones

itemVariant paints the pages that are not current and the controls, ghost by default or outlined. tone is the colour the current page takes, the rest of the row staying neutral.

vue

ghost

outline

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography } from 'vectis-ui'

const variants = ['ghost', 'outline'] as const
const tones = ['accent', 'neutral', 'danger'] as const

const page = ref(3)
</script>

<template>
  <div class="column">
    <!-- The variant paints the OTHER pages and the controls; the current page takes
         `selectedVariant`, filled by default. -->
    <div v-for="variant in variants" :key="variant" class="row">
      <VPagination
        v-for="tone in tones"
        :key="tone"
        v-model="page"
        :length="6"
        :item-variant="variant"
        :tone="tone"
        :label="`${variant}, ${tone}`"
      />
      <VTypography variant="caption" tone="muted">{{ variant }}</VTypography>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-6);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-5);
}
</style>

How the selection is drawn

selectedVariant is how the current page is painted in the row's tone: filled with solid, tinted with soft, or the colour of its text alone with ghost. In an outline row, a soft or ghost current page keeps the frame's border, so the frame stays closed.

vue

solid

soft

ghost

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography, type PaginationSelectedVariant } from 'vectis-ui'

const variants: PaginationSelectedVariant[] = ['solid', 'soft', 'ghost']
const pages = ref<Record<string, number>>({ solid: 3, soft: 3, ghost: 3 })
</script>

<template>
  <div class="column">
    <!-- How the current page is drawn, in the row's tone: filled with `solid`, tinted
         with `soft`, or the colour of its text alone with `ghost`. Solid is the loudest
         and the safest default; ghost is for a row that must stay quiet, where the
         difference rests on the text colour alone. -->
    <div v-for="variant in variants" :key="variant" class="row">
      <VTypography variant="caption" tone="muted">{{ variant }}</VTypography>
      <VPagination
        v-model="pages[variant]"
        :length="6"
        :selected-variant="variant"
        item-variant="outline"
        :label="`Pages, ${variant}`"
      />
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  justify-items: start;
  gap: var(--vectis-space-5);
}
.row {
  display: grid;
  justify-items: start;
  gap: var(--vectis-space-2);
}
</style>

Detached

detached spaces the buttons out and gives each its own corners, instead of joining them into a segmented control. seamless keeps them joined and takes the lines out from between them, so the row reads as one frame.

vue

Joined, the default

detached

seamless

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography } from 'vectis-ui'

const joined = ref(3)
const detached = ref(3)
const seamless = ref(3)
</script>

<template>
  <div class="column">
    <div class="row">
      <VPagination v-model="joined" :length="6" item-variant="outline" label="Joined pages" />
      <VTypography variant="caption" tone="muted">Joined, the default</VTypography>
    </div>

    <!-- The same word, in the same direction, as VButtonGroup and VToggle: the buttons
         are spaced and each keeps its own corners. -->
    <div class="row">
      <VPagination
        v-model="detached"
        :length="6"
        detached
        item-variant="outline"
        label="Detached pages"
      />
      <VTypography variant="caption" tone="muted">detached</VTypography>
    </div>

    <!-- Joined again, with the lines between the buttons taken out: one frame, and the
         current page as a highlight that moves inside it. -->
    <div class="row">
      <VPagination
        v-model="seamless"
        :length="6"
        seamless
        item-variant="outline"
        label="Seamless pages"
      />
      <VTypography variant="caption" tone="muted">seamless</VTypography>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-5);
}
</style>

Elevated

elevated raises the row off the page. Joined, the shadow belongs to the row; detached, every button carries its own.

vue

elevated

elevated, detached

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography } from 'vectis-ui'

const joined = ref(3)
const detached = ref(3)
</script>

<template>
  <div class="column">
    <!-- Joined, the shadow is the ROW's: one per pill would fall into every joint,
         the segments overlapping by a pixel. -->
    <div class="row">
      <VPagination
        v-model="joined"
        :length="6"
        elevated
        item-variant="outline"
        label="Raised joined pages"
      />
      <VTypography variant="caption" tone="muted">elevated</VTypography>
    </div>

    <div class="row">
      <VPagination
        v-model="detached"
        :length="6"
        elevated
        detached
        item-variant="outline"
        label="Raised detached pages"
      />
      <VTypography variant="caption" tone="muted">elevated, detached</VTypography>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-6);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-5);
}
</style>

Sizes

size sets the height, from 24 to 56 pixels, and compact takes 4px off it. A pill is square at one digit and widens by itself past that.

vue

xs

sm

md

lg

xl

xs compact

sm compact

md compact

lg compact

xl compact

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography } from 'vectis-ui'

const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const

const page = ref(3)
</script>

<template>
  <div class="column">
    <div v-for="size in sizes" :key="size" class="row">
      <VPagination
        v-model="page"
        :length="6"
        :size="size"
        item-variant="outline"
        :label="`Pages, ${size}`"
      />
      <VTypography variant="caption" tone="muted">{{ size }}</VTypography>
    </div>

    <!-- A pill is square at one digit and widens by itself past that: its minimum
         width derives from the control height, so nothing is set per size. -->
    <div v-for="size in sizes" :key="`${size}-compact`" class="row">
      <VPagination
        v-model="page"
        :length="6"
        :size="size"
        compact
        item-variant="outline"
        :label="`Pages, ${size} compact`"
      />
      <VTypography variant="caption" tone="muted">{{ size }} compact</VTypography>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-4);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-5);
}
</style>

Length

length is how many pages there are in all, one by default. Every page is rendered unless totalVisible says how many slots to keep.

vue

length 4

length 12

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography } from 'vectis-ui'

const short = ref(2)
const long = ref(7)
</script>

<template>
  <div class="column">
    <div class="row">
      <VPagination v-model="short" :length="4" item-variant="outline" label="Four pages" />
      <VTypography variant="caption" tone="muted">length 4</VTypography>
    </div>

    <!-- Every page is rendered unless `totalVisible` says how many slots to keep. -->
    <div class="row">
      <VPagination v-model="long" :length="12" item-variant="outline" label="Twelve pages" />
      <VTypography variant="caption" tone="muted">length 12</VTypography>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-5);
}
</style>

Total visible

totalVisible is how many slots the row renders, ellipses counted among them, which is what keeps its width constant. The first and last pages are always kept, and five is the effective minimum.

vue

totalVisible 5

totalVisible 7

totalVisible 9

Page 1 of 20

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography } from 'vectis-ui'

const page = ref(1)
</script>

<template>
  <div class="column">
    <!-- The count is CONSTANT: the ellipses are slots like the pages, so the row keeps
         exactly the same width whichever page is current. Walk through it and nothing
         moves sideways. -->
    <div v-for="slots in [5, 7, 9]" :key="slots" class="row">
      <VPagination
        v-model="page"
        :length="20"
        :total-visible="slots"
        item-variant="outline"
        :label="`Twenty pages in ${slots} slots`"
      />
      <VTypography variant="caption" tone="muted">totalVisible {{ slots }}</VTypography>
    </div>

    <VTypography variant="body-sm" tone="muted">Page {{ page }} of 20</VTypography>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-5);
}
</style>

Previous and next

controls decides what the previous and next buttons show: an icon, a word, both, or nothing at all. Their icons and their wording are yours, a label being both the visible text and the accessible name. A control reaching the last page it can go to disables itself and hands the keyboard focus to the page just reached.

vue

icon, the default

text

both

false, no controls at all

Icons and words of your own

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography } from 'vectis-ui'
import { arrow_left_alt as arrowLeftAlt, arrow_right_alt as arrowRightAlt } from 'vectis-ui/icons'

const DISPLAYS = [
  { value: 'icon', caption: 'icon, the default' },
  { value: 'text', caption: 'text' },
  { value: 'both', caption: 'both' },
  { value: false, caption: 'false, no controls at all' },
] as const

const page = ref(3)
</script>

<template>
  <div class="column">
    <!-- One prop rather than a boolean beside a union: you pick a member, you do not
         turn one off. -->
    <div v-for="display in DISPLAYS" :key="String(display.value)" class="row">
      <VPagination
        v-model="page"
        :length="6"
        :controls="display.value"
        item-variant="outline"
        :label="`Pages, controls ${display.caption}`"
      />
      <VTypography variant="caption" tone="muted">{{ display.caption }}</VTypography>
    </div>

    <!-- The icons and the wording are yours. A label is both the visible text and the
         accessible name, which is what keeps the control named once its text is hidden
         at a narrow width. -->
    <div class="row">
      <VPagination
        v-model="page"
        :length="6"
        controls="both"
        :prev-icon="arrowLeftAlt"
        :next-icon="arrowRightAlt"
        prev-text="Newer"
        next-text="Older"
        item-variant="outline"
        label="Pages with wording of their own"
      />
      <VTypography variant="caption" tone="muted">Icons and words of your own</VTypography>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-5);
}
</style>

Unreachable pages

disabledPages takes a list of pages, or a function when the rule is easier to write than to enumerate. The previous and next controls step over those pages and disable themselves only when there is nothing left to step to.

vue

A list. Now on page 1

A predicate. Now on page 1

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography } from 'vectis-ui'

const listed = ref(1)
const predicate = ref(1)
</script>

<template>
  <div class="column">
    <!-- A list, for a handful of known pages. The controls STEP OVER them rather than
         stopping at one: from page 1, next lands on 5. -->
    <div class="row">
      <VPagination
        v-model="listed"
        :length="10"
        :disabled-pages="[2, 3, 4]"
        controls="both"
        item-variant="outline"
        label="Pages 2 to 4 unavailable"
      />
      <VTypography variant="caption" tone="muted">A list. Now on page {{ listed }}</VTypography>
    </div>

    <!-- A predicate, when the rule is easier to write than to enumerate. When nothing
         is left to step to, the control disables itself. -->
    <div class="row">
      <VPagination
        v-model="predicate"
        :length="10"
        :disabled-pages="(page) => page % 2 === 0"
        controls="both"
        item-variant="outline"
        label="Even pages unavailable"
      />
      <VTypography variant="caption" tone="muted">
        A predicate. Now on page {{ predicate }}
      </VTypography>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-5);
}
</style>

States

disabled puts the whole row out of reach, greyed through the colour tokens. At either end of the range the matching control disables itself.

vue

The whole row switched off

On the first page

On the last page

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography } from 'vectis-ui'

const page = ref(3)
const first = ref(1)
const last = ref(6)
</script>

<template>
  <div class="column">
    <div class="row">
      <VPagination
        v-model="page"
        :length="6"
        disabled
        controls="both"
        item-variant="outline"
        label="Pages unavailable"
      />
      <VTypography variant="caption" tone="muted">The whole row switched off</VTypography>
    </div>

    <!-- The controls disable themselves at the ends: there is nothing to step to. -->
    <div class="row">
      <VPagination
        v-model="first"
        :length="6"
        controls="both"
        item-variant="outline"
        label="On the first page"
      />
      <VTypography variant="caption" tone="muted">On the first page</VTypography>
    </div>

    <div class="row">
      <VPagination
        v-model="last"
        :length="6"
        controls="both"
        item-variant="outline"
        label="On the last page"
      />
      <VTypography variant="caption" tone="muted">On the last page</VTypography>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-5);
}
</style>

Alignment

align says where the row sits in the width it is given. It only says something in responsive mode, which is what makes the nav take the whole width available.

vue

start

center

end

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography } from 'vectis-ui'

const aligns = ['start', 'center', 'end'] as const

const page = ref(3)
</script>

<template>
  <div class="column">
    <!-- Alignment only says something in responsive mode, where the nav takes the whole
         width it is given. Outside it the row has its own intrinsic width and sits
         wherever its parent puts it. -->
    <div v-for="align in aligns" :key="align" class="box">
      <VPagination
        v-model="page"
        :length="6"
        responsive
        :align="align"
        item-variant="outline"
        :label="`Pages aligned ${align}`"
      />
      <VTypography variant="caption" tone="muted">{{ align }}</VTypography>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
}
.box {
  display: grid;
  gap: var(--vectis-space-2);
  padding: var(--vectis-space-3);
  border: 1px dashed var(--vectis-color-border);
  border-radius: var(--vectis-radius-surface);
}
</style>

Narrow containers

responsive sheds pages as the space narrows, hiding the neighbours of the current page one step at a time while the first page, the last and the current one never go. It measures its own width rather than the window's.

vue

Drag the handle at the corner: the neighbours of the current page go one step at a time, while the first page, the last and the current one never do.

<script setup lang="ts">
import { ref } from 'vue'
import { VPagination, VTypography } from 'vectis-ui'

const page = ref(8)
</script>

<template>
  <div class="column">
    <VTypography variant="body-sm" tone="muted">
      Drag the handle at the corner: the neighbours of the current page go one step at a time, while
      the first page, the last and the current one never do.
    </VTypography>

    <!-- The row asks about its OWN width rather than the window's, so it sheds pages
         inside a narrow panel while the page around it stays wide. -->
    <div class="box">
      <VPagination
        v-model="page"
        :length="16"
        responsive
        controls="both"
        align="center"
        item-variant="outline"
        label="Sixteen pages in a narrow panel"
      />
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-3);
}
.box {
  overflow: auto;
  inline-size: 38rem;
  min-inline-size: 14rem;
  max-inline-size: 100%;
  padding: var(--vectis-space-3);
  border: 1px dashed var(--vectis-color-border);
  border-radius: var(--vectis-radius-surface);
  resize: horizontal;
}
</style>

API

Props

PropTypeDefault
lengthnumber1
How many pages there are in all. It is 1 by default, which renders a single page: the real count almost always has to be given.
totalVisiblenumbernone
How many slots to render, ellipses counted among them, so the row keeps exactly the same width whichever page is current. Below five there would be nothing left to show around the current page, so five is the effective minimum. Left out, every page is rendered.
detachedbooleanfalse
Separates the buttons instead of joining them into one segmented control. It is the word VButtonGroup and VToggle use for the same question, in the same direction.
seamlessbooleanfalse
Takes the lines out from between the joined buttons, so the row reads as one frame rather than as segments, on the terms of VButtonGroup's own prop. It has no effect under detached.
itemVariantPaginationItemVariant'ghost' | 'outline''ghost'
How the pages other than the current one, and the controls, are drawn. What the current page takes is selectedVariant. It is named for the items because that is what it paints: on VTabs and VDataTable variant names the decoration of the frame instead.
selectedVariantPaginationSelectedVariant'solid' | 'soft' | 'ghost''solid'
How the current page is drawn, in the row's tone: filled, tinted, or the colour of its text alone.
tonePaginationTone'accent' | 'neutral' | 'danger''accent'
The colour the current page takes. The other pages and the controls stay neutral.
sizePaginationSize'xs' | 'sm' | 'md' | 'lg' | 'xl''md'
The height of the buttons, from the scale shared by every control.
compactbooleanfalse
Takes 4px off the height of every button.
elevatedbooleanfalse
Raises the row off the page. Joined, the shadow belongs to the row rather than to each pill, which is what stops it falling into the joints; detached, every button carries its own.
alignPaginationAlign'start' | 'center' | 'end''start'
Where the row sits in the space it is given. It only matters in responsive mode, where the row takes the whole width available.
controlsPaginationControlsfalse | 'icon' | 'text' | 'both''icon'
The previous and next buttons on either side of the pages: what they show, or false to leave them out. One prop rather than two, the shape VFilePicker preview and VCarousel controls already use.
prevIconIconSourcechevron_left
The icon of the previous control.
nextIconIconSourcechevron_right
The icon of the next control.
prevTextstringnone
The wording of the previous control, used both as its visible text and as what screen readers announce. It falls back to the design system dictionary.
nextTextstringnone
The wording of the next control, used both as its visible text and as what screen readers announce. It falls back to the design system dictionary.
disabledbooleanfalse
Makes the whole component unusable.
disabledPagesPaginationMatchernone
Which pages cannot be reached, as a list or as a function. The previous and next controls step over them rather than stopping at one.
responsivebooleanfalse
Lets the row shed pages as the space narrows, by asking about its own width. It is off by default, because it makes the row take the full width available.
labelstringnone
What screen readers announce for the navigation itself. It falls back to the design system dictionary.
pageLabel(page: number) => stringnone
How a page is announced. A pill shows a bare number, which alone means nothing to a screen reader: this is what turns it into "Page 3". It falls back to the design system dictionary.
v-modelnumber1
The page being shown, counted from 1. It starts on the first.

Types

The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.

export interface BuiltinIcon {
  name: string
  paths: readonly [string] | readonly [string, string]
}
export type IconRender =
  | { path: string; viewBox?: string }
  | { component: Component; props?: Record<string, unknown> }
  | { src: string }
  | { text: string; class?: string }
  | { class: string }
export type IconSource = string | BuiltinIcon | IconRender
export type PaginationMatcher = number[] | ((page: number) => boolean)