Checkbox
A control that can be on, off, or neither.
src/components/rahti_ui/checkbox.rs
Basics
Three states, all rendered by the server. The tick and the dash are lucide's `check` and `minus`, drawn as background images rather than as an Indicator child — an `<input>` is void and can hold no icon. Clicking any of these works with JavaScript switched off, which is the whole reason this is an `<input>` and not shadcn's `<button role="checkbox">`.
States
A `false` leaves the attribute off rather than writing `checked="false"`, which HTML would read as checked. `.invalid(true)` writes `aria-invalid`, and the `aria-invalid:` utilities in the class list do the rest — the styling and the accessible state are the same fact, spelled once.
With a Label
The two ways HTML associates a label with a control, and both make the text clickable. `Label`'s class list is `flex items-center gap-2`, so the first example only has to override `items-center` to line a two-line description up with the box.
Disabled, and a Label that knows
This is why shadcn's class list opens with `peer`. The Label carries `peer-disabled:cursor-not-allowed peer-disabled:opacity-50`, which fires when a preceding sibling marked `peer` is disabled — the two components are built to sit next to each other, and these are plain tags — a Checkbox and a Label with nothing overridden each render as one literal element, so the box really is the label's preceding sibling.
A group, as a form posts it
One `name`, three `value`s — HTML's own way to post a set, and it needs no JavaScript and no array in the page. A checkbox with no `value` posts `on`, which is HTML's rule rather than this component's to restate.
Attributes
`.attr(…)` writes anything; `.unset(…)` takes one away, which is the thing a value cannot express and the reason a forwarded set is an `Attrs` rather than a list of pairs. The last box has no visible label, so it carries an `aria-label` instead — a prop for that would be a prop for every ARIA attribute.
Class override
`class` is merged, not appended: a utility written here replaces the one it conflicts with and leaves the rest of the box alone. The merge is `twMerge`'s, in the browser, so unlike every other box on this page these three are unstyled until PulsePoint mounts — and on a checkbox that shows, because `appearance-none` is one of the utilities that is missing until then.
What this port changed
shadcn's Checkbox is a Radix `CheckboxPrimitive.Root`: a `<button role="checkbox" data-state>` with an Indicator child and a hidden input beside it for the form. A button does not tick itself, so every one of those boxes needs JavaScript. This one is a native `<input type="checkbox">`, which ticks, submits, focuses and reads correctly with no runtime at all — the same bet Input, Textarea and Button already made.
| shadcn | here | why |
|---|---|---|
| data-[state=checked]: | checked: | Radix keeps state in a data attribute because a button has none. A native checkbox has `:checked`. |
| — | appearance-none | shadcn is styling a button. A checkbox draws its own box, and shadcn's border and background have nothing to sit on until that is gone. |
| <CheckIcon /> | background-image | An `<input>` is void and can hold no children. Same lucide path, same 14px, same stroke width — but not an element, so there is no `checkbox-indicator` slot. |
| indeterminate | data-indeterminate | A native checkbox's third state is a DOM property with no attribute behind it. Nothing is lost — the UA's rendering is already switched off — but the server writes the dash itself, plus `aria-checked="mixed"`. |
Everything else — every colour, every ring, every `dark:` — is shadcn's, copied rather than reinterpreted.
Controlled, page scope
`.render()` returns the element itself, with no boundary around it, so interpolating it into this block puts it in this block's scope and both the binding and the handler resolve. A checkbox reports itself through `target.checked`, not `target.value`.
{agreed ? "Accepted — the button below is live." : "Not accepted yet."}
Indeterminate, driven by its children
The third state is where a parent box sits when some of its children are ticked. `data-indeterminate` is bound here rather than written, because the state moves — and it is bound as `String(…)`, since the selector Tailwind compiles asks for `[data-indeterminate="true"]` by value and a bare `true` would arrive as an empty attribute.
{picked.length} of 3 picked.
The component tag, and its boundary
The tag is the right call for a box this page does not drive — a name, a value, a `checked` default, a `required`. What a plain form posts, the tag renders, and every box above the reactive sections is one.
Where the tag stops
Twice, and the second is specific to this component. A binding does not cross the boundary: PulsePoint owns `checked` and the events that maintain it, and takes that ownership in the scope the element is mounted in, which through a tag is the component's.
// Compiles against a scope with no `agreed`, and does nothing.
<Checkbox checked={agreed} onchange={setAgreed(target.checked)} />What the tag does not stamp is a wrapper, and that took arranging. A component whose `html!` root is a fragment has no element to carry the block's name, so the block is delimited by `<!--pp:id-->` comments — which the runtime turns into a real `<pp-fragment>` at hydration. Both this component and Label render one literal element instead, so `peer` still reaches across and "Disabled, and a Label that knows" works with plain tags.