Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Checkbox

Native checkboxes, restyled, with select-all groups.

Installation

pnpm dlx zazz-ui add checkbox
npx zazz-ui add checkbox
yarn dlx zazz-ui add checkbox
bunx zazz-ui add checkbox

Also adds fields, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.

Examples

Default

Preferences
<fieldset data-ui="field-group">
  <legend>Preferences</legend>
  <div
    style="
      --display: flex-col;
      --gap: 2;
    "
  >
    <label data-ui="field" data-field-orientation="horizontal">
      <input type="checkbox" name="preferences-all" data-checkbox-controls="preferences" />
      <span data-field-slot="label">Select all</span>
    </label>
    <label data-ui="field" data-field-orientation="horizontal">
      <input type="checkbox" name="preferences" value="newsletter" />
      <span data-field-slot="label">Subscribe to the newsletter</span>
    </label>
    <label data-ui="field" data-field-orientation="horizontal">
      <input type="checkbox" name="preferences" value="terms" checked />
      <span data-field-slot="label">Accept terms &amp; conditions</span>
    </label>
  </div>
</fieldset>
/**
 * checkbox.css — Checkbox (input[type="checkbox"]:not([role="switch"]))
 *
 * @layer      variables, reset
 * @requires   _layers.css, _variables.css, _properties.css (--_*-resolved, --_focus-shadow), fields.css (--ui-field-* background/border)
 * @uses       appearance: none — redraws the native control from theme tokens
 * @uses       checked/indeterminate fill with --primary and layer a glyph (never color alone)
 * @tokens     --ui-checkbox-* (@layer variables; named after the utilities they back;
 *             surface/border default to the --ui-field-* family)
 * @see        checkbox.ts: derives select-all group state (data-checkbox-controls)
 *             into the JS-only indeterminate property; styling keys off :indeterminate
 */

@layer variables {
  :root {
    /* surface + border follow --ui-field-* */
    --ui-checkbox-size: calc(var(--spacing) * 4.5);
    --ui-checkbox-bg: var(--ui-field-bg);
    --ui-checkbox-foreground: var(--color-primary-foreground);
    --ui-checkbox-border-width: var(--ui-field-border-width);
    --ui-checkbox-border-style: var(--ui-field-border-style);
    --ui-checkbox-border-color: var(--ui-field-border-color);
    --ui-checkbox-border-color--hover: var(--ui-field-border-color--hover);
    --ui-checkbox-bg--checked: var(--color-primary);
    --ui-checkbox-border-color--checked: var(--color-primary);
    --ui-checkbox-rounded: var(--radius-xs);
    --ui-checkbox-glyph-size: 1em;
    --ui-checkbox-checkmark-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E")
      center / contain no-repeat;
    --ui-checkbox-indeterminate-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E")
      center / contain no-repeat;
  }
}

