Checkbox
Restyled native checkbox element on the shared field base.
Checkboxes restyle native <input type="checkbox"> elements. Bind state using standard checked, name, and value attributes.
Default
<fieldset class="ui-field-group"> <legend>Preferences</legend> <div class="flex flex-col gap-xs"> <label class="ui-field" data-orientation="horizontal"> <input type="checkbox" data-checkbox-controls="preferences" /> <span data-slot="field-label">Select all</span> </label> <label class="ui-field" data-orientation="horizontal"> <input type="checkbox" name="preferences" value="newsletter" /> <span data-slot="field-label">Subscribe to the newsletter</span> </label> <label class="ui-field" data-orientation="horizontal"> <input type="checkbox" name="preferences" value="terms" checked /> <span data-slot="field-label">Accept terms & conditions</span> </label> </div></fieldset>// primitives/checkbox/checkbox.js"use strict";/** * @fileoverview Checkbox select-all groups. * @description Derives a select-all checkbox's tri-state from its members. * A controller declares `data-checkbox-controls="<name>"` and manages every * checkbox sharing that `name` within the same form (or the document when * unassociated). Checking the controller checks or unchecks all members; * member changes roll back up as checked (all), unchecked (none), or * indeterminate (a subset) — the mixed state is the JS-only `indeterminate` * property (the platform has no content attribute for it), painted by * `:indeterminate` in checkbox.css. Members inside a `<tr>` reflect their * state as `data-state="selected"` on the row, lighting up the table * primitive's selected-row styling. * * State follows the kit's signals division of labor (`base/signals.ts`): the * delegated change listener is the input adapter writing member states into * `state`; `deriveTriState` is the pure derived logic under `computed`; one * `effect` per group writes the controller property and row attributes back * to the DOM, batched to a microtask. * * @example * <table class="ui-table"> * <thead><tr><th><input type="checkbox" data-checkbox-controls="tasks" /></th>...</tr></thead> * <tbody><tr><td><input type="checkbox" name="tasks" /></td>...</tr></tbody> * </table> */import { computed, effect, state } from "../../base/signals.js";import { registerRefresh } from "../../base/zazz-element.js";const CONTROLS_ATTR = "data-checkbox-controls";/** * @description Derives a controller's tri-state from its members' checked * states: `"all"` when every member is checked (and there is at least one), * `"none"` when none are, `"some"` for a subset. * * @param checked - Each member's checked state. * @returns The derived tri-state. */function deriveTriState(checked) { const count = checked.filter(Boolean).length; if (count === 0) return "none"; return count === checked.length ? "all" : "some";}// --- Group discovery ---/** * @description Collects the member checkboxes a controller manages: every * checkbox sharing the controlled `name` in the controller's form, or in the * document when the controller has no form. * * @param controller - The select-all checkbox. * @returns The managed member checkboxes (never the controller itself). * @private */function membersOf(controller) { const name = controller.getAttribute(CONTROLS_ATTR); if (!name) return []; const scope = controller.form ?? controller.ownerDocument; const inputs = scope.querySelectorAll('input[type="checkbox"]'); return Array.from(inputs).filter((input) => input.name === name && input !== controller);}/** * @description Finds the controller managing a member checkbox, if any. * * @param member - A checkbox that may belong to a select-all group. * @returns The controller, or null when the checkbox is unmanaged. * @private */function controllerOf(member) { if (!member.name) return null; const scope = member.form ?? member.ownerDocument; const controllers = scope.querySelectorAll(`input[type="checkbox"][${CONTROLS_ATTR}]`); for (const controller of controllers) { if (controller.getAttribute(CONTROLS_ATTR) === member.name && controller !== member) { return controller; } } return null;}/** Live groups keyed by controller; pruned in `initCheckboxes`. */const groups = new Map();/** * @description Reads members' checked states from the DOM into a group's * signal. The DOM stays the source of truth for the element list. * * @param controller - The group's select-all checkbox. * @private */function recount(controller) { groups.get(controller)?.members.set(membersOf(controller).map((member) => member.checked));}/** * @description Creates the reactive group for a controller: a member-state * signal, the pure tri-state derivation, and one effect writing the * controller's `checked`/`indeterminate` and each member row's * `data-state="selected"` back to the DOM. The effect's first run is * immediate; re-runs batch to a microtask. * * @param controller - The select-all checkbox. * @private */function createGroup(controller) { const members = state(membersOf(controller).map((member) => member.checked)); const tri = computed(() => deriveTriState(members.get())); const dispose = effect(() => { const derived = tri.get(); const checked = members.get(); controller.checked = derived === "all"; controller.indeterminate = derived === "some"; membersOf(controller).forEach((member, index) => { const row = member.closest("tr"); if (!row) return; if (checked[index]) { row.setAttribute("data-state", "selected"); } else if (row.getAttribute("data-state") === "selected") { row.removeAttribute("data-state"); } }); }); groups.set(controller, { members, dispose });}/** * @description Delegated change handler (input adapter): a controller change * fans out to its members imperatively then writes the group signal once; a * member change recounts its group. * * @param event - The change event. * @private */function onChange(event) { const target = event.target; if (!(target instanceof HTMLInputElement) || target.type !== "checkbox") return; if (target.hasAttribute(CONTROLS_ATTR)) { if (!groups.has(target)) createGroup(target); for (const member of membersOf(target)) member.checked = target.checked; recount(target); return; } const controller = controllerOf(target); if (!controller) return; if (!groups.has(controller)) createGroup(controller); recount(controller);}// --- Init ---/** * @description Initializes select-all groups in a scope: prunes groups whose * controllers left the DOM (disposing their effects), creates groups for new * controllers, and recounts existing ones. Idempotent: re-running is a no-op. * * @param scope - Root to scan; defaults to the whole document. */function initCheckboxes(scope = document) { for (const [controller, group] of groups) { if (!controller.isConnected) { group.dispose(); groups.delete(controller); } } const controllers = scope.querySelectorAll(`input[type="checkbox"][${CONTROLS_ATTR}]`); for (const controller of controllers) { if (groups.has(controller)) { recount(controller); } else { createGroup(controller); } }}// Auto-initialize when DOM is ready (only in browser environment)if (typeof window !== "undefined" && typeof document !== "undefined") { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => initCheckboxes()); } else { initCheckboxes(); } // Delegate at document level so groups work wherever checkboxes appear. document.addEventListener("change", onChange); // After a SPA <main> swap, re-scan the new content. registerRefresh(initCheckboxes);}export { deriveTriState, initCheckboxes };/** * checkbox.css — Checkbox (input[type="checkbox"]:not([role="switch"])) * * @layer reset * @requires layers.css, _variables.css * @uses appearance: none — redraws the native control from theme tokens * @uses checked/indeterminate fill with --primary and layer a glyph (never colour alone) * @tokens --ui-checkbox-* (size, background, border, radius, checkmark-mask, indeterminate-mask) * @see checkbox.ts: derives select-all group state (data-checkbox-controls) * into the JS-only indeterminate property; styling keys off :indeterminate */@layer reset { :where(input[type="checkbox"]:not([role="switch"])) { --ui-checkbox-size: var(--step-4_5); --ui-checkbox-background: var(--input); --ui-checkbox-border: var(--border); --ui-checkbox-border--hover: var(--primary); --ui-checkbox-background--checked: var(--primary); --ui-checkbox-border--checked: var(--primary); --ui-checkbox-radius: var(--radius-xs); /* @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --ui-checkbox-checkmark-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cpolyline points='40 144 96 200 224 72' fill='none' stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='32'/%3E%3C/svg%3E"); --ui-checkbox-checkmark-size: var(--step-3); --ui-checkbox-indeterminate-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cline x1='40' y1='128' x2='216' y2='128' fill='none' stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='32'/%3E%3C/svg%3E"); --ui-checkbox-indeterminate-size: var(--step-3); appearance: none; flex-shrink: 0; inline-size: var(--ui-checkbox-size); block-size: var(--ui-checkbox-size); background-color: var(--ui-checkbox-background); border: 1px solid var(--ui-checkbox-border); border-radius: var(--ui-checkbox-radius); cursor: pointer; /* ring renders as box-shadow; transparent outline twin keeps focus visible in forced-colors / high-contrast modes */ --_ring-offset-width: 0px; --_ring-width: 0px; box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--_ring, var(--ring)); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } :where(input[type="checkbox"]:not([role="switch"]):hover) { border-color: var(--ui-checkbox-border--hover); } :where(input[type="checkbox"]:not([role="switch"]):focus-visible) { --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } :where( input[type="checkbox"]:not([role="switch"]):checked, input[type="checkbox"]:not([role="switch"]):indeterminate ) { background-color: var(--ui-checkbox-background--checked); border-color: var(--ui-checkbox-border--checked); background-repeat: no-repeat; background-position: center; } :where(input[type="checkbox"]:not([role="switch"]):checked) { background-image: var(--ui-checkbox-checkmark-mask); background-size: var(--ui-checkbox-checkmark-size); } :where(input[type="checkbox"]:not([role="switch"]):indeterminate) { background-image: var(--ui-checkbox-indeterminate-mask); background-size: var(--ui-checkbox-indeterminate-size); }}Indeterminate
The mixed state is always derived — HTML has no indeterminate attribute, only the JS input.indeterminate property, styled via native :indeterminate. Let a select-all group derive it (below), or set the property from your own script:
input.indeterminate = true; // cleared natively when the user clicksSelect-all groups
A controller checkbox with data-checkbox-controls="<name>" manages every checkbox sharing that name in the same form (or the document when unassociated). Checking it checks or unchecks all members; member changes roll back up as checked, unchecked, or indeterminate for a subset. Members inside a table row also reflect onto the <tr> as data-state="selected".
<table class="ui-table"> <caption> Sprint tasks with managed selection. </caption> <thead> <tr> <th> <input type="checkbox" data-checkbox-controls="tasks" aria-label="Select all tasks" /> </th> <th>Task</th> <th>Assignee</th> <th class="text-right">Due</th> </tr> </thead> <tbody> <tr> <td> <input type="checkbox" name="tasks" value="ZAZZ-101" aria-label="Select Audit focus rings" checked /> </td> <td>Audit focus rings</td> <td>Robin</td> <td class="text-right">Aug 28</td> </tr> <tr> <td> <input type="checkbox" name="tasks" value="ZAZZ-102" aria-label="Select Document table variants" /> </td> <td>Document table variants</td> <td>Sasha</td> <td class="text-right">Aug 29</td> </tr> <tr> <td> <input type="checkbox" name="tasks" value="ZAZZ-103" aria-label="Select Ship checkbox groups" checked /> </td> <td>Ship checkbox groups</td> <td>Quinn</td> <td class="text-right">Sep 01</td> </tr> <tr> <td> <input type="checkbox" name="tasks" value="ZAZZ-104" aria-label="Select Retire legacy tokens" /> </td> <td>Retire legacy tokens</td> <td>Robin</td> <td class="text-right">Sep 03</td> </tr> </tbody></table>// primitives/checkbox/checkbox.js"use strict";/** * @fileoverview Checkbox select-all groups. * @description Derives a select-all checkbox's tri-state from its members. * A controller declares `data-checkbox-controls="<name>"` and manages every * checkbox sharing that `name` within the same form (or the document when * unassociated). Checking the controller checks or unchecks all members; * member changes roll back up as checked (all), unchecked (none), or * indeterminate (a subset) — the mixed state is the JS-only `indeterminate` * property (the platform has no content attribute for it), painted by * `:indeterminate` in checkbox.css. Members inside a `<tr>` reflect their * state as `data-state="selected"` on the row, lighting up the table * primitive's selected-row styling. * * State follows the kit's signals division of labor (`base/signals.ts`): the * delegated change listener is the input adapter writing member states into * `state`; `deriveTriState` is the pure derived logic under `computed`; one * `effect` per group writes the controller property and row attributes back * to the DOM, batched to a microtask. * * @example * <table class="ui-table"> * <thead><tr><th><input type="checkbox" data-checkbox-controls="tasks" /></th>...</tr></thead> * <tbody><tr><td><input type="checkbox" name="tasks" /></td>...</tr></tbody> * </table> */import { computed, effect, state } from "../../base/signals.js";import { registerRefresh } from "../../base/zazz-element.js";const CONTROLS_ATTR = "data-checkbox-controls";/** * @description Derives a controller's tri-state from its members' checked * states: `"all"` when every member is checked (and there is at least one), * `"none"` when none are, `"some"` for a subset. * * @param checked - Each member's checked state. * @returns The derived tri-state. */function deriveTriState(checked) { const count = checked.filter(Boolean).length; if (count === 0) return "none"; return count === checked.length ? "all" : "some";}// --- Group discovery ---/** * @description Collects the member checkboxes a controller manages: every * checkbox sharing the controlled `name` in the controller's form, or in the * document when the controller has no form. * * @param controller - The select-all checkbox. * @returns The managed member checkboxes (never the controller itself). * @private */function membersOf(controller) { const name = controller.getAttribute(CONTROLS_ATTR); if (!name) return []; const scope = controller.form ?? controller.ownerDocument; const inputs = scope.querySelectorAll('input[type="checkbox"]'); return Array.from(inputs).filter((input) => input.name === name && input !== controller);}/** * @description Finds the controller managing a member checkbox, if any. * * @param member - A checkbox that may belong to a select-all group. * @returns The controller, or null when the checkbox is unmanaged. * @private */function controllerOf(member) { if (!member.name) return null; const scope = member.form ?? member.ownerDocument; const controllers = scope.querySelectorAll(`input[type="checkbox"][${CONTROLS_ATTR}]`); for (const controller of controllers) { if (controller.getAttribute(CONTROLS_ATTR) === member.name && controller !== member) { return controller; } } return null;}/** Live groups keyed by controller; pruned in `initCheckboxes`. */const groups = new Map();/** * @description Reads members' checked states from the DOM into a group's * signal. The DOM stays the source of truth for the element list. * * @param controller - The group's select-all checkbox. * @private */function recount(controller) { groups.get(controller)?.members.set(membersOf(controller).map((member) => member.checked));}/** * @description Creates the reactive group for a controller: a member-state * signal, the pure tri-state derivation, and one effect writing the * controller's `checked`/`indeterminate` and each member row's * `data-state="selected"` back to the DOM. The effect's first run is * immediate; re-runs batch to a microtask. * * @param controller - The select-all checkbox. * @private */function createGroup(controller) { const members = state(membersOf(controller).map((member) => member.checked)); const tri = computed(() => deriveTriState(members.get())); const dispose = effect(() => { const derived = tri.get(); const checked = members.get(); controller.checked = derived === "all"; controller.indeterminate = derived === "some"; membersOf(controller).forEach((member, index) => { const row = member.closest("tr"); if (!row) return; if (checked[index]) { row.setAttribute("data-state", "selected"); } else if (row.getAttribute("data-state") === "selected") { row.removeAttribute("data-state"); } }); }); groups.set(controller, { members, dispose });}/** * @description Delegated change handler (input adapter): a controller change * fans out to its members imperatively then writes the group signal once; a * member change recounts its group. * * @param event - The change event. * @private */function onChange(event) { const target = event.target; if (!(target instanceof HTMLInputElement) || target.type !== "checkbox") return; if (target.hasAttribute(CONTROLS_ATTR)) { if (!groups.has(target)) createGroup(target); for (const member of membersOf(target)) member.checked = target.checked; recount(target); return; } const controller = controllerOf(target); if (!controller) return; if (!groups.has(controller)) createGroup(controller); recount(controller);}// --- Init ---/** * @description Initializes select-all groups in a scope: prunes groups whose * controllers left the DOM (disposing their effects), creates groups for new * controllers, and recounts existing ones. Idempotent: re-running is a no-op. * * @param scope - Root to scan; defaults to the whole document. */function initCheckboxes(scope = document) { for (const [controller, group] of groups) { if (!controller.isConnected) { group.dispose(); groups.delete(controller); } } const controllers = scope.querySelectorAll(`input[type="checkbox"][${CONTROLS_ATTR}]`); for (const controller of controllers) { if (groups.has(controller)) { recount(controller); } else { createGroup(controller); } }}// Auto-initialize when DOM is ready (only in browser environment)if (typeof window !== "undefined" && typeof document !== "undefined") { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => initCheckboxes()); } else { initCheckboxes(); } // Delegate at document level so groups work wherever checkboxes appear. document.addEventListener("change", onChange); // After a SPA <main> swap, re-scan the new content. registerRefresh(initCheckboxes);}export { deriveTriState, initCheckboxes };/** * checkbox.css — Checkbox (input[type="checkbox"]:not([role="switch"])) * * @layer reset * @requires layers.css, _variables.css * @uses appearance: none — redraws the native control from theme tokens * @uses checked/indeterminate fill with --primary and layer a glyph (never colour alone) * @tokens --ui-checkbox-* (size, background, border, radius, checkmark-mask, indeterminate-mask) * @see checkbox.ts: derives select-all group state (data-checkbox-controls) * into the JS-only indeterminate property; styling keys off :indeterminate */@layer reset { :where(input[type="checkbox"]:not([role="switch"])) { --ui-checkbox-size: var(--step-4_5); --ui-checkbox-background: var(--input); --ui-checkbox-border: var(--border); --ui-checkbox-border--hover: var(--primary); --ui-checkbox-background--checked: var(--primary); --ui-checkbox-border--checked: var(--primary); --ui-checkbox-radius: var(--radius-xs); /* @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --ui-checkbox-checkmark-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cpolyline points='40 144 96 200 224 72' fill='none' stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='32'/%3E%3C/svg%3E"); --ui-checkbox-checkmark-size: var(--step-3); --ui-checkbox-indeterminate-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cline x1='40' y1='128' x2='216' y2='128' fill='none' stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='32'/%3E%3C/svg%3E"); --ui-checkbox-indeterminate-size: var(--step-3); appearance: none; flex-shrink: 0; inline-size: var(--ui-checkbox-size); block-size: var(--ui-checkbox-size); background-color: var(--ui-checkbox-background); border: 1px solid var(--ui-checkbox-border); border-radius: var(--ui-checkbox-radius); cursor: pointer; /* ring renders as box-shadow; transparent outline twin keeps focus visible in forced-colors / high-contrast modes */ --_ring-offset-width: 0px; --_ring-width: 0px; box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--_ring, var(--ring)); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } :where(input[type="checkbox"]:not([role="switch"]):hover) { border-color: var(--ui-checkbox-border--hover); } :where(input[type="checkbox"]:not([role="switch"]):focus-visible) { --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } :where( input[type="checkbox"]:not([role="switch"]):checked, input[type="checkbox"]:not([role="switch"]):indeterminate ) { background-color: var(--ui-checkbox-background--checked); border-color: var(--ui-checkbox-border--checked); background-repeat: no-repeat; background-position: center; } :where(input[type="checkbox"]:not([role="switch"]):checked) { background-image: var(--ui-checkbox-checkmark-mask); background-size: var(--ui-checkbox-checkmark-size); } :where(input[type="checkbox"]:not([role="switch"]):indeterminate) { background-image: var(--ui-checkbox-indeterminate-mask); background-size: var(--ui-checkbox-indeterminate-size); }}