Tucano v0.37.2

Django

Tucano was designed for the Django way: the field is still the form's native element, the value reaches request.POST the way the form expects it, and everything is configured through attributes — in the form's widgets, without a line of JavaScript.

Below each field, what the form would submit right now. Change the values to see it.

Loading

Copy the dist/ folder into the project's static files and load both files in the base template. defer lets the script run after the HTML, and the CSS already draws the raw field at its final size, so nothing jumps when it initializes.

{% load static %}
<link rel="stylesheet" href="{% static 'tucano/tucano.min.css' %}">
<script src="{% static 'tucano/tucano.min.js' %}" defer></script>

Widgets

Each component is a data-tuc-* attribute. In Django it goes through the widget's attrs, and an empty value is enough: the attribute being there is what turns it on.

class ContractForm(forms.ModelForm):
    class Meta:
        model = Contract
        fields = ["event_date", "due_date", "state", "tags", "color", "document", "description", "attachment"]
        widgets = {
            "event_date":  forms.TextInput(attrs={"data-tuc-datepicker": "", "data-time": "true"}),
            "due_date":    forms.TextInput(attrs={"data-tuc-datepicker": "", "data-min": "2026-01-01"}),
            "state":       forms.Select(attrs={"data-tuc-select": ""}),
            "tags":        forms.SelectMultiple(attrs={"data-tuc-select": "", "data-max-items": "3"}),
            "color":       forms.TextInput(attrs={"data-tuc-color": "", "data-alpha": "false"}),
            "document":    forms.TextInput(attrs={"data-tuc-mask": "cpf-cnpj", "data-validate": "true"}),
            "description": forms.Textarea(attrs={"data-tuc-editor": ""}),
            "attachment":  forms.ClearableFileInput(attrs={"data-tuc-upload": "", "data-max-size": "5mb"}),
        }

Use TextInput for the date picker, not DateInput

DateInput renders type="date", and the browser draws its own calendar next to ours. The text field with data-tuc-datepicker is what gives you the library's panel; if you want the system picker on phones, ask for it with data-native.

With {{ form }}, {{ form.as_div }} or field by field, the attributes end up in the HTML and each field initializes itself on page load and after every HTMX swap.

What reaches the POST

The native element still owns the value, so the server receives what it would receive without the library. The difference is where it helps the form: the date arrives in ISO, not in the on-screen format.

event_date  = 2026-12-25T09:30          # DateTimeField parses it directly
due_date    = 2026-12-25                # so does DateField
period      = 2026-03-01,2026-03-15     # split on the comma in clean_
tags        = django                    # request.POST.getlist("tags")
tags        = htmx
color       = #4f46e5
document    = 123.456.789-09            # the field's text, formatted
FieldWhat arrivesWhy
Date pickerISO: 2026-12-25, and 2026-12-25T09:30 with data-time — with data-seconds, the seconds tooThe visible field shows the locale's format and has no name; an <input type="hidden"> with the original name carries the ISO value
Period2026-03-01,2026-03-15Start and end in ISO, separated by a comma, in a single field
Multiple selectOne value per checked optionThe native <select multiple> is what posts: read it with getlist()
Color picker#4f46e5; with opacity below 1, #4f46e580data-format switches to rgb or hsl; data-alpha="false" removes opacity
MaskThe formatted text, as it is in the fieldThe mask is pure behavior on top of the <input>. With data-tuc-reveal, a hidden input with the name carries the unformatted content
Upload without data-urlThe files, in request.FILESThey stay in the native <input type="file"> and go up on submit — the form needs enctype="multipart/form-data"
Upload with data-urlThe ids the endpoint returned, under the input's nameEach file was already uploaded right away; the form posts only the reference
EditorSanitized HTML, in the <textarea>The textarea is still the field; sanitize again on the server before publishing

Period in the form

Django has no range field, so the period arrives as text and gets split in clean_.

The period goes back into the field in the on-screen format

The date picker reads a period's initial value in the format it displays — in Brazilian Portuguese, 01/03/2026 — 15/03/2026. When the form comes back with an error, Django writes back into the field exactly what was posted — the ISO value with a comma. A widget that returns the display format keeps the field right in both cases, the posted value and the initial one.

from datetime import date

from django import forms


