Summit Themes
Blog

How to create nested checkboxes with Alpine.js

Nested checkboxes — a parent that toggles all its children, and that goes indeterminate when only some children are checked — look simple but have a surprising number of moving parts. You need to keep the parent's visual state in sync with its children, handle the three-way indeterminate state that CSS alone cannot express, and avoid the classic trap of letting the parent and children fight each other in an infinite update loop.

Alpine.js v3 gives you exactly the right tools for this: x-data for reactive state, x-model for two-way checkbox binding, x-effect for reactive side-effects, and $refs for reaching into the DOM to set the indeterminate property (which is write-only in JavaScript — you cannot set it with an HTML attribute or Alpine binding alone).

What we're building

A list of options under a "Select all" parent checkbox. The parent:

  • Checks all children when you check it.
  • Unchecks all children when you uncheck it.
  • Shows as indeterminate (the dash state) when some — but not all — children are checked.
  • Updates itself correctly when you click children individually.

No plugins, no build step. Drop it anywhere you have Alpine loaded.

Step 1 — Set up the data model

Everything lives in a single x-data object. Represent each option as an object with an id, a label, and a checked boolean. That gives you fine-grained control and avoids the "array of values" style that Alpine uses with x-model on same-name inputs — here you want to drive state from objects, not from the DOM.

<div x-data="{
  options: [
    { id: 1, label: 'Plumbing', checked: false },
    { id: 2, label: 'Electrical', checked: false },
    { id: 3, label: 'HVAC',       checked: false },
    { id: 4, label: 'Roofing',    checked: false }
  ],

  get allChecked() {
    return this.options.every(o => o.checked);
  },
  get noneChecked() {
    return this.options.every(o => !o.checked);
  },
  get someChecked() {
    return !this.allChecked && !this.noneChecked;
  },

  toggleAll(checked) {
    this.options.forEach(o => o.checked = checked);
  }
}">
  <!-- markup goes here -->
</div>

Three computed getters — allChecked, noneChecked, and someChecked — express every state the parent needs to know about. Alpine re-evaluates getters reactively whenever a property they depend on changes, so you never have to manually recalculate.

Step 2 — Render the parent checkbox

The parent checkbox has two jobs: reflect the current state, and drive all children when clicked. The indeterminate property is the tricky part — it cannot be set via an HTML attribute or :indeterminate="..." because it is a DOM property, not an HTML attribute. The only way to set it is through JavaScript. That is where x-ref and x-effect come in.

<label>
  <input
    type="checkbox"
    x-ref="parentBox"
    :checked="allChecked"
    x-effect="$refs.parentBox.indeterminate = someChecked"
    @change="toggleAll($event.target.checked)"
  />
  Select all services
</label>

Breaking this down:

  • x-ref="parentBox" — gives Alpine a stable handle to the element.
  • :checked="allChecked" — binds the visual checked state to the getter.
  • x-effect="$refs.parentBox.indeterminate = someChecked" — runs every time someChecked changes, writing the property directly onto the DOM node. This is the only reliable way to set indeterminate.
  • @change="toggleAll($event.target.checked)" — when the user clicks the parent, push the new state down to all children.

Do not use x-model on the parent. x-model on a checkbox writes a boolean back into your data on every change event, which would conflict with the toggleAll logic and produce confusing double-writes. Using :checked (one-way, read) plus @change (one-way, write) keeps the data flow explicit.

Step 3 — Render the child checkboxes

Loop over options with x-for and bind each checkbox directly to its option object's checked property.

<ul>
  <template x-for="option in options" :key="option.id">
    <li>
      <label>
        <input
          type="checkbox"
          x-model="option.checked"
        />
        <span x-text="option.label"></span>
      </label>
    </li>
  </template>
</ul>

x-model here is correct — it is binding a single checkbox to a single boolean property on an object in an array. When the user clicks a child, option.checked flips, Alpine re-evaluates allChecked and someChecked, and both the parent's :checked binding and the x-effect update automatically.

The complete, copy-pasteable component

Here is the full component in one block. Paste it anywhere on a page that loads Alpine from the CDN (<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>).

