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
BbPagination selects a page. Your application owns the page state, visible
rows, request and loading feedback.
Passthrough
Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.
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-0417Delivered€1,298.00
- ORD-2026-0418Delivered€338.80
- ORD-2026-0421Refunded€899.00
- ORD-2026-0426Delivered€477.00
- ORD-2026-0430Delivered€2,196.00
1–5 of 20
<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 gap-3 px-3 py-2 text-sm"
>
<span class="shrink-0 font-medium whitespace-nowrap tabular-nums">{{ order.reference }}</span>
<span class="min-w-0 grow truncate text-xs text-[color:var(--bb-text-muted)]">
{{ orderStatusLabels[order.status] }}
</span>
<span class="shrink-0 text-right tabular-nums">
{{ euro.format(order.total) }}
</span>
</li>
</ul>
<!-- The range line keeps to one line; the pager takes the rest of the row
and fits its buttons to it. No `justify-between`, no `flex-wrap`. -->
<div class="flex items-center gap-2 border-t border-[color:var(--bb-border)] px-3 py-2">
<p
class="shrink-0 text-xs whitespace-nowrap text-[color:var(--bb-text-muted)] tabular-nums"
role="status"
>
{{ loading ? 'Loading…' : `${from}–${to} of ${orders.length}` }}
</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 { computed, ref, watch } from 'vue';
import { BbPagination } from 'bitboss-ui';
import type { Order } from '~/demo-data';
import { delay, orders, orderStatusLabels } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', { style: 'currency', currency: 'EUR' });
const perPage = 5;
const page = ref(1);
const loading = ref(false);
const from = computed(() => (page.value - 1) * perPage + 1);
const to = computed(() => Math.min(page.value * perPage, orders.length));
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.
A range line beside the pager ("6–10 of 20"): the pager takes the whole row
and fits its buttons to it, so a plain <p> beside it gets squeezed to one word
per line. Put the line in a flex items-center gap-2 row and give it
shrink-0 whitespace-nowrap. Skip justify-between and flex-wrap.
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.
1<template>
<div class="flex w-full max-w-md flex-col gap-3">
<!-- `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"
/>
<!-- Write an out-of-range page and watch the model come back clamped. -->
<div class="flex flex-wrap items-center gap-2 text-sm">
<BbButton size="sm" variant="outline" @click="page = 0">Set page 0</BbButton>
<BbButton size="sm" variant="outline" @click="page = 99">Set page 99</BbButton>
<span class="text-[color:var(--bb-text-muted)]">
Model: <code>{{ page }}</code>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, 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 v2loading prop
In v3 there is no loading prop. It was removed, not renamed. A leftover
:loading="fetching" compiles and has no effect; a development build warns that
it was removed. 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">
<!-- CSS `resize` does nothing on touch screens, so the widths are also buttons. -->
<div aria-label="Frame width" role="group">
<BbButton
v-for="preset in presets"
:key="preset.width"
v-model="frameWidth"
:false-value="preset.width"
group
size="sm"
:true-value="preset.width"
variant="outline"
>
{{ preset.label }}
</BbButton>
</div>
<!-- Narrow the frame: the strip drops page buttons on its own, never below
three, and puts them back when the space returns. -->
<div
class="max-w-full min-w-[17rem] resize-x overflow-auto rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
:style="{ width: frameWidth }"
>
<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 { BbButton, BbPagination } from 'bitboss-ui';
const presets = [
{ label: 'Narrow', width: '17rem' },
{ label: 'Medium', width: '22rem' },
{ label: 'Wide', width: '28rem' },
];
const frameWidth = ref('28rem');
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. When the container is too narrow, the control drops page buttons, never below three, and adds them back when the space returns. That is why the demo above changes width.
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-xl 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"
>
<template #status="{ content, item }">
<BbBadge size="sm" :variant="statusVariant[item.status]">{{ content }}</BbBadge>
</template>
</BbTable>
<!-- On a phone the size and range sit above the pager; from `sm` up, one row. -->
<div
class="flex flex-col gap-2 border-t border-[color:var(--bb-border)] px-3 py-2 sm:flex-row sm:items-center sm:gap-3"
>
<div class="flex shrink-0 items-center gap-3">
<!-- `hide-label` keeps the name for screen readers; the options say it. -->
<BbSelect
id="orders-per-page"
v-model="perPage"
class="w-32 shrink-0"
compact
hide-label
item-text="text"
:items="pageSizes"
label="Rows per page"
name="perPage"
/>
<p class="shrink-0 text-xs whitespace-nowrap text-[color:var(--bb-text-muted)] tabular-nums">
{{ from }}–{{ to }} of {{ orders.length }}
</p>
</div>
<!-- No v-model and no per-page: the shared id is the wiring, and the
context already holds the page size. `total-items` could sit on
either side; on the pager, the server render already knows the count. -->
<BbPagination
label="Orders pagination"
table-id="orders-pager"
:total-items="orders.length"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue';
import { BbBadge, BbPagination, BbSelect, BbTable, useBbTableContext } from 'bitboss-ui';
import type { BadgeVariantType, BbTableColumn } from 'bitboss-ui';
import type { Order, OrderStatus } from '~/demo-data';
import { orders, orderStatusLabels } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', { style: 'currency', currency: 'EUR' });
const columns: BbTableColumn<Order>[] = [
{ key: 'reference', label: 'Reference' },
{
key: 'status',
label: 'Status',
formatter: (value: OrderStatus) => orderStatusLabels[value],
},
{ key: 'total', label: 'Total', align: 'right', formatter: euro.format },
];
const statusVariant: Record<OrderStatus, BadgeVariantType> = {
pending: 'outline',
processing: 'secondary',
shipped: 'primary',
delivered: 'secondary',
cancelled: 'destructive',
refunded: 'outline',
};
const pageSizes = [5, 10, 20].map((value) => ({ value, text: `${value} per page` }));
// Anything can join the rendezvous with the same id — here the page-size
// select and the range text, which read and write the same `page` / `perPage`.
const { page, perPage } = useBbTableContext('orders-pager', { perPage: 5 });
// A new page size changes what "page 3" means, so start over from the top.
watch(perPage, () => (page.value = 1));
const from = computed(() => (page.value - 1) * perPage.value + 1);
const to = computed(() => Math.min(page.value * perPage.value, orders.length));
const rows = computed(() => orders.slice(from.value - 1, to.value));
</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, the server render
already shows the right page count, 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 throw, it pairs with nothing, and a development build warns the first time someone pages with no table mounted for that id.
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. Disabled
buttons never carry a link, so crawlers and middle-clicks cannot reach invalid
pages.
<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 v2querykey → queryKey
The prop was querykey, all lowercase, and it is now queryKey. The compiler
cannot see it: a leftover querykey is ignored, URL sync moves back to
?page=, and every deep link and server route reading the old parameter stops
matching. A development build warns, npx bitboss-ui check --fix renames it,
and rg -i querykey finds the links and routes too.
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.
Q3 board pack · sheet 1 of 18
Cover
<template>
<div class="flex w-full max-w-sm flex-col gap-2">
<header class="rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] px-3 py-2.5">
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]">
Q3 board pack · sheet {{ sheet }} of {{ sheets.length }}
</p>
<p class="m-0 mt-0.5 font-medium">{{ sheets[sheet - 1] }}</p>
</header>
<BbPagination
v-model="sheet"
align="left"
label="Board pack sheets"
:max-size="5"
:total-pages="sheets.length"
>
<!-- 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 sheets = [
'Cover',
'Agenda',
'Chief executive summary',
'Revenue by region',
'Gross margin',
'Operating costs',
'Headcount',
'Cash flow',
'Sales pipeline',
'Customer churn',
'Product roadmap',
'Hiring plan',
'Capital expenditure',
'Risk register',
'Resolutions for approval',
'Q4 outlook',
'Appendix: KPI definitions',
'Appendix: glossary',
];
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 an icon.
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.