@layer reset {
  :where(input[type="checkbox"]:not([role="switch"])) {
    position: relative;
    -webkit-appearance: none;
    appearance: none;
    flex-shrink: 0;
    /* dual-mode: --size is a scale number or a length */
    --_size-len: var(--_size-resolved, var(--ui-checkbox-size));
    --_size-num: var(--_size-resolved, var(--ui-checkbox-size));
    inline-size: calc(var(--_size-len) + var(--_size-num) * var(--spacing));
    block-size: calc(var(--_size-len) + var(--_size-num) * var(--spacing));

    background-color: var(--_bg-resolved, var(--ui-checkbox-bg));
    border-width: var(--_border-width-resolved, var(--ui-checkbox-border-width));
    border-style: var(--_border-style-resolved, var(--ui-checkbox-border-style));
    border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color));
    border-radius: var(--_rounded-resolved, var(--ui-checkbox-rounded));

    /* centres the ::before glyph */
    display: inline-grid;
    place-items: center;
    background-clip: padding-box;

    cursor: pointer;

    /* The ring is published to --_focus-shadow so a --shadow utility keeps
       it; the transparent outline twin keeps focus visible in forced-colors */
    --_ring-offset-width: 0px;
    --_ring-width: 0px;
    --_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
    --_focus-shadow:
      0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
      0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
        var(--_ring-fill, var(--ring-shadow-color)),
      inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
      inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
        var(--_ring-fill, var(--ring-shadow-color));
    box-shadow: var(--_focus-shadow, 0 0 #0000), var(--_shadow-resolved, 0 0 #0000);
    outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
    outline-offset: calc(
      var(--outline-offset) - var(--_focus-in) *
        (2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
    );

    transition-property: border-color, background-color, color, opacity, box-shadow;
    transition-duration: var(--default-transition-duration);
    transition-timing-function: var(--default-transition-timing-function);
  }

  @media (hover: hover) {
    :where(input[type="checkbox"]:not([role="switch"]):hover) {
      border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color--hover));
    }
  }

  :where(input[type="checkbox"]:not([role="switch"]):focus-visible) {
    --_ring-offset-width: var(--ring-offset-width);
    --_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
    outline-color: transparent;
  }

  :where(
    input[type="checkbox"]:not([role="switch"]):checked,
    input[type="checkbox"]:not([role="switch"]):indeterminate
  ) {
    background-color: var(--_bg-resolved, var(--ui-checkbox-bg--checked));
    border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color--checked));
    color: var(--ui-checkbox-foreground);
  }

  /* The glyph is a masked ::before painted in currentColor (pseudo-elements
     are invalid inside :where(), so the :where() closes before ::before) */
  :where(input[type="checkbox"]:not([role="switch"]):checked)::before,
  :where(input[type="checkbox"]:not([role="switch"]):indeterminate)::before {
    content: "";
    position: absolute;
    pointer-events: none;
    background-color: currentColor;
    inline-size: var(--ui-checkbox-glyph-size);
    block-size: var(--ui-checkbox-glyph-size);
    max-inline-size: 100%;
    max-block-size: 100%;
    transition-property: background-color, opacity, transform;
    transition-duration: calc(var(--default-transition-duration) * 0.75);
    transition-delay: calc(var(--default-transition-duration) * 0.25);
    transition-timing-function: var(--default-transition-timing-function);
    transform: scale(0) translateY(100%);
    opacity: 0;
    will-change: transform;
  }

  :where(input[type="checkbox"]:not([role="switch"]):checked)::before {
    mask: var(--ui-checkbox-checkmark-mask);
    transform: scale(1);
    opacity: 1;

    @starting-style {
      transform: scale(0) translateY(100%);
      opacity: 0;
    }
  }

  :where(input[type="checkbox"]:not([role="switch"]):indeterminate)::before {
    mask: var(--ui-checkbox-indeterminate-mask);
    transform: scale(1);
    opacity: 1;

    @starting-style {
      transform: scaleX(0);
      opacity: 0;
    }
  }
}

/* Disabled and committed-invalid states: an invalid control remaps the
   --ui-field-* family on itself. */
