Pagination
Made for Django's Paginator: you pass the page number and the total, and it builds the rest. The
items are links with a real href that keep the filter, search and ordering from the URL — so opening in another tab, the
browser's back button and search engines keep working.
Examples
On this page clicks don't navigate: each example swaps the current page in place, so you can try it out.
Few pages
No ellipsis, and the inactive edge gets out of the way.
Wider neighborhood
data-around="2" shows two pages on each side of the current one.
More pages at the edges
data-edges="2" keeps the first two and the last two.
Other labels and parameter
data-prev-text, data-next-text and data-param="p" in the URL.
Centered
class="tuc-pagination is-center" alongside data-tuc-pagination.
Right-aligned
is-end, to sit under a table's amount column.
How to use
With the numbers the Paginator already has. The element is replaced by the navigation on load and on every
htmx:afterSwap; with a single page, nothing is rendered — there's nowhere to go.
In the Django template
{% for contract in page_obj %}
…
{% endfor %}
<div data-tuc-pagination
data-page="{{ page_obj.number }}"
data-pages="{{ page_obj.paginator.num_pages }}"></div>from django.core.paginator import Paginator
def contracts(request):
qs = Contract.objects.filter(customer__name__icontains=request.GET.get("q", "")).order_by("-created_at")
page_obj = Paginator(qs, 25).get_page(request.GET.get("page"))
return render(request, "contracts/list.html", {"page_obj": page_obj})On a page at /contracts/?q=padaria&sort=amount, the link to page 3 comes out as
/contracts/?q=padaria&sort=amount&page=3: only the page parameter changes. That's what keeps the search and the ordering
standing when you turn the page, without the template having to rebuild the query string.
| Attribute | Default | What for |
|---|---|---|
data-page | 1 | The current page |
data-pages | 1 | The total number of pages |
data-param | page | Name of the parameter in the query string |
data-around | 1 | Visible pages on each side of the current one |
data-edges | 1 | Visible pages at each edge |
data-prev-text | Anterior | Label of the back edge |
data-next-text | Próxima | Label of the forward edge |
With HTMX
Since the items are already links, hx-boost on a container above is enough to turn page changes into partial
requests — and the pagination that comes back in the new fragment initializes on its own. No onChange needed.
In JavaScript
For those who build the list on the client. The Tucano.pagination() shortcut returns the element ready to insert; the class
returns the instance, with the element in node.
const pag = new Tucano.Pagination({
page: 1,
pages: 12,
label: 'Contracts pagination', // aria-label of the navigation
onChange: (page, p) => { // with onChange, clicking doesn't navigate
load(page);
p.setPage(page); // redraws with the new current page
},
});
document.querySelector('#footer').append(pag.node);
pag.href(3); // '/contracts/?q=padaria&page=3' — the link to a page
pag.opts.pages = 15;
pag.render(); // redraws after changing the options
pag.destroy(); // removes the element
// Just the element, without keeping the instance
document.querySelector('#footer').append(Tucano.pagination({ page: 2, pages: 5 }));Alignment is a class on .tuc-pagination: from JavaScript, pag.node.classList.add('is-center'), or
is-end. In the template the marked element receives the navigation inside it, so write the class next to the attribute:
<div class="tuc-pagination is-center" data-tuc-pagination
data-page="{{ page_obj.number }}"
data-pages="{{ page_obj.paginator.num_pages }}"></div>Which numbers show up
With many pages, not everything fits. The edges, the current page and its neighbors stay; the rest becomes an ellipsis. The calculation is
exported as Tucano.pageWindow(), and returns null where the ellipsis goes:
Tucano.pageWindow(5, 20); // [1, null, 4, 5, 6, null, 20]
Tucano.pageWindow(3, 7); // [1, 2, 3, 4, null, 7]
Tucano.pageWindow(10, 30, { around: 2 }); // [1, null, 8, 9, 10, 11, 12, null, 30]A gap of a single page never becomes an ellipsis: showing the number takes the same space and gives one more place to
click. That's why, on page 3 of 7, the 2 shows up instead of ….
Why links, and not buttons
In a server-paginated list — the case of every Django CRUD — the page number lives in the URL. Swapping the links
for onclick would break the middle-click, the browser's back button and indexing all at once.
The items are the system's button
Each item is a .tuc-btn is-ghost, and the current page is is-outline. Height, radius, focus and touch are already solved in the button, and a second button design on the same screen is what gives away a patched-together library. The current page gets an outline, not a fill: in a bar with ten targets, ten solid buttons would fight each other.
The disabled edge is a <span>, not an <a> without href
A link that leads nowhere stays in the Tab path and is announced as a link. The <span> with aria-disabled simply gets out of the way, and .tuc-btn draws the disabled state without an extra rule.
On mobile (below 40rem) the words at the edges go away and the arrows stay: the space is worth more for the numbers.
Before the script builds it, the empty element already reserves the height of a control, so the content below doesn't jump up.
Keyboard and accessibility
They're links in a <nav>, and the keyboard is the browser's.
| Key | Action |
|---|---|
Tab Shift+Tab | Moves between pages; the disabled edge and the ellipsis stay out of the path |
Enter | Goes to the focused page |
The navigation is role="navigation" with aria-label — "Paginação" by default, which you can change with the
label option when there are two on the same screen. The current page carries aria-current="page", and the ellipsis,
aria-hidden.
API
Generated from the code on every build — if something is not here, it does not exist.
[data-tuc-pagination]new Tucano.Pagination(opcoes) Tucano.pageWindow() Tucano.pagination()data-around data-edges data-next-text data-page data-pages data-param data-prev-texthref render setPage destroyOptions
The notes in this table come from comments in the source code, which are written in Portuguese.
| Option | Default | What for |
|---|---|---|
page | 1 | |
pages | 1 | |
param | 'page' | |
around | 1 | paginas visiveis de cada lado da atual |
edges | 1 | paginas visiveis nas pontas |
prevText | undefined | default: setTexts ("Anterior") |
nextText | undefined | default: setTexts ("Próxima") |
label | undefined | default: setTexts ("Paginação") |
onChange | null |