Tucano v0.37.2

Checkbox, radio and switch

All three are the native <input> with a different look, and nothing else. The name, the value, the required and the screen reader announcement are still the browser's — the form posts the way it always has, and there is no JavaScript at all.

Checkbox
Shipping
Switch

Examples

Everything below is the same <input> with different classes and attributes.

Mixed state

The native indeterminate becomes a dash. It does not exist as an HTML attribute — only as a property, in JavaScript.

Side by side

is-inline on the group puts the options on the same line, wrapping when they do not fit.

Billing cycle

With an error

aria-invalid="true" on the input paints the border while it is unchecked — Django 5 already writes the attribute.

Accept the terms to continue.

Control on the right

is-end makes the whole row clickable, with the controls lined up in a column — the preferences list.

How to use

A class on the input and a <label class="tuc-choice"> around it. Clicking the text checks it, and the screen reader reads the text as the control's name — no for or id needed.

<label class="tuc-choice">
  <input type="checkbox" class="tuc-check" name="invoice"> Invoice by email
</label>

<fieldset class="tuc-choices">
  <legend>Shipping</legend>
  <label class="tuc-choice">
    <input type="radio" class="tuc-radio" name="shipping" value="standard" checked>
    <span>Standard <span class="tuc-choice__hint">Up to 7 business days</span></span>
  </label>
  <label class="tuc-choice">
    <input type="radio" class="tuc-radio" name="shipping" value="express">
    <span>Express <span class="tuc-choice__hint">Arrives tomorrow</span></span>
  </label>
</fieldset>

<label class="tuc-choice is-end">
  <input type="checkbox" role="switch" class="tuc-switch" name="notifications"> Email notifications
</label>

With a description, the text and .tuc-choice__hint go inside a <span>: that is what keeps the two stacked next to the control, aligned with the first line.

In a Django form

The class goes in the widget's attrs. For choices (RadioSelect, CheckboxSelectMultiple), loop over the field in the template: each item hands you the ready input in tag and the text in choice_label.

class PreferencesForm(forms.Form):
    invoice = forms.BooleanField(
        required=False, label="Invoice by email",
        widget=forms.CheckboxInput(attrs={"class": "tuc-check"}),
    )
    notifications = forms.BooleanField(
        required=False, label="Email notifications",
        widget=forms.CheckboxInput(attrs={"class": "tuc-switch", "role": "switch"}),
    )
    shipping = forms.ChoiceField(
        choices=[("standard", "Standard"), ("express", "Express")],
        widget=forms.RadioSelect(attrs={"class": "tuc-radio"}),
    )
<label class="tuc-choice">{{ form.invoice }} {{ form.invoice.label }}</label>

<label class="tuc-choice is-end">{{ form.notifications }} <span>{{ form.notifications.label }}</span></label>

<fieldset class="tuc-choices">
  <legend>{{ form.shipping.label }}</legend>
  {% for option in form.shipping %}
    <label class="tuc-choice">{{ option.tag }} {{ option.choice_label }}</label>
  {% endfor %}
</fieldset>

The POST is plain HTML: a checked box sends its value (or on, without one), an unchecked one sends nothing — which is why BooleanField needs required=False.

Mixed state

For the box that summarizes a group, like a table's "select all". It only exists as a property:

document.querySelector('#all').indeterminate = true;

A switch is for things that happen right away

A checkbox is for things that only take effect on save. A switch in a form that still asks you to "Save" promises an effect that has not happened.

Why they are drawn

accent-color would solve it in one line, but it hands you the system's look: on macOS a blue that is not yours, on Windows different corners, and neither with the radius of the rest of your fields.

And it only paints the checked box — the radio next to it would keep the system circle. Drawn, the three match each other and the neighboring input on any system, and use the same tokens: the accent is --tuc-accent, the focus ring is the fields' --tuc-accent-ring.

Holds up against the project's CSS

The classes are repeated in the selector (input.tuc-check.tuc-check) to beat common rules like input[type=checkbox] { ... } and .card label { display: block }, which with a single class would break the row apart.

Keyboard and accessibility

It is all the browser's, because the control is still the input. The <fieldset> with <legend> gives the screen reader the name of the radio group.

KeyAction
TabReaches the checkbox or the switch; in a radio group, the checked one
SpaceChecks and unchecks the checkbox, turns the switch on and off
↑ ↓ ← →Moves between the radios with the same name, checking them

The switch needs role="switch" in the template

CSS does not add a role. The role is what makes the screen reader say "switch, on" instead of "checkbox, checked" — and anyone who forgets it still has a working checkbox, just announced under the wrong name.

With prefers-reduced-motion, the check and switch-slide transitions are turned off.

Classes

CSS only. There is no component to initialize and no custom event: listen to the native change.

Class or attributeWhereWhat for
tuc-checkinput type="checkbox"Checkbox; the mixed state is the native indeterminate
tuc-radioinput type="radio"Radio — one of several with the same name
tuc-switchinput type="checkbox"Switch; requires role="switch" on the input
tuc-choicelabelJoins control and text, with the control aligned to the first line
tuc-choice__hintspan inside the textDescription below the text
is-end.tuc-choiceControl on the right and the whole row clickable
tuc-choicesfieldset or divStacked group; removes the fieldset's border, padding and min-width
is-inline.tuc-choicesOptions side by side, wrapping
disabledinputFades the control and the label text, with a not-allowed cursor
aria-invalid="true"inputError border while unchecked; :user-invalid does the same after the person interacts