@layer ui.components {
  input[type="checkbox"]:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
}
// 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-table-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 data-ui="table">
 *   <thead><tr><th><input type="checkbox" name="tasks-all" 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";
}
/**
 * @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-table-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-table-state", "selected");
            }
            else if (row.getAttribute("data-table-state") === "selected") {
                row.removeAttribute("data-table-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);
}
/**
 * @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 an in-page navigation swap, re-scan the new content.
    registerRefresh(initCheckboxes);
}
export { deriveTriState, initCheckboxes };

Tasklist

Sprint tasks with managed selection.
Task Assignee Due
Audit focus rings Robin Aug 28
Document table variants Sasha Aug 29
Ship checkbox groups Quinn Sep 01
Retire legacy tokens Robin Sep 03
<table data-ui="table">
  <caption>
    Sprint tasks with managed selection.
  </caption>
  <thead>
    <tr>
      <th>
        <input
          type="checkbox"
          name="tasks-all"
          data-checkbox-controls="tasks"
          aria-label="Select all tasks"
        />
      </th>
      <th>Task</th>
      <th>Assignee</th>
      <th style="--text-align: end">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 style="--text-align: end">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 style="--text-align: end">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 style="--text-align: end">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 style="--text-align: end">Sep 03</td>
    </tr>
  </tbody>
</table>
/**
 * checkbox.css — Checkbox (input[type="checkbox"]:not([role="switch"]))
 *
 * @layer      variables, reset
 * @requires   _layers.css, _variables.css, _properties.css (--_*-resolved, --_focus-shadow), fields.css (--ui-field-* background/border)
 * @uses       appearance: none — redraws the native control from theme tokens
 * @uses       checked/indeterminate fill with --primary and layer a glyph (never color alone)
 * @tokens     --ui-checkbox-* (@layer variables; named after the utilities they back;
 *             surface/border default to the --ui-field-* family)
 * @see        checkbox.ts: derives select-all group state (data-checkbox-controls)
 *             into the JS-only indeterminate property; styling keys off :indeterminate
 */

@layer variables {
  :root {
    /* surface + border follow --ui-field-* */
    --ui-checkbox-size: calc(var(--spacing) * 4.5);
    --ui-checkbox-bg: var(--ui-field-bg);
    --ui-checkbox-foreground: var(--color-primary-foreground);
    --ui-checkbox-border-width: var(--ui-field-border-width);
    --ui-checkbox-border-style: var(--ui-field-border-style);
    --ui-checkbox-border-color: var(--ui-field-border-color);
    --ui-checkbox-border-color--hover: var(--ui-field-border-color--hover);
    --ui-checkbox-bg--checked: var(--color-primary);
    --ui-checkbox-border-color--checked: var(--color-primary);
    --ui-checkbox-rounded: var(--radius-xs);
    --ui-checkbox-glyph-size: 1em;
    --ui-checkbox-checkmark-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E")
      center / contain no-repeat;
    --ui-checkbox-indeterminate-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E")
      center / contain no-repeat;
  }
}

