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.
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.
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.
| Key | Action |
|---|---|
Tab | Reaches the checkbox or the switch; in a radio group, the checked one |
Space | Checks 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 attribute | Where | What for |
|---|---|---|
tuc-check | input type="checkbox" | Checkbox; the mixed state is the native indeterminate |
tuc-radio | input type="radio" | Radio — one of several with the same name |
tuc-switch | input type="checkbox" | Switch; requires role="switch" on the input |
tuc-choice | label | Joins control and text, with the control aligned to the first line |
tuc-choice__hint | span inside the text | Description below the text |
is-end | .tuc-choice | Control on the right and the whole row clickable |
tuc-choices | fieldset or div | Stacked group; removes the fieldset's border, padding and min-width |
is-inline | .tuc-choices | Options side by side, wrapping |
disabled | input | Fades the control and the label text, with a not-allowed cursor |
aria-invalid="true" | input | Error border while unchecked; :user-invalid does the same after the person interacts |