Table
A server-rendered list. The template's <table> stays the source of
truth and the cell is yours. JavaScript only steps in where HTML can't reach: sorting by column and checking
rows in bulk.
This demo sorts on screen (data-sort-mode="client") because there's no server behind it. In a paginated list, the default is the server — see Sorting.
Examples
Variants are classes on the table itself. By default rows are separated by a bottom border, and not by zebra stripes: in a thirty-row list, stripes turn into noise. If you want stripes, ask for them.
Striped
is-striped paints even rows with --tuc-elevated.
| Product | Stock |
|---|---|
| 6 mm screw | 1,240 |
| Hex nut | 860 |
| Flat washer | 3,105 |
| 8 mm wall plug | 412 |
With grid
is-bordered closes the table and separates the columns.
| Month | Revenue | Expenses |
|---|---|---|
| July | R$ 48.200 | R$ 31.900 |
| August | R$ 52.750 | R$ 33.400 |
| September | R$ 50.100 | R$ 35.020 |
Compact
is-compact reduces cell spacing, for long lists.
| Code | Description | Status |
|---|---|---|
| NF-1021 | Maintenance service | Paid |
| NF-1022 | Parts replacement | Pending |
| NF-1023 | Technical visit | Draft |
| NF-1024 | Installation | Canceled |
Empty state
A table with no rows is still a table: a single cell, with tuc-table__empty.
| Customer | Due date | Amount |
|---|---|---|
| No contracts match these filters. | ||
Sticky header
is-sticky goes on the wrapper, which gets a max height so there's something to stick against.
| Time | Event |
|---|---|
| 08:00 | Register opened |
| 08:12 | Sale 3391 |
| 08:40 | Sale 3392 |
| 09:05 | Cash drop |
| 09:31 | Sale 3393 |
| 10:02 | Sale 3394 |
| 10:48 | Return 88 |
| 11:20 | Sale 3395 |
User and second line
Photo or initials, name and a subtle line below. With a width on the column, the second line truncates with an ellipsis.
| Person | Role |
|---|---|
| ALAna Limaana.lima@valeconstruction.com | Administrator |
| RSRafael Souzarafael@serrafarms.com | Finance |
How to use
Mark the <table> with data-tuc-table and it initializes on its own, including one
that arrives later through HTMX. On each sortable column, data-sort gives the type and
data-field gives the name that goes in the URL.
<table data-tuc-table data-selectable>
<thead><tr>
<th data-sort="text" data-field="customer" style="width:38%">Customer</th>
<th data-sort="number" data-field="amount" class="is-number">Amount</th>
<th data-sort="none" class="tuc-table__actions">Actions</th>
</tr></thead>
<tbody>
<tr data-id="12">
<td>Vale Construction</td>
<td class="is-number" data-sort-value="12400">R$ 12.400,00</td>
<td class="tuc-table__actions">
<button type="button" class="tuc-btn is-outline is-icon is-sm" aria-label="Edit">...</button>
</td>
</tr>
</tbody>
</table>In the Django template
The loop is the usual one. The row's data-id is what bulk selection posts, and
{% empty %} draws the empty state.
<table data-tuc-table data-selectable>
<thead><tr>
<th data-sort="text" data-field="customer" style="width:38%">Customer</th>
<th data-sort="text" data-field="status">Status</th>
<th data-sort="number" data-field="amount" class="is-number">Amount</th>
<th data-sort="none" class="tuc-table__actions">Actions</th>
</tr></thead>
<tbody>
{% for obj in page_obj %}
<tr data-id="{{ obj.pk }}">
<td>
<span class="tuc-table__user">
<span class="tuc-table__avatar"><img src="{{ obj.photo.url }}" alt=""></span>
<span>{{ obj.name }}<span class="tuc-table__sub">{{ obj.email }}</span></span>
</span>
</td>
<td><span class="tuc-badge is-success">{{ obj.get_status_display }}</span></td>
<td class="is-number" data-sort-value="{{ obj.amount }}" data-tuc-format="brl">{{ obj.amount }}</td>
<td class="tuc-table__actions">
<a class="tuc-btn is-outline is-icon is-sm" href="{% url 'contract-edit' obj.pk %}" aria-label="Edit">...</a>
</td>
</tr>
{% empty %}
<tr><td colspan="5" class="tuc-table__empty">No contracts found.</td></tr>
{% endfor %}
</tbody>
</table>The colspan counts the selection column
With data-selectable the script adds a column at the start of each row, so the empty cell has to span one more column than the header written in the template. For the same reason, the empty row gets a checkbox with no value; if that bothers you, render the table without data-selectable when there are no rows.
In JavaScript
To pass onSort and onSelect, which don't exist as attributes, or to read the selection.
const t = new Tucano.Table('#contracts', { selectable: true, sortMode: 'client' });
t.getSelected(); // ['12', '15'] — the checked data-id values
t.clearSelection();
t.sort(2, 'desc', 'number');
t.destroy(); // releases the listenersSorting
Sorting is the server's job, and that's the default. In a paginated list, reordering the twenty rows on screen produces a fake order: the real largest amount may be on page 7, and the table starts lying while looking truthful. What knows how to sort the whole set is the database.
That's why, in server mode, the header becomes an <a> to the same URL with ?sort= and
?dir=. It works with no JavaScript at all, works with HTMX's hx-boost, opens in another tab
and comes back with the browser's button. The arrow comes from the query string, so it stays correct after a reload. The
link keeps the rest of the query string — filter and search aren't lost — and drops page: staying on
page 7 with a different order would show a chunk from the middle of a list nobody has seen.
| Customer | Due date | Amount |
|---|---|---|
| Aurora Studio | 05/10/2026 | R$ 2.300,00 |
| Warm Bread Bakery | 21/08/2026 | R$ 480,00 |
| Wellness Clinic | 01/12/2026 | R$ 9.150,00 |
Here the headers are the server-mode links — hover to see the href. An onSort cancels the navigation and simulates the response; in your project the click loads the sorted page.
In the view
?sort and ?dir go into order_by() before the Paginator, which is
exactly where sorting has to happen. Map the URL name through a closed list: passing the parameter
straight to order_by() would let anyone sort by any field, and a nonexistent name brings down
the view with FieldError.
from django.core.paginator import Paginator
ORDERS = {"customer": "name", "status": "status", "amount": "amount"}
def contracts(request):
field = ORDERS.get(request.GET.get("sort"), "name")
if request.GET.get("dir") == "desc":
field = f"-{field}"
# "pk" breaks ties: without a total order, the same row can show up on two pages.
qs = Contract.objects.order_by(field, "pk")
page_obj = Paginator(qs, 20).get_page(request.GET.get("page"))
return render(request, "contracts/list.html", {"page_obj": page_obj})<table data-tuc-table>...</table>
<div data-tuc-pagination
data-page="{{ page_obj.number }}"
data-pages="{{ page_obj.paginator.num_pages }}"></div>Pagination does the same thing from the other side: the page link keeps sort and dir, so
turning the page doesn't undo the order.
Types and raw value
| Attribute | Where | What for |
|---|---|---|
data-sort="text" | <th> | Text, compared in pt-BR, case-insensitive and with numbers in natural order |
data-sort="number" | <th> | Number; accepts 1.234,50 and ignores symbols like R$ |
data-sort="date" | <th> | Date, read by new Date() — use an ISO value |
data-sort="none" or absent | <th> | Column without sorting |
data-field | <th> | Name that goes in ?sort=; without it, the column index is used |
data-sort-value | <td> | Raw value, when the displayed text doesn't sort well |
The raw value matters in client mode: 12/09/2026 isn't a date the browser reads in the Brazilian
format, and "3 days ago" or "Under review" don't sort as text. Write the value displayed for people and the
ISO date or the number in data-sort-value.
On screen, for a small table
data-sort-mode="client" exists for the other case: a small, complete table, without pagination, where sorting
on screen is the right thing. There the header becomes a <button>, because there's nowhere to navigate, and
aria-sort is updated on every click.
<table data-tuc-table data-sort-mode="client">...</table>With HTMX
Since the header and the pagination are real links, hx-boost on an ancestor is enough: HTMX swaps the
content without reloading, the URL follows along and the new table initializes itself on htmx:afterSwap.
<div hx-boost="true">
<table data-tuc-table>...</table>
<div data-tuc-pagination data-page="3" data-pages="12"></div>
</div>To handle navigation yourself, pass onSort: when it's defined, the click is canceled and your code
takes over. The tucano:sort event fires either way, before navigation.
new Tucano.Table('#contracts', {
onSort: ({ column, field, direction }, table) => {
htmx.ajax('GET', `?sort=${field}&dir=${direction}`, '#list');
},
});| Attribute | Default | What for |
|---|---|---|
data-sort-mode | server | client sorts on screen |
data-sort-param | sort | Name of the field parameter in the URL |
data-dir-param | dir | Name of the direction parameter; the values are asc and desc |
data-sortable | true | false turns off sorting for the whole table |
Bulk selection
Selection is still a form. With data-selectable, each row gets a real
<input type="checkbox" name="selected"> with its data-id as the value —
in Django it arrives as request.POST.getlist("selected"), with no JavaScript in between.
<form method="post" action="{% url 'contracts-approve' %}">
{% csrf_token %}
<table data-tuc-table data-selectable>...</table>
<button class="tuc-btn is-primary">Approve selected</button>
</form>from django.views.decorators.http import require_POST
@require_POST
def approve(request):
ids = request.POST.getlist("selected")
Contract.objects.filter(pk__in=ids).update(status="approved")
return redirect("contracts")An action button inside the form takes type="button"
A <button> with no type inside a <form> submits the form. In the actions column, write type="button" or use <a>, or else "Edit" turns into "Approve selected".
The header checkbox checks and unchecks the whole page and shows the mixed state when only some rows are checked.
Every change fires tucano:select on the table, with the checked values — the same ones the form
would send.
document.querySelector('#contracts').addEventListener('tucano:select', (e) => {
e.detail.selected; // ['1', '3']
e.detail.row; // the <tr> that changed
});| Attribute | Default | What for |
|---|---|---|
data-selectable | off | Turns on the selection column |
data-select-name | selected | The name of the checkboxes in the POST |
data-id on the <tr> | — | The value of that row's checkbox; without it the checkbox is empty |
The cell is yours
Anything you want fits inside the <td>. What the library offers are the arrangements that
show up in every listing, so nobody has to reinvent aligning an avatar next to a name.
| Class | What it does |
|---|---|
.tuc-table__user | Row with avatar and text, vertically centered |
.tuc-table__avatar | 32px circle with uppercase initials; with an <img> inside, the photo covers the circle |
.tuc-table__sub | Subtle second line: email under the name, code under the title |
.tuc-badge | Status badge: is-success, is-warning, is-danger, is-info; with no tone it's neutral |
.tuc-table__actions | Column pushed to the right, without line wrapping, for the system buttons — tuc-btn is-outline is-icon is-sm |
.is-number | On th and td: right-aligns with fixed-width digits, to compare values at a glance |
data-tuc-format="brl" | On the amount cell: the 12400.00 Django prints becomes R$ 12.400,00. Accepts a dot or comma as decimal separator; data-tuc-format="currency" formats without the symbol |
.tuc-table__empty | Single empty-state cell, centered and with breathing room |
A table cell doesn't become flex
display: flex on a <th> or <td> takes the cell out of the column calculation: it stops taking part in the width and draws its own box on top of the header. To push to the right use text-align, as .tuc-table__actions does; flex goes on the content, as in .tuc-table__user. And the second line's ellipsis only shows up with a width on the column — without it the column grows with the text: <th style="width:38%">.
If it's clickable, it looks like a button
The actions column uses the system's outlined buttons, not loose icons: with no border and no background, an icon reads as disabled decoration. Horizontal scrolling lives in .tuc-table-wrap, which the script creates if it doesn't exist — a <table> with overflow loses its table behavior.
Keyboard and accessibility
Everything the table adds is a native element: link, button and checkbox. That's why each one is already
in the Tab path and responds to the usual keys.
| Key | Action |
|---|---|
Tab | Goes through the sortable headers, the checkboxes and each row's buttons |
Enter | On the header, sorts — follows the link in server mode |
Space | Checks or unchecks the checkbox; in client mode, also sorts through the header button |
The sorted header announces aria-sort="ascending" or "descending"; the others,
"none". The header checkbox is labeled "Selecionar todas as linhas desta página" and each row's,
"Selecionar linha"; the mixed state is the native indeterminate, which the screen reader announces. The sort
arrow is aria-hidden: the attribute tells the direction, not the drawing.
API
Generated from the code on every build — if something is not here, it does not exist.
table[data-tuc-table]new Tucano.Table(alvo, opcoes)data-dir-param data-select-name data-selectable data-sort-mode data-sort-param data-sortablesort getSelected clearSelection destroytucano:sort tucano:selectOptions
The notes in this table come from comments in the source code, which are written in Portuguese.
| Option | Default | What for |
|---|---|---|
sortable | true | |
sortMode | 'server' | server | client |
sortParam | 'sort' | |
dirParam | 'dir' | |
selectable | false | coluna de selecao em massa |
selectName | 'selected' | |
onSort | null | definido, intercepta o clique e cancela a navegacao |
onSelect | null |