class PeriodInput(forms.TextInput):
    def format_value(self, value):
        if isinstance(value, str) and "," in value:
            try:
                value = [date.fromisoformat(p) for p in value.split(",", 1)]
            except ValueError:
                return value
        if isinstance(value, (list, tuple)) and len(value) == 2:
            return f"{value[0]:%d/%m/%Y} — {value[1]:%d/%m/%Y}"
        return super().format_value(value)

A plain date and a date with time don't need this: the value Django writes — 2026-12-25 or 2026-12-25 09:30:00 — is read directly, like the first field in the example above.

Form errors

A field with an error is aria-invalid="true", not a class. That's the attribute the screen reader announces, and Django 5 writes it on its own on every field that came back with an error — so {{ field }} comes out red without anyone writing a thing.

{# Django 5 already puts aria-invalid on the field with an error, and aria-describedby pointing to the help text #}
<label class="tuc-label{% if field.field.required %} is-required{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<p class="tuc-hint" id="{{ field.auto_id }}_helptext">{{ field.help_text }}</p>{% endif %}
{% for error in field.errors %}<p class="tuc-error">{{ error }}</p>{% endfor %}

The attribute stays on the native element, and the CSS reaches the control the script built: the select, the color field, the editor and the upload zone get the error border and ring through the native element inside them. No component copies the attribute, which is why none of them gets out of sync when HTMX swaps the field.

Invalid CNPJ.

This field is required.

For Django 4 or earlier, put the attribute on the widget when the field has an error — in the form's full_clean(), after validation:

class BaseForm(forms.Form):
    def full_clean(self):
        super().full_clean()
        for name in self.errors:
            if name in self.fields:
                self.fields[name].widget.attrs["aria-invalid"] = "true"

The browser's own validation also paints the field, but only after the person interacts: an empty required field turns red through :user-invalid when you leave it or try to submit, not on page load.

Messages as toasts

The messages framework becomes a toast without JavaScript. Put the loop in the base template; each <div> is read, becomes a toast and leaves the DOM.

{% for m in messages %}
  <div data-tuc-toast data-type="{{ m.tags }}">{{ m }}</div>
{% endfor %}

The debug, info, success, warning and error levels are mapped automatically — debug becomes info. extra_tags don't get in the way: Django writes them before the level ("highlight success"), and the component uses the first word that is a known type.

AttributeWhat for
data-typeType: Django's levels, or loading
data-titleBold title above the text
data-textText; without it, the content of the <div> is used
data-durationTime on screen, in ms; false doesn't close on its own
data-positiontop-start, top-center, top-end, bottom-start, bottom-center or bottom-end (default)
from django.contrib import messages

def save(request, pk):
    ...
    messages.success(request, "Contract saved.")
    return redirect("contract", pk=pk)

HTMX

HTMX is already covered. The library initializes again on every htmx:afterSwap, inside the swapped fragment, so a field that arrives through a swap works without you doing anything. Anything already set up has data-tuc-ready and is skipped.

Toast triggered by the server

An HTMX response has no redirect and no base template for the messages loop. The server sends the toast through the HX-Trigger header, and the library listens for the tucano:toast event on <body>:

import json

def save(request, pk):
    ...
    return HttpResponse(status=204, headers={"HX-Trigger": json.dumps(
        {"tucano:toast": {"type": "success", "text": "Contract saved"}})})

The object accepts the same options as Tucano.toast(): type, title, text, duration, position. A bare string instead of the object becomes an info toast. If the swapped fragment brings <div data-tuc-toast> elements, they become toasts too.

A field that fires a request

Every component fires the native change, so hx-trigger="change" works with no tweaks. In the date picker, though, the element with the name is the hidden input with the ISO value, not the visible field: put hx-get on the form, which includes every field, instead of on the input.

<form hx-get="{% url 'agenda' %}" hx-trigger="change" hx-target="#results">
  <input type="text" name="day" data-tuc-datepicker>
  <select name="room" data-tuc-select>...</select>
</form>

Replacing a select's options

An existing select that receives new <option> elements through hx-swap="innerHTML" is not initialized again — it is already set up. Ask it to re-read its options:

<select name="state" data-tuc-select hx-get="{% url 'cities' %}" hx-target="#city">...</select>
<select name="city" id="city" data-tuc-select></select>
document.addEventListener('htmx:afterSwap', (e) => {
  if (e.target.matches('select[data-tuc-select]')) e.target._tucano?.refresh();
});

HTML that didn't come through HTMX

The automatic setup covers page load and htmx:afterSwap. If the HTML comes in through fetch and innerHTML, call Tucano.init(node) on the new fragment. And through npm, importing from tucano, nothing initializes on its own: import tucano/auto, which boots and listens to HTMX like the CDN script.

CSRF on upload

With direct upload each file goes up in its own request, outside the form submit — and the form's {% csrf_token %} doesn't go along. That's why the component reads the csrftoken cookie and sends the value in the X-CSRFToken header, which is what Django checks. Removal through data-delete-url carries the same header.

<form method="post" action="{% url 'gallery-save' %}">
  {% csrf_token %}
  <input type="file" name="photos" multiple accept="image/*"
         data-tuc-upload data-url="{% url 'upload-temp' %}" data-max-size="5mb">
  <button class="tuc-btn is-primary">Save</button>
</form>
def upload_temp(request):
    uploaded = request.FILES["file"]             # data-field-name changes the "file"
    temp = TempUpload.objects.create(file=uploaded)
    return JsonResponse({"id": str(temp.id), "url": temp.file.url})

def gallery_save(request):
    ids = request.POST.getlist("photos")         # the ids returned above
    ...

The cookie can only be read if JavaScript can see it. With CSRF_COOKIE_HTTPONLY = True or a different CSRF_COOKIE_NAME, pass the token yourself — the form's {% csrf_token %} has already put the value on the page:

const token = document.querySelector('[name=csrfmiddlewaretoken]').value;
new Tucano.Upload('#photos', { url: '/upload/', headers: { 'X-CSRFToken': token } });

HTMX has the same problem from the other side: its requests need the header too. Declare it once on <body>:

<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>

Remember the cleanup

With direct upload the file reaches the server before the form is saved. If the person uploads and closes the tab, it stays there: a task that deletes temporary files older than 24 hours takes care of it.

Table and pagination with Paginator

The table sorts on the server and the pagination is made for Paginator. Both are real links that keep the rest of the query string, so sort order, filter and page live together in the same URL.

from django.core.paginator import Paginator

ORDERS = {"customer": "name", "amount": "amount", "due_date": "due_date"}

def contracts(request):
    qs = Contract.objects.all()
    if q := request.GET.get("q"):
        qs = qs.filter(name__icontains=q)

    field = ORDERS.get(request.GET.get("sort"), "name")
    if request.GET.get("dir") == "desc":
        field = f"-{field}"
    qs = qs.order_by(field, "pk")               # sort BEFORE paginating

    page_obj = Paginator(qs, 20).get_page(request.GET.get("page"))
    return render(request, "contracts/list.html", {"page_obj": page_obj})
<form method="get">
  <input class="tuc-input" type="search" name="q" value="{{ request.GET.q }}">
  {# keeps the sort order when filtering #}
  <input type="hidden" name="sort" value="{{ request.GET.sort }}">
  <input type="hidden" name="dir" value="{{ request.GET.dir }}">
</form>

<table data-tuc-table>
  <thead><tr>
    <th data-sort="text" data-field="customer">Customer</th>
    <th data-sort="date" data-field="due_date">Due date</th>
    <th data-sort="number" data-field="amount" class="is-number">Amount</th>
  </tr></thead>
  <tbody>
    {% for obj in page_obj %}
    <tr data-id="{{ obj.pk }}">
      <td>{{ obj.name }}</td>
      <td>{{ obj.due_date|date:"d/m/Y" }}</td>
      <td class="is-number" data-tuc-format="brl">{{ obj.amount }}</td>
    </tr>
    {% empty %}
    <tr><td colspan="3" class="tuc-table__empty">No contracts found.</td></tr>
    {% endfor %}
  </tbody>
</table>

<div data-tuc-pagination
     data-page="{{ page_obj.number }}"
     data-pages="{{ page_obj.paginator.num_pages }}"></div>
Click onThe URL becomes
The "Amount" header?q=vale&sort=amount&dir=asc — page is dropped, because changing the sort order goes back to page 1
The same header again?q=vale&sort=amount&dir=desc
Page 3?q=vale&sort=amount&dir=desc&page=3

Map ?sort through a closed list, like ORDERS above: passing the parameter straight to order_by() would let anyone sort by any field, and a name that doesn't exist crashes the view. The "pk" at the end breaks ties — without a total order, the same row can show up on two pages. With a single page, the pagination is not rendered. The details are on the Table and Pagination pages.