Form
Label, help text, error message and the text field, with classes only and no JavaScript. The field
that failed is marked by aria-invalid="true", the attribute the screen reader announces and that Django 5
writes on its own — so {{ field }} comes out red without anyone writing a thing.
As it appears on the registration certificate.
Enter a complete email address.
You can change it later, with no fee.
Examples
The same four classes in different situations.
Required
is-required on the label adds the asterisk; required goes on the field.
Browser validation
Painted by :user-invalid, only after the field is touched. Type something without @ and leave.
With an icon
.tuc-input-group with an SVG before the field.
Disabled
Native disabled; nothing else.
Short list
data-tuc-select with data-search="false": no search box, and with the aria-invalid error.
Choose a state.
Multiple choices
With multiple the choices become tags, and getlist() still receives one per option.
The error works on the components
The same aria-invalid="true" paints the fields the script replaces with its own control. The attribute stays on the
native element, and the CSS reaches the control from there.
Choose the customer.
The date must be after today.
Invalid CNPJ.
This color is already in use.
Required to continue.
How to use
A label with for, and help and error tied to the field by aria-describedby. That is what makes the
screen reader read the help and the error along with the field name.
<label class="tuc-label is-required" for="doc">CNPJ</label>
<input id="doc" name="doc" data-tuc-mask="cnpj" required aria-invalid="true" aria-describedby="doc-e">
<p class="tuc-error" id="doc-e">Invalid CNPJ.</p>
<label class="tuc-label" for="plan">Plan</label>
<select id="plan" name="plan" data-tuc-select aria-describedby="plan-h">
<option>Monthly</option>
<option>Yearly</option>
</select>
<p class="tuc-hint" id="plan-h">You can change it later.</p>
<textarea class="tuc-input" name="notes" rows="3"></textarea>The field components — date picker, mask, color picker — put on .tuc-input by themselves. Write the class on
plain fields, so the whole form row has the same height.
In the Django template
{# Django 5 already puts aria-invalid and aria-describedby on the field #}
<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 help id is the one Django 5 points to in the field's aria-describedby, <field id>_helptext.
With this snippet in an {% include %}, every field in the project gets the same label, help and error.
In the Django form
class CustomerForm(forms.ModelForm):
class Meta:
model = Customer
fields = ["company", "email", "plan", "notes"]
widgets = {
"company": forms.TextInput(attrs={"class": "tuc-input"}),
"email": forms.EmailInput(attrs={"class": "tuc-input"}),
"plan": forms.Select(attrs={"data-tuc-select": ""}),
"notes": forms.Textarea(attrs={"class": "tuc-input", "rows": 3}),
}In JavaScript
There is no component: to flag an error only the client finds, write the attribute, and remove it once it is fixed.
const field = document.querySelector('#email');
field.setAttribute('aria-invalid', 'true');
field.setAttribute('aria-describedby', 'email-e');
// fixed:
field.removeAttribute('aria-invalid');Why an attribute, and not a class
A red class only serves those who can see it. aria-invalid serves the screen and the screen reader at the same
time, which is why the two cannot drift apart.
Since Django 5.0 the form writes the attribute on every field that came back with an error. On components that replace the field
with their own control — select, color picker, editor, upload — the attribute stays on the native element, and the CSS reaches the control
through select[aria-invalid] + .tuc-select and :has(). No component copies the attribute, so none of them
falls out of sync when HTMX swaps the field. Even before the script mounts, the raw field already starts with a red border.
Browser validation paints, but only after the field is touched
An empty required field or a type="email" without @ turn red through :user-invalid when the person leaves it or tries to submit — and not on page load, which would start out all red. A browser that doesn't know the selector simply doesn't paint this case.
Green when it passes, only where you asked
With data-validate on a native .tuc-input — email, required, pattern —, the field turns green through :user-valid once the person fills it in correctly. Without the attribute nothing changes: a whole form going green is noise. On a mask with data-validate the green shows while typing. Errors win: a field with aria-invalid="true" never turns green.
On focus the ring is red too, and doesn't go back to the accent color: the error still holds while it is being fixed. On the checkbox and the
radio the error shows only while they are unchecked — once checked, the accent color says more than an error that no longer applies.
The is-required asterisk is decoration only: the screen reader already announces required, and hearing "asterisk"
after the name would get in the way.
Lives alongside the project's CSS
label and input[type=text] are among the tags every project styles, and .card label would beat a single class. The classes here repeat in the selector to weigh more, so the label and the field don't fall apart inside the cards of your layout.
Classes
CSS only. None of them needs JavaScript.
| Class or attribute | Where | What for |
|---|---|---|
tuc-label | <label> | Label above the field |
is-required | with tuc-label | Asterisk after the name, hidden from the screen reader |
tuc-hint | below the field | Help text |
tuc-error | below the field | Error message |
tuc-input | <input> <textarea> <select> | Field with the library's height, border and focus; on <select>, the arrow |
tuc-input-group | around the field | Makes room for an <svg> on the left |
aria-invalid="true" | on the native field | Error border and ring, on any field in the library |
:user-invalid | automatic | Browser validation error, after the person touches the field |
tuc-invalid | <input> | Set by the mask when validation fails, together with aria-invalid; don't write it by hand |