Tabela
Lista renderizada pelo servidor. O <table> do template continua sendo a fonte da
verdade e a célula é livre. O JavaScript entra só onde o HTML não alcança: ordenar pela coluna e marcar
linhas em massa.
Esta demonstração ordena na tela (data-sort-mode="client") porque não há servidor atrás dela. Numa lista paginada, o padrão é o servidor — veja Ordenação.
Exemplos
As variantes são classes na própria tabela. Por padrão as linhas se separam por uma borda de baixo, e não por zebra: numa lista de trinta linhas a zebra vira ruído. Quem quiser zebra pede.
Zebrada
is-striped pinta as linhas pares com --tuc-elevated.
| Produto | Estoque |
|---|---|
| Parafuso 6 mm | 1.240 |
| Porca sextavada | 860 |
| Arruela lisa | 3.105 |
| Bucha 8 mm | 412 |
Com grade
is-bordered fecha a tabela e separa as colunas.
| Mês | Receita | Despesa |
|---|---|---|
| Julho | R$ 48.200 | R$ 31.900 |
| Agosto | R$ 52.750 | R$ 33.400 |
| Setembro | R$ 50.100 | R$ 35.020 |
Compacta
is-compact reduz o espaçamento da célula, para listas longas.
| Código | Descrição | Situação |
|---|---|---|
| NF-1021 | Serviço de manutenção | Paga |
| NF-1022 | Troca de peças | Pendente |
| NF-1023 | Visita técnica | Rascunho |
| NF-1024 | Instalação | Cancelada |
Estado vazio
A tabela sem linhas continua sendo tabela: uma célula só, com tuc-table__empty.
| Cliente | Vencimento | Valor |
|---|---|---|
| Nenhum contrato encontrado com esses filtros. | ||
Cabeçalho fixo
is-sticky vai no envólucro, que ganha altura máxima para ter contra o que grudar.
| Horário | Evento |
|---|---|
| 08:00 | Abertura do caixa |
| 08:12 | Venda 3391 |
| 08:40 | Venda 3392 |
| 09:05 | Sangria |
| 09:31 | Venda 3393 |
| 10:02 | Venda 3394 |
| 10:48 | Devolução 88 |
| 11:20 | Venda 3395 |
Usuário e segunda linha
Foto ou iniciais, nome e uma linha discreta embaixo. Com largura na coluna, a segunda linha corta com reticência.
| Pessoa | Papel |
|---|---|
| ALAna Limaana.lima@construtoravale.com.br | Administradora |
| RSRafael Souzarafael@agroserra.com.br | Financeiro |
Como usar
Marque a <table> com data-tuc-table e ela inicializa sozinha, inclusive a
que chegar depois por HTMX. Em cada coluna ordenável, data-sort diz o tipo e
data-field diz o nome que vai na URL.
<table data-tuc-table data-selectable>
<thead><tr>
<th data-sort="text" data-field="customer" style="width:38%">Cliente</th>
<th data-sort="number" data-field="amount" class="is-number">Valor</th>
<th data-sort="none" class="tuc-table__actions">Ações</th>
</tr></thead>
<tbody>
<tr data-id="12">
<td>Construtora Vale</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="Editar">...</button>
</td>
</tr>
</tbody>
</table>No template do Django
O laço é o de sempre. O data-id da linha é o que a seleção em massa posta, e o
{% empty %} desenha o estado vazio.
<table data-tuc-table data-selectable>
<thead><tr>
<th data-sort="text" data-field="customer" style="width:38%">Cliente</th>
<th data-sort="text" data-field="status">Situação</th>
<th data-sort="number" data-field="amount" class="is-number">Valor</th>
<th data-sort="none" class="tuc-table__actions">Ações</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="Editar">...</a>
</td>
</tr>
{% empty %}
<tr><td colspan="5" class="tuc-table__empty">Nenhum contrato encontrado.</td></tr>
{% endfor %}
</tbody>
</table>O colspan conta a coluna de seleção
Com data-selectable o script acrescenta uma coluna no começo de cada linha, então a célula vazia precisa cobrir uma coluna a mais do que o cabeçalho escrito no template. Pelo mesmo motivo, a linha vazia ganha uma caixa de seleção sem valor; se isso incomodar, renderize a tabela sem data-selectable quando não houver linhas.
Em JavaScript
Para passar onSort e onSelect, que não existem como atributo, ou para ler a seleção.
const t = new Tucano.Table('#contracts', { selectable: true, sortMode: 'client' });
t.getSelected(); // ['12', '15'] — os data-id marcados
t.clearSelection();
t.sort(2, 'desc', 'number');
t.destroy(); // solta os listenersOrdenação
Ordenar é trabalho do servidor, e esse é o padrão. Numa lista paginada, reordenar as vinte linhas que estão na tela produz uma ordem falsa: o maior valor real pode estar na página 7, e a tabela passa a mentir com cara de verdade. Quem sabe ordenar o conjunto inteiro é o banco.
Por isso, no modo servidor, o cabeçalho vira um <a> para a mesma URL com ?sort= e
?dir=. Funciona sem JavaScript nenhum, funciona com o hx-boost do HTMX, abre em outra aba
e volta pelo botão do navegador. A seta sai da query string, então continua certa depois do recarregamento. O
link preserva o resto da query string — filtro e busca não se perdem — e tira o page: continuar na
página 7 com outra ordem mostraria um pedaço do meio de uma lista que ninguém viu.
| Cliente | Vencimento | Valor |
|---|---|---|
| Estúdio Aurora | 05/10/2026 | R$ 2.300,00 |
| Padaria Pão Quente | 21/08/2026 | R$ 480,00 |
| Clínica Bem-Estar | 01/12/2026 | R$ 9.150,00 |
Aqui os cabeçalhos são os links do modo servidor — passe o ponteiro para ver o href. Um onSort cancela a navegação e simula a resposta; no seu projeto o clique carrega a página ordenada.
Na view
?sort e ?dir entram no order_by() antes do Paginator, que é
exatamente onde a ordenação tem de acontecer. Traduza o nome da URL por uma lista fechada: passar o parâmetro
direto ao order_by() deixaria qualquer um ordenar por qualquer campo, e um nome inexistente derruba
a view com 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" desempata: sem ordem total, a mesma linha pode aparecer em duas páginas.
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>A paginação faz a mesma coisa pelo outro lado: o link de página preserva sort e dir, então
virar a página não desfaz a ordem.
Tipos e valor cru
| Atributo | Onde | Para quê |
|---|---|---|
data-sort="text" | <th> | Texto, comparado em pt-BR, sem diferenciar maiúscula e com números em ordem natural |
data-sort="number" | <th> | Número; aceita 1.234,50 e ignora símbolos como R$ |
data-sort="date" | <th> | Data, lida por new Date() — use valor ISO |
data-sort="none" ou ausente | <th> | Coluna sem ordenação |
data-field | <th> | Nome que vai em ?sort=; sem ele vale o índice da coluna |
data-sort-value | <td> | Valor cru, quando o texto exibido não ordena bem |
O valor cru importa no modo cliente: 12/09/2026 não é uma data que o navegador leia no formato
brasileiro, e "há 3 dias" ou "Em análise" não ordenam como texto. Escreva o valor exibido para a pessoa e o
ISO ou o número em data-sort-value.
Na tela, para tabela pequena
data-sort-mode="client" existe para o outro caso: tabela pequena e completa, sem paginação, onde ordenar
na tela é a coisa certa. Ali o cabeçalho vira <button>, porque não há para onde navegar, e o
aria-sort é atualizado a cada clique.
<table data-tuc-table data-sort-mode="client">...</table>Com HTMX
Como o cabeçalho e a paginação são links de verdade, o hx-boost num ancestral basta: o HTMX troca o
conteúdo sem recarregar, a URL acompanha e a tabela nova se inicializa sozinha no htmx:afterSwap.
<div hx-boost="true">
<table data-tuc-table>...</table>
<div data-tuc-pagination data-page="3" data-pages="12"></div>
</div>Para decidir a navegação por conta própria, passe onSort: com ele definido, o clique é cancelado e quem
escreveu assume. O evento tucano:sort sai de qualquer jeito, antes da navegação.
new Tucano.Table('#contracts', {
onSort: ({ column, field, direction }, table) => {
htmx.ajax('GET', `?sort=${field}&dir=${direction}`, '#list');
},
});| Atributo | Padrão | Para quê |
|---|---|---|
data-sort-mode | server | client ordena na tela |
data-sort-param | sort | Nome do parâmetro do campo na URL |
data-dir-param | dir | Nome do parâmetro do sentido; os valores são asc e desc |
data-sortable | true | false desliga a ordenação da tabela inteira |
Seleção em massa
A seleção continua sendo um formulário. Com data-selectable, cada linha ganha um
<input type="checkbox" name="selected"> de verdade com o data-id dela como valor —
no Django chega como request.POST.getlist("selected"), sem JavaScript no meio.
<form method="post" action="{% url 'contracts-approve' %}">
{% csrf_token %}
<table data-tuc-table data-selectable>...</table>
<button class="tuc-btn is-primary">Aprovar selecionados</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")Botão de ação dentro do formulário leva type="button"
Um <button> sem tipo dentro de <form> envia o formulário. Na coluna de ações, escreva type="button" ou use <a>, senão "Editar" vira "Aprovar selecionados".
A caixa do cabeçalho marca e desmarca a página inteira e mostra o estado misto quando só parte está marcada.
Cada mudança dispara tucano:select na tabela, com os valores marcados — os mesmos que o formulário
enviaria.
document.querySelector('#contracts').addEventListener('tucano:select', (e) => {
e.detail.selected; // ['1', '3']
e.detail.row; // a <tr> que mudou
});| Atributo | Padrão | Para quê |
|---|---|---|
data-selectable | desligado | Liga a coluna de seleção |
data-select-name | selected | O name das caixas no POST |
data-id na <tr> | — | O valor da caixa daquela linha; sem ele a caixa vai vazia |
A célula é livre
Dentro do <td> cabe o que você quiser. O que a biblioteca oferece são os arranjos que
aparecem em toda listagem, para ninguém ter de reinventar o alinhamento de um avatar ao lado de um nome.
| Classe | O que faz |
|---|---|
.tuc-table__user | Linha com avatar e texto, alinhados ao centro |
.tuc-table__avatar | Círculo de 32px com iniciais em maiúsculas; com <img> dentro, a foto cobre o círculo |
.tuc-table__sub | Segunda linha discreta: e-mail sob o nome, código sob o título |
.tuc-badge | Etiqueta de estado: is-success, is-warning, is-danger, is-info; sem tom fica neutra |
.tuc-table__actions | Coluna encostada à direita, sem quebra de linha, para os botões do sistema — tuc-btn is-outline is-icon is-sm |
.is-number | Em th e td: alinha à direita com dígitos de largura fixa, para comparar valores com a vista |
data-tuc-format="brl" | Na célula de valor: o 12400.00 que o Django imprime vira R$ 12.400,00. Aceita ponto ou vírgula como decimal; data-tuc-format="currency" formata sem o símbolo |
.tuc-table__empty | Célula única do estado vazio, centrada e com respiro |
Célula de tabela não vira flex
display: flex num <th> ou <td> tira a célula do cálculo de colunas: ela para de participar da largura e desenha a própria caixa por cima do cabeçalho. Para encostar à direita use text-align, como faz .tuc-table__actions; o flex fica no conteúdo, como em .tuc-table__user. E a reticência da segunda linha só aparece com largura na coluna — sem ela a coluna cresce com o texto: <th style="width:38%">.
Se é clicável, tem cara de botão
A coluna de ações usa os botões do sistema com contorno, e não ícones soltos: sem borda e sem fundo, um ícone lê como decoração desativada. A rolagem horizontal fica no .tuc-table-wrap, que o script cria se não existir — uma <table> com overflow perde o comportamento de tabela.
Teclado e acessibilidade
Tudo o que a tabela acrescenta é elemento nativo: link, botão e caixa de seleção. Por isso cada um já
está no caminho do Tab e responde às teclas de sempre.
| Tecla | Ação |
|---|---|
Tab | Passa pelos cabeçalhos ordenáveis, pelas caixas e pelos botões de cada linha |
Enter | No cabeçalho, ordena — segue o link no modo servidor |
Espaço | Marca ou desmarca a caixa; no modo cliente, também ordena pelo botão do cabeçalho |
O cabeçalho ordenado anuncia aria-sort="ascending" ou "descending"; os outros,
"none". A caixa do cabeçalho se chama "Selecionar todas as linhas desta página" e cada linha,
"Selecionar linha"; o estado misto é o indeterminate nativo, que o leitor de tela anuncia. A seta de
ordenação é aria-hidden: quem informa o sentido é o atributo, e não o desenho.
API
Gerada do código a cada build — se algo não está aqui, não existe.
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:selectOpções
| Opção | Padrão | Para quê |
|---|---|---|
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 |