Pagination
A 36px line under the grid: how many records the filters hide, a way to clear them, and Next page when more rows wait.
Playground
Change the controls to try each prop. The preview shows the candidates line with both actions; the code lists only the props you changed.
import { Pagination } from '@/ui/pagination';<Pagination />Under the grid
The line sits under the table, after the calculation footer, aligned to the cell padding. Both actions are quiet buttons.
| Candidate | Company | Updated |
|---|---|---|
| Yuki Kimura | Halcyon Bank | 2h ago |
| Maya Chen | Northwind Logistics | Yesterday |
import { Pagination } from '@/ui/pagination';
import { Table, type TableColumn } from '@/ui/table';
type Candidate = { id: string; name: string; company: string; updated: string };
const candidates: Candidate[] = [
{ id: 'c1', name: 'Yuki Kimura', company: 'Halcyon Bank', updated: '2h ago' },
{ id: 'c2', name: 'Maya Chen', company: 'Northwind Logistics', updated: 'Yesterday' },
];
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', cell: (row) => row.name },
{ key: 'company', label: 'Company', width: 200, cell: (row) => row.company },
{ key: 'updated', label: 'Updated', cell: (row) => row.updated },
];
export default function PaginationUnderTheGrid() {
return (
<div className="flex w-full flex-col bg-surface">
<Table label="Candidates" columns={columns} rows={candidates} />
<Pagination noun={{ one: 'candidate', many: 'candidates' }} hidden={8} onClear={() => {}} onNext={() => {}} />
</div>
);
}What the line shows
Next page shows while more rows wait. The count shows while filters hide rows, in the singular for one. On the last page with nothing hidden there is no line at all; the footer count is enough.
More rows, nothing hidden
Last page, one row hidden
Many hidden
Last page, nothing hidden: no line at all
import { Pagination } from '@/ui/pagination';
const noun = { one: 'candidate', many: 'candidates' };
export default function PaginationWhatItShows() {
return (
<div className="flex w-full flex-col gap-200">
<div className="flex flex-col gap-050">
<p className="text-body-small leading-body-small text-text-subtle">More rows, nothing hidden</p>
<Pagination noun={noun} onNext={() => {}} className="bg-surface" />
</div>
<div className="flex flex-col gap-050">
<p className="text-body-small leading-body-small text-text-subtle">Last page, one row hidden</p>
<Pagination noun={noun} hidden={1} onClear={() => {}} className="bg-surface" />
</div>
<div className="flex flex-col gap-050">
<p className="text-body-small leading-body-small text-text-subtle">Many hidden</p>
<Pagination noun={noun} hidden={12480} onClear={() => {}} onNext={() => {}} className="bg-surface" />
</div>
<div className="flex flex-col gap-050">
<p className="text-body-small leading-body-small text-text-subtle">Last page, nothing hidden: no line at all</p>
<Pagination noun={noun} />
</div>
</div>
);
}Long lists
A View with thousands of records loads 100 rows a page. While the next page loads, the rows dim, Next page ignores more presses, and its spinner shows only after 300ms. A page that fails to load says so in a toast that stays, with Try again; this example fails once on purpose.
| Candidate | Number |
|---|---|
| Candidate 1 | 1 |
| Candidate 2 | 2 |
| Candidate 3 | 3 |
| Candidate 4 | 4 |
| Candidate 5 | 5 |
| Candidate 6 | 6 |
| Candidate 7 | 7 |
| Candidate 8 | 8 |
| Candidate 9 | 9 |
| Candidate 10 | 10 |
| Candidate 11 | 11 |
| Candidate 12 | 12 |
| Candidate 13 | 13 |
| Candidate 14 | 14 |
| Candidate 15 | 15 |
| Candidate 16 | 16 |
| Candidate 17 | 17 |
| Candidate 18 | 18 |
| Candidate 19 | 19 |
| Candidate 20 | 20 |
| Candidate 21 | 21 |
| Candidate 22 | 22 |
| Candidate 23 | 23 |
| Candidate 24 | 24 |
| Candidate 25 | 25 |
| Candidate 26 | 26 |
| Candidate 27 | 27 |
| Candidate 28 | 28 |
| Candidate 29 | 29 |
| Candidate 30 | 30 |
| Candidate 31 | 31 |
| Candidate 32 | 32 |
| Candidate 33 | 33 |
| Candidate 34 | 34 |
| Candidate 35 | 35 |
| Candidate 36 | 36 |
| Candidate 37 | 37 |
| Candidate 38 | 38 |
| Candidate 39 | 39 |
| Candidate 40 | 40 |
| Candidate 41 | 41 |
| Candidate 42 | 42 |
| Candidate 43 | 43 |
| Candidate 44 | 44 |
| Candidate 45 | 45 |
| Candidate 46 | 46 |
| Candidate 47 | 47 |
| Candidate 48 | 48 |
| Candidate 49 | 49 |
| Candidate 50 | 50 |
| Candidate 51 | 51 |
| Candidate 52 | 52 |
| Candidate 53 | 53 |
| Candidate 54 | 54 |
| Candidate 55 | 55 |
| Candidate 56 | 56 |
| Candidate 57 | 57 |
| Candidate 58 | 58 |
| Candidate 59 | 59 |
| Candidate 60 | 60 |
| Candidate 61 | 61 |
| Candidate 62 | 62 |
| Candidate 63 | 63 |
| Candidate 64 | 64 |
| Candidate 65 | 65 |
| Candidate 66 | 66 |
| Candidate 67 | 67 |
| Candidate 68 | 68 |
| Candidate 69 | 69 |
| Candidate 70 | 70 |
| Candidate 71 | 71 |
| Candidate 72 | 72 |
| Candidate 73 | 73 |
| Candidate 74 | 74 |
| Candidate 75 | 75 |
| Candidate 76 | 76 |
| Candidate 77 | 77 |
| Candidate 78 | 78 |
| Candidate 79 | 79 |
| Candidate 80 | 80 |
| Candidate 81 | 81 |
| Candidate 82 | 82 |
| Candidate 83 | 83 |
| Candidate 84 | 84 |
| Candidate 85 | 85 |
| Candidate 86 | 86 |
| Candidate 87 | 87 |
| Candidate 88 | 88 |
| Candidate 89 | 89 |
| Candidate 90 | 90 |
| Candidate 91 | 91 |
| Candidate 92 | 92 |
| Candidate 93 | 93 |
| Candidate 94 | 94 |
| Candidate 95 | 95 |
| Candidate 96 | 96 |
| Candidate 97 | 97 |
| Candidate 98 | 98 |
| Candidate 99 | 99 |
| Candidate 100 | 100 |
import { useState } from 'react';
import { Pagination } from '@/ui/pagination';
import { Table, type TableColumn } from '@/ui/table';
import { toast } from '@/ui/toast';
type Candidate = { id: string; name: string; number: number };
const total = 12480;
const pageSize = 100;
const pageOf = (page: number): Candidate[] =>
Array.from({ length: pageSize }, (_, index) => {
const number = page * pageSize + index + 1;
return { id: `c${number}`, name: `Candidate ${number.toLocaleString('en-GB')}`, number };
});
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', cell: (row) => row.name },
{ key: 'number', label: 'Number', width: 120, align: 'end', cell: (row) => row.number.toLocaleString('en-GB') },
];
// The app loads 100 rows a page by cursor; the third page fails here, to show the error toast with Try again.
export default function PaginationLongLists() {
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
const next = (attempt = 0) => {
setLoading(true);
setTimeout(() => {
setLoading(false);
if (page === 1 && attempt === 0) {
toast.add({
type: 'error',
title: "Couldn't load the next page",
description: 'Check your connection and try again.',
actionProps: { children: 'Try again', onClick: () => next(1) },
});
return;
}
setPage((current) => current + 1);
}, 900);
};
const last = (page + 1) * pageSize >= total;
return (
<div className="flex w-full flex-col bg-surface">
<Table label={`Candidates, page ${page + 1}`} columns={columns} rows={pageOf(page)} loading={loading} className="max-h-60" />
<Pagination noun={{ one: 'candidate', many: 'candidates' }} loading={loading} onNext={last ? undefined : () => next()} />
</div>
);
}Kanban board
A job's candidates as columns by stage, from CV sent to Placed. Drag a card to move it, or use its Move to menu. Earlier stages fold into one column.
Period controls
The bar under a report's header that picks which numbers the page shows: a preset period, a typed range, and whose numbers. Every change reloads every figure on the page.