Use it for
Reach for BbPagination when a list is too long for one screen and people need
deterministic, jump-anywhere access — search results, an invoice ledger, an audit
log. Prefer it over infinite scroll whenever someone has to find their place
again, or refer to "page 4".
Use something else when
BbBreadcrumbs— the reader is moving through a hierarchy, not a sequenceBbTabs— they are peer views of the same record
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
BbPagination selects a page. Your application owns the page state, visible
rows, request and loading feedback.
Paginating an application list
Start with a real list. Bind the 1-based page, derive the visible rows from it, and pass the result count back to the control.
- ORD-2026-0417Delivered1298.00 EUR
- ORD-2026-0418Delivered338.80 EUR
- ORD-2026-0421Refunded899.00 EUR
- ORD-2026-0426Delivered477.00 EUR
- ORD-2026-0430Delivered2196.00 EUR
20 orders
<template>
<div
class="w-full max-w-md overflow-hidden rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
>
<ul
:aria-busy="loading"
class="divide-y divide-[color:var(--bb-border)]"
:class="loading && 'opacity-50'"
>
<li
v-for="order in rows"
:key="order.id"
class="flex items-center justify-between gap-2 px-3 py-2 text-sm"
>
<span class="font-medium tabular-nums">{{ order.reference }}</span>
<span class="text-xs text-[color:var(--bb-text-muted)]">
{{ orderStatusLabels[order.status] }}
</span>
<span class="shrink-0 tabular-nums">
{{ order.total.toFixed(2) }} {{ order.currency }}
</span>
</li>
</ul>
<div
class="flex flex-wrap items-center justify-between gap-2 border-t border-[color:var(--bb-border)] px-3 py-2"
>
<p class="text-xs text-[color:var(--bb-text-muted)]" role="status">
{{ loading ? 'Loading…' : `${orders.length} orders` }}
</p>
<!-- The application owns page, rows and loading. The pager only reports
intent and stays disabled while that intent is being applied. -->
<BbPagination
v-model="page"
:disabled="loading"
label="Orders pagination"
:per-page="perPage"
:total-items="orders.length"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { BbPagination } from 'bitboss-ui';
import type { Order } from '~/demo-data';
import { delay, orders, orderStatusLabels } from '~/demo-data';
const perPage = 5;
const page = ref(1);
const loading = ref(false);
const slice = (value: number): Order[] =>
orders.slice((value - 1) * perPage, value * perPage);
// The first page is already on screen, so nothing is fetched at mount. Every
// later page turn goes through the same fake request a real list would.
const rows = ref<Order[]>(slice(1));
watch(page, async (value) => {
loading.value = true;
rows.value = await delay(slice(value), 500);
loading.value = false;
});
</script>
The list owns page, rows and loading. The pager emits only the next page.
While the request runs, disabled prevents overlapping navigation and the list
keeps the loading message where readers are already looking.
Pass total-items with per-page when the API reports a row count. Use
total-pages when it reports only a page count. A derived count wins when both
are present.
Always set label to name the list. The localized “Pagination” fallback is too
generic when a screen has more than one result set.
State ownership
v-model is the current page. A standalone pager clamps it to
1..totalPages; page 0 becomes 1 and page 99 becomes the last page.
With one page, every control is disabled, so you can omit the pager.
<template>
<div class="flex w-full max-w-md flex-col gap-2">
<div
class="grid h-20 place-items-center rounded-[var(--bb-radius)] border border-dashed border-[color:var(--bb-border)] text-sm text-[color:var(--bb-text-muted)]"
>
Page {{ page }} of 12
</div>
<!-- `label` names the list this landmark paginates. A page with more than
one pager and a default label gives a screen-reader user two
landmarks both called "Pagination". -->
<BbPagination
v-model="page"
align="left"
label="Orders pagination"
:total-pages="12"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbPagination } from 'bitboss-ui';
// Pages are 1-based: page 0 is clamped to 1, and your slicing math is
// `(page - 1) * perPage`.
const page = ref(1);
</script>
Listen to update:modelValue, never button clicks. Button availability and the
ellipsis window are internal.
Every control is a button, or a link under navigation. The active page carries
aria-current="page" and Previous/Next include localized accessible text.
Coming from v2
In v3 there is no loading prop. It was removed, not renamed. A leftover
:loading="fetching" compiles but does nothing. Use disabled and keep the
loading affordance on the list or table.
Long ranges and narrow containers
Long ranges fold around the current page with ellipses, and you never compute the window yourself.
Page 1 of 42. The window folds around the current page — walk to the middle of the range and both ellipses appear.
<template>
<div class="flex flex-col gap-2">
<!-- Drag the frame narrower: the strip drops page buttons on its own,
never below three, and puts them back when the space returns. -->
<div
class="min-w-[14rem] resize-x overflow-auto rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
style="width: 28rem"
>
<BbPagination
v-model="page"
align="center"
label="Search results pagination"
:max-size="7"
:total-pages="42"
/>
</div>
<p class="text-xs text-[color:var(--bb-text-muted)]">
Page {{ page }} of 42. The window folds around the current page — walk
to the middle of the range and both ellipses appear.
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbPagination } from 'bitboss-ui';
const page = ref(1);
</script>
max-size (default 6) caps how many page buttons show at once. The count
excludes the ellipses and Previous / Next, and carries a ±1 tolerance so odd
and even windows stay balanced around the current page.
It is a maximum, not a promise: a ResizeObserver drops page buttons when the
container is too narrow — never below three — and adds them back when the space
returns. That is why the demo above is resizable, and why there is no
"max visible items" prop to fight with.
align places the control inside its full-width row (right by default,
center, left), and ellipsis swaps the ... placeholder text. Neither the
window nor the ellipses should be rebuilt by hand: hiding buttons with CSS breaks
the fit logic that is already running.
Paired with a table
When the list is a BbTable, skip the wiring: give the table an id and the
pagination the matching table-id.
1–5 of 20
<template>
<div
class="w-full max-w-lg overflow-hidden rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
>
<BbTable
id="orders-pager"
caption="Orders"
:columns="columns"
compact
item-value="id"
:items="rows"
:total-items="orders.length"
/>
<div
class="flex flex-wrap items-center justify-between gap-2 border-t border-[color:var(--bb-border)] px-3 py-2"
>
<p class="text-xs text-[color:var(--bb-text-muted)]">
{{ from }}–{{ to }} of {{ orders.length }}
</p>
<!-- No v-model: the shared id is the wiring. The page count comes from
the table's `total-items` through the same context. -->
<BbPagination label="Orders pagination" table-id="orders-pager" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { BbPagination, BbTable, useBbTableContext } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import { orders, orderStatusLabels } from '~/demo-data';
type Row = { id: number; reference: string; status: string; total: string };
const columns: BbTableColumn<Row>[] = [
{ key: 'reference', label: 'Reference' },
{ key: 'status', label: 'Status' },
{ key: 'total', label: 'Total', align: 'right' },
];
// Anything can join the rendezvous with the same id — a toolbar, a filter, or
// this component, which seeds the page size and reads the page the pager sets.
const { page, perPage } = useBbTableContext('orders-pager', { perPage: 5 });
const from = computed(() => (page.value - 1) * perPage.value + 1);
const to = computed(() => Math.min(page.value * perPage.value, orders.length));
const rows = computed<Row[]>(() =>
orders.slice(from.value - 1, to.value).map((order) => ({
id: order.id,
reference: order.reference,
status: orderStatusLabels[order.status],
total: `${order.total.toFixed(2)} ${order.currency}`,
}))
);
</script>
Both components publish their pagination props into the shared table context, so
the page count is derived automatically and the current page stays two-way
synced. There is no v-model here, mount order does not matter, and any other
component can join the same rendezvous with useBbTableContext('orders-pager') —
a toolbar that resets the page after a filter change, a footer that shows the
range.
The props are symmetric: page, per-page, total-items and total-pages are
accepted by both sides, so the server's fields can go wherever is convenient.
Pass each fact to one place, though — giving the table and the pager
different total-items is an author error the library resolves arbitrarily. One
rule settles conflicts: a count derived from a real total-items always wins,
and total-pages is only a seed for when no row count is known.
Paired with a table the control does not clamp. The context owns the page, so an out-of-range value is the consumer's to fix — correcting it here would push a page nobody asked for. And mind the id: a typo does not error, it just pairs with nothing.
Pages that survive a reload
Set navigation and the current page is reflected in the URL, so it is
bookmarkable and survives a reload. query-key names the parameter, page by
default. Under Vue Router or Nuxt every enabled button becomes a to link that
merges the parameter into the current route's query, leaving other parameters
alone; without a router it falls back to real hrefs built from
window.location, so the same markup deep-links in a plain SPA.
<BbPagination
v-model="page"
label="Exports pagination"
navigation
replace
:total-pages="totalPages"
/>
Pair navigation with replace. Paging is a view control, not a journey:
without it every click pushes a history entry, and someone who walked to page 12
needs eleven presses of Back to escape the list. With it the URL stays current —
still deep-linkable, still reload-proof — and one Back returns to wherever they
came from.
One thing stays yours: the component writes the URL, it never reads it. Seed the model from the route once, at setup, or a shared link lands on page 1:
import { ref } from 'vue';
import { useRoute } from 'vue-router'; // or '#imports' in Nuxt
const route = useRoute();
const page = ref(Number(route.query.page) || 1);
Coming from v2
The prop was querykey, all lowercase, and it is now queryKey. This one is
silent — a leftover querykey is simply ignored, URL sync moves back to
?page=, and every deep link and every server route reading the old parameter
breaks quietly. rg -i querykey finds them all, kebab spelling included.
Disabled buttons never carry a link, so crawlers and middle-clicks cannot reach
invalid pages.
Custom button content
Four slots repaint the buttons without touching their behaviour: item receives
{ item, active, disabled } for each page number, previous and next replace
the chevrons, and ellipsis replaces the fold marker.
<template>
<div class="flex w-full max-w-sm flex-col gap-2">
<div
class="grid h-24 place-items-center rounded-[var(--bb-radius)] bg-[color:var(--bb-muted)] text-sm text-[color:var(--bb-text-muted)]"
>
Sheet {{ sheet }} of 18 · Q3 board pack
</div>
<BbPagination
v-model="sheet"
align="left"
label="Board pack sheets"
:max-size="5"
:total-pages="18"
>
<!-- Replacing the chevrons throws away the visually-hidden labels that
came with them, so the words are the accessible name now. Text or
an sr-only span — but something has to name these two buttons. -->
<template #previous><span class="px-1 text-xs">Prev</span></template>
<template #next><span class="px-1 text-xs">Next</span></template>
<template #item="{ item, active }">
<span class="px-0.5 text-xs" :class="active && 'font-semibold'">
{{ item }}
</span>
</template>
</BbPagination>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbPagination } from 'bitboss-ui';
const sheet = ref(1);
</script>
previous and next come with an accessibility bill. The chevrons ship with
visually hidden, localized text behind them; replacing the slot content replaces
that text too. Put a word in the button, or an sr-only span — but do not leave
two buttons whose only content is a glyph.
The ellipsis is not a button: it renders as a plain aria-hidden span, so it
never lands in the accessibility tree as focusless noise. The page buttons stay
real buttons with all their wiring; the slots only change what is painted inside
them.