<div x-data="{
  options: [
    { id: 1, label: 'Plumbing',    checked: false },
    { id: 2, label: 'Electrical',  checked: false },
    { id: 3, label: 'HVAC',        checked: false },
    { id: 4, label: 'Roofing',     checked: false }
  ],

  get allChecked() {
    return this.options.every(o => o.checked);
  },
  get noneChecked() {
    return this.options.every(o => !o.checked);
  },
  get someChecked() {
    return !this.allChecked && !this.noneChecked;
  },

  toggleAll(checked) {
    this.options.forEach(o => o.checked = checked);
  }
}">

  <!-- Parent -->
  <label style="font-weight: bold;">
    <input
      type="checkbox"
      x-ref="parentBox"
      :checked="allChecked"
      x-effect="$refs.parentBox.indeterminate = someChecked"
      @change="toggleAll($event.target.checked)"
    />
    Select all services
  </label>

  <!-- Children -->
  <ul style="margin-top: 0.5rem; padding-left: 1.5rem; list-style: none;">
    <template x-for="option in options" :key="option.id">
      <li>
        <label>
          <input type="checkbox" x-model="option.checked" />
          <span x-text="option.label"></span>
        </label>
      </li>
    </template>
  </ul>

  <!-- Debug readout (remove in production) -->
  <p style="margin-top: 1rem; font-size: 0.875rem; color: #666;">
    Selected:
    <span x-text="options.filter(o => o.checked).map(o => o.label).join(', ') || 'none'"></span>
  </p>

</div>

Extracting to Alpine.data() for reuse

If you need this pattern in multiple places — a permission editor, a filter panel, a multi-step form — extract the logic into Alpine.data() so you can reference it by name instead of duplicating the object.

<script>
document.addEventListener('alpine:init', () => {
  Alpine.data('checkboxGroup', (items) => ({
    options: items.map((label, i) => ({ id: i, label, checked: false })),

    get allChecked() {
      return this.options.every(o => o.checked);
    },
    get someChecked() {
      return this.options.some(o => o.checked) && !this.allChecked;
    },

    toggleAll(checked) {
      this.options.forEach(o => o.checked = checked);
    }
  }));
});
</script>

<!-- Usage -->
<div x-data="checkboxGroup(['Plumbing', 'Electrical', 'HVAC', 'Roofing'])">
  <input
    type="checkbox"
    x-ref="parentBox"
    :checked="allChecked"
    x-effect="$refs.parentBox.indeterminate = someChecked"
    @change="toggleAll($event.target.checked)"
  />
  <template x-for="option in options" :key="option.id">
    <input type="checkbox" x-model="option.checked" />
  </template>
</div>

Pass the array of labels as the initializer argument and the component handles the rest. You can instantiate it any number of times on the same page without naming conflicts because each x-data block gets its own scope.

Common mistakes to avoid

Using x-bind:indeterminate instead of x-effect

:indeterminate="someChecked" does not work. Alpine's x-bind sets HTML attributes; indeterminate is a DOM property. The browser ignores the attribute. You must use x-effect (or x-init / $watch) to write to element.indeterminate directly via $refs.

Binding x-model on the parent

If you put x-model="allChecked" on the parent, Alpine will try to write a boolean back into the getter — which throws a silent error because a getter without a setter is read-only. Use :checked to read and @change to write, with toggleAll as the bridge.

Forgetting to use :key in x-for

Without :key, Alpine cannot efficiently track which DOM node maps to which option object when the list changes. Add :key="option.id" to avoid subtle bugs where checkboxes appear to swap their checked state.

Reading checked state from the DOM instead of data

Alpine's reactive system only tracks JavaScript state inside x-data. If you query document.querySelectorAll('input:checked') and compare counts, you are working outside Alpine's reactivity and will get stale values. Keep everything in options[i].checked and let Alpine sync the DOM for you.

Going further: multi-level nesting

If you need more than one level (categories containing subcategories containing items), the same pattern composes. Each category node holds its own children array, and each category checkbox becomes the "parent" relative to its own children while also being a "child" relative to its grandparent. A recursive component approach works well here: define the group logic in Alpine.data('checkboxGroup', ...) and nest the template inside itself using x-data="checkboxGroup(node.children)" on each child container. The reactivity chains naturally because each level's getters only inspect their own options array, not the whole tree.

Wrapping up

The nested checkbox pattern — parent, children, indeterminate state — is one of the cases where Alpine's composability really shines. You get a clean data model (x-data with getters), declarative bindings (x-model on children, :checked on the parent), and an escape hatch for DOM-property manipulation (x-effect + $refs) that stays within Alpine's reactive system rather than fighting it. The whole thing fits in roughly 30 lines and works anywhere Alpine v3 is loaded — no build step, no extra dependencies.