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| Field | What arrives | Why |
|---|---|---|
| Date picker | ISO: 2026-12-25, and 2026-12-25T09:30 with data-time — with data-seconds, the seconds too | The visible field shows the locale's format and has no name; an <input type="hidden"> with the original name carries the ISO value |
| Period | 2026-03-01,2026-03-15 | Start and end in ISO, separated by a comma, in a single field |
| Multiple select | One value per checked option | The native <select multiple> is what posts: read it with getlist() |
| Color picker | #4f46e5; with opacity below 1, #4f46e580 | data-format switches to rgb or hsl; data-alpha="false" removes opacity |
| Mask | The formatted text, as it is in the field | The 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-url | The files, in request.FILES | They stay in the native <input type="file"> and go up on submit — the form needs enctype="multipart/form-data" |
Upload with data-url | The ids the endpoint returned, under the input's name | Each file was already uploaded right away; the form posts only the reference |
| Editor | Sanitized 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.
| Attribute | What for |
|---|---|
data-type | Type: Django's levels, or loading |
data-title | Bold title above the text |
data-text | Text; without it, the content of the <div> is used |
data-duration | Time on screen, in ms; false doesn't close on its own |
data-position | top-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 on | The 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.