Tucano v0.37.2

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.

No rows checked.
Customer Status Due date Amount Actions
VCVale ConstructionTax ID 12-3456789 Under review 12/09/2026 R$ 12.400,00
SFSerra FarmsTax ID 98-7654321 Approved 03/07/2026 R$ 3.890,50
HWHorizon WoodworksTax ID 45-6789012 Overdue 28/11/2026 R$ 760,00
ATAurora TransportTax ID 23-4567890 New 19/02/2026 R$ 45.120,90

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.

ProductStock
6 mm screw1,240
Hex nut860
Flat washer3,105
8 mm wall plug412

With grid

is-bordered closes the table and separates the columns.

MonthRevenueExpenses
JulyR$ 48.200R$ 31.900
AugustR$ 52.750R$ 33.400
SeptemberR$ 50.100R$ 35.020

Compact

is-compact reduces cell spacing, for long lists.

CodeDescriptionStatus
NF-1021Maintenance servicePaid
NF-1022Parts replacementPending
NF-1023Technical visitDraft
NF-1024InstallationCanceled

Empty state

A table with no rows is still a table: a single cell, with tuc-table__empty.

CustomerDue dateAmount
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.

TimeEvent
08:00Register opened
08:12Sale 3391
08:40Sale 3392
09:05Cash drop
09:31Sale 3393
10:02Sale 3394
10:48Return 88
11:20Sale 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.

PersonRole
ALAna Limaana.lima@valeconstruction.comAdministrator
RSRafael Souzarafael@serrafarms.comFinance

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 listeners

Sorting

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 Studio05/10/2026R$ 2.300,00
Warm Bread Bakery21/08/2026R$ 480,00
Wellness Clinic01/12/2026R$ 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

AttributeWhereWhat 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');
  },
});
AttributeDefaultWhat for
data-sort-modeserverclient sorts on screen
data-sort-paramsortName of the field parameter in the URL
data-dir-paramdirName of the direction parameter; the values are asc and desc
data-sortabletruefalse 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
});
AttributeDefaultWhat for
data-selectableoffTurns on the selection column
data-select-nameselectedThe 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.

ClassWhat it does
.tuc-table__userRow with avatar and text, vertically centered
.tuc-table__avatar32px circle with uppercase initials; with an <img> inside, the photo covers the circle
.tuc-table__subSubtle second line: email under the name, code under the title
.tuc-badgeStatus badge: is-success, is-warning, is-danger, is-info; with no tone it's neutral
.tuc-table__actionsColumn pushed to the right, without line wrapping, for the system buttons — tuc-btn is-outline is-icon is-sm
.is-numberOn 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__emptySingle 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.

KeyAction
TabGoes through the sortable headers, the checkboxes and each row's buttons
EnterOn the header, sorts — follows the link in server mode
SpaceChecks 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.

Markup
table[data-tuc-table]
In JS
new Tucano.Table(alvo, opcoes)
Attributes
data-dir-param data-select-name data-selectable data-sort-mode data-sort-param data-sortable
Methods
sort getSelected clearSelection destroy
Events
tucano:sort tucano:select

Options

The notes in this table come from comments in the source code, which are written in Portuguese.

OptionDefaultWhat for
sortabletrue
sortMode'server'server | client
sortParam'sort'
dirParam'dir'
selectablefalsecoluna de selecao em massa
selectName'selected'
onSortnulldefinido, intercepta o clique e cancela a navegacao
onSelectnull