@layer reset {
  :where(input[type="checkbox"]:not([role="switch"])) {
    position: relative;
    -webkit-appearance: none;
    appearance: none;
    flex-shrink: 0;
    /* dual-mode: --size is a scale number or a length */
    --_size-len: var(--_size-resolved, var(--ui-checkbox-size));
    --_size-num: var(--_size-resolved, var(--ui-checkbox-size));
    inline-size: calc(var(--_size-len) + var(--_size-num) * var(--spacing));
    block-size: calc(var(--_size-len) + var(--_size-num) * var(--spacing));

    background-color: var(--_bg-resolved, var(--ui-checkbox-bg));
    border-width: var(--_border-width-resolved, var(--ui-checkbox-border-width));
    border-style: var(--_border-style-resolved, var(--ui-checkbox-border-style));
    border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color));
    border-radius: var(--_rounded-resolved, var(--ui-checkbox-rounded));

    /* centres the ::before glyph */
    display: inline-grid;
    place-items: center;
    background-clip: padding-box;

    cursor: pointer;

    /* The ring is published to --_focus-shadow so a --shadow utility keeps
       it; the transparent outline twin keeps focus visible in forced-colors */
    --_ring-offset-width: 0px;
    --_ring-width: 0px;
    --_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
    --_focus-shadow:
      0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
      0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
        var(--_ring-fill, var(--ring-shadow-color)),
      inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
      inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
        var(--_ring-fill, var(--ring-shadow-color));
    box-shadow: var(--_focus-shadow, 0 0 #0000), var(--_shadow-resolved, 0 0 #0000);
    outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
    outline-offset: calc(
      var(--outline-offset) - var(--_focus-in) *
        (2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
    );

    transition-property: border-color, background-color, color, opacity, box-shadow;
    transition-duration: var(--default-transition-duration);
    transition-timing-function: var(--default-transition-timing-function);
  }

  @media (hover: hover) {
    :where(input[type="checkbox"]:not([role="switch"]):hover) {
      border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color--hover));
    }
  }

  :where(input[type="checkbox"]:not([role="switch"]):focus-visible) {
    --_ring-offset-width: var(--ring-offset-width);
    --_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
    outline-color: transparent;
  }

  :where(
    input[type="checkbox"]:not([role="switch"]):checked,
    input[type="checkbox"]:not([role="switch"]):indeterminate
  ) {
    background-color: var(--_bg-resolved, var(--ui-checkbox-bg--checked));
    border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color--checked));
    color: var(--ui-checkbox-foreground);
  }

  /* The glyph is a masked ::before painted in currentColor (pseudo-elements
     are invalid inside :where(), so the :where() closes before ::before) */
  :where(input[type="checkbox"]:not([role="switch"]):checked)::before,
  :where(input[type="checkbox"]:not([role="switch"]):indeterminate)::before {
    content: "";
    position: absolute;
    pointer-events: none;
    background-color: currentColor;
    inline-size: var(--ui-checkbox-glyph-size);
    block-size: var(--ui-checkbox-glyph-size);
    max-inline-size: 100%;
    max-block-size: 100%;
    transition-property: background-color, opacity, transform;
    transition-duration: calc(var(--default-transition-duration) * 0.75);
    transition-delay: calc(var(--default-transition-duration) * 0.25);
    transition-timing-function: var(--default-transition-timing-function);
    transform: scale(0) translateY(100%);
    opacity: 0;
    will-change: transform;
  }

  :where(input[type="checkbox"]:not([role="switch"]):checked)::before {
    mask: var(--ui-checkbox-checkmark-mask);
    transform: scale(1);
    opacity: 1;

    @starting-style {
      transform: scale(0) translateY(100%);
      opacity: 0;
    }
  }

  :where(input[type="checkbox"]:not([role="switch"]):indeterminate)::before {
    mask: var(--ui-checkbox-indeterminate-mask);
    transform: scale(1);
    opacity: 1;

    @starting-style {
      transform: scaleX(0);
      opacity: 0;
    }
  }
}

/* Disabled and committed-invalid states: an invalid control remaps the
   --ui-field-* family on itself. */
@layer ui.components {
  input[type="checkbox"]:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
}
// 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-table-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 data-ui="table">
 *   <thead><tr><th><input type="checkbox" name="tasks-all" 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";
}
/**
 * @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-table-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-table-state", "selected");
            }
            else if (row.getAttribute("data-table-state") === "selected") {
                row.removeAttribute("data-table-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);
}
/**
 * @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 an in-page navigation swap, re-scan the new content.
    registerRefresh(initCheckboxes);
}
export { deriveTriState, initCheckboxes };

Behavior

primitives/checkbox/checkbox.js

Checkbox select-all groups. 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-table-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.

Attributes

checkbox attributes
AttributeKindValuesNotes
data-checkbox-controlsConfigfree valueConfiguration the kit's script reads.

Requires: fields.

Hooks

Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.

checkbox hooks
HookDefaultState
--ui-checkbox-bgvar(--ui-field-bg)—
--ui-checkbox-bg--checkedvar(--color-primary)checked
--ui-checkbox-border-colorvar(--ui-field-border-color)—
--ui-checkbox-border-color--checkedvar(--color-primary)checked
--ui-checkbox-border-color--hovervar(--ui-field-border-color--hover)hover
--ui-checkbox-border-stylevar(--ui-field-border-style)—
--ui-checkbox-border-widthvar(--ui-field-border-width)—
--ui-checkbox-checkmark-maskurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") center / contain no-repeat—
--ui-checkbox-foregroundvar(--color-primary-foreground)—
--ui-checkbox-glyph-size1em—
--ui-checkbox-indeterminate-maskurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E") center / contain no-repeat—
--ui-checkbox-roundedvar(--radius-xs)—
--ui-checkbox-sizecalc(var(--spacing) * 4.5)—