Holidays Deal Full Access for 50% OFF. Use code lex50 at checkout.

You'll get every theme available plus future additions. That's 43 themes total. Unlimited projects. Lifetime updates. One payment.

How to create nested checkboxes with Alpine.js

Build a nested notification preferences control with Alpine.js and Tailwind CSS, including select-all, group toggles, and accurate indeterminate states.

Published on November 25, 2025 by Michael Andreuzza

Nested checkboxes help users understand how individual preferences roll up into larger categories. In this walkthrough we will assemble an Alpine.js component that lets people toggle an entire notification tree, drill down into a group, or pick a single option without losing track of what is currently active.


What we are building

This component delivers:

  • A top-level “All notifications” checkbox that turns every option on or off
  • Group checkboxes that mirror their children and flip when the entire group is selected
  • Individual checkboxes bound to a shared selected array
  • Automatic indeterminate states so users see when a parent is only partially selected
  • Tailwind utility classes for spacing, sizing, and focus states

You can drop the finished markup into any Astro, Blade, or static HTML file once Alpine and Tailwind are available on the page.

Model the tree with Alpine data

Give Alpine everything it needs to describe the hierarchy and keep a single source of truth for what is selected.

<div
  x-data="{
    groups: [ /* see full example below */ ],
    selected: [],
    allItems(){
      return this.groups.flatMap(group => group.items.map(item => item.key))
    },
    isGroupAll(key){
      const group = this.groups.find(g => g.key === key)
      return group.items.every(item => this.selected.includes(item.key))
    },
    isGroupNone(key){
      const group = this.groups.find(g => g.key === key)
      return group.items.every(item => !this.selected.includes(item.key))
    },
  }"
></div>
  • groups supplies the label text and unique keys used by each checkbox
  • selected contains only the child keys, which keeps reactivity simple and predictable
  • allItems() makes it painless to compare against a “select all” action

Wire up the master checkbox

The top checkbox needs to toggle everything and mirror the aggregate state of the tree.

<input
  type="checkbox"
  x-ref="topRef"
  @change="toggleAll($refs)"
  class="text-blue-600 rounded shadow size-4 border-zinc-300 focus:ring-blue-600"
/>

Inside toggleAll() we either clear the selected array or copy the list created by allItems(). After each change we call update($refs) (shown below) so the indeterminate flag is correct.

Group toggles and children

Each group checkbox behaves similarly, but only touches its own slice of the selected array. Using Array.from(new Set()) guarantees that toggling a group on won’t duplicate keys. Individual inputs bind via x-model="selected", so Alpine automatically pushes or removes the key when a checkbox changes.

<input
  id="tree-sec"
  type="checkbox"
  x-ref="secRef"
  @change="toggleGroup('security', $refs)"
/>

Inside toggleGroup() we inspect isGroupAll(key) to decide whether to remove or add the group’s keys.

Keep DOM state in sync

The update() helper is the secret sauce for the indeterminate UX. Alpine can’t set the indeterminate attribute declaratively, so we grab the elements by reference and flip the property directly.

update($refs){
  const setRef = (ref, all, none) => {
    if (!ref) return
    ref.checked = all
    ref.indeterminate = !all && !none
  }
  setRef($refs.topRef, this.selected.length === this.allItems().length, this.selected.length === 0)
  setRef($refs.secRef, this.isGroupAll('security'), this.isGroupNone('security'))
  setRef($refs.mktRef, this.isGroupAll('marketing'), this.isGroupNone('marketing'))
  setRef($refs.comRef, this.isGroupAll('community'), this.isGroupNone('community'))
}

Call init(){ this.update(this.$refs) } so the component renders with the correct visual state on first paint.

Full Alpine.js + Tailwind CSS snippet

Paste the snippet below into your template to get the entire experience. Feel free to rename groups, tweak gaps, or replace the color tokens with your brand palette.

<div
  class="flex flex-col w-full max-w-lg gap-2"
  x-data="{
      groups: [
        { key: 'security', label: 'Security', items: [
          { key: 'login', label: 'Login alerts' },
          { key: 'password', label: 'Password changes' },
        ]},
        { key: 'marketing', label: 'Marketing', items: [
          { key: 'promos', label: 'Promotions' },
          { key: 'product', label: 'Product updates' },
        ]},
        { key: 'community', label: 'Community', items: [
          { key: 'comments', label: 'Comments' },
          { key: 'mentions', label: 'Mentions' },
        ]},
      ],
      selected: [],
      allItems(){ return this.groups.flatMap(g => g.items.map(i => i.key)) },
      isGroupAll(key){ const g = this.groups.find(x => x.key===key); return g.items.every(i => this.selected.includes(i.key)) },
      isGroupNone(key){ const g = this.groups.find(x => x.key===key); return g.items.every(i => !this.selected.includes(i.key)) },
      toggleAll($refs){
        if (this.selected.length === this.allItems().length) this.selected = []
        else this.selected = [...this.allItems()]
        this.update($refs)
      },
      toggleGroup(key,$refs){
        const g = this.groups.find(x => x.key===key)
        const keys = g.items.map(i => i.key)
        if (this.isGroupAll(key)) this.selected = this.selected.filter(k => !keys.includes(k))
        else this.selected = Array.from(new Set([...this.selected, ...keys]))
        this.update($refs)
      },
      update($refs){
        const setRef = (ref, all, none) => { if (!ref) return; ref.checked = all; ref.indeterminate = !all && !none }
        setRef($refs.topRef, this.selected.length === this.allItems().length, this.selected.length === 0)
        setRef($refs.secRef, this.isGroupAll('security'), this.isGroupNone('security'))
        setRef($refs.mktRef, this.isGroupAll('marketing'), this.isGroupNone('marketing'))
        setRef($refs.comRef, this.isGroupAll('community'), this.isGroupNone('community'))
      },
      init(){ this.update(this.$refs) }
    }"
>
  <div class="flex items-start gap-3">
    <input
      id="tree-all"
      name="tree-all"
      type="checkbox"
      x-ref="topRef"
      @change="toggleAll($refs)"
      class="text-blue-600 rounded shadow size-4 border-zinc-300 focus:ring-blue-600"
    />
    <div class="w-full">
      <label class="text-base block text-sm text-zinc-700">
        All notifications
      </label>
      <div class="flex flex-col pl-6 mt-2 gap-2">
        <!-- Security group -->
        <div class="flex flex-col gap-1">
          <div class="flex items-center gap-2">
            <input
              id="tree-sec"
              type="checkbox"
              x-ref="secRef"
              @change="toggleGroup('security', $refs)"
              class="text-blue-600 rounded shadow size-4 border-zinc-300 focus:ring-blue-600"
            />
            <label for="tree-sec" class="text-sm text-zinc-700">Security</label>
          </div>
          <template
            x-for="item in groups.find(g => g.key==='security').items"
            :key="item.key"
          >
            <div class="flex items-center pl-6 gap-2">
              <input
                type="checkbox"
                :id="`tree-sec-`+item.key"
                :value="item.key"
                x-model="selected"
                @change="update($refs)"
                class="text-blue-600 rounded shadow size-4 border-zinc-300 focus:ring-blue-600"
              />
              <label
                :for="`tree-sec-`+item.key"
                class="text-sm text-zinc-600"
                x-text="item.label"
              ></label>
            </div>
          </template>
        </div>
        <!-- Marketing group -->
        <div class="flex flex-col gap-1">
          <div class="flex items-center gap-2">
            <input
              id="tree-mkt"
              type="checkbox"
              x-ref="mktRef"
              @change="toggleGroup('marketing', $refs)"
              class="text-blue-600 rounded shadow size-4 border-zinc-300 focus:ring-blue-600"
            />
            <label for="tree-mkt" class="text-sm text-zinc-700"
              >Marketing</label
            >
          </div>
          <template
            x-for="item in groups.find(g => g.key==='marketing').items"
            :key="item.key"
          >
            <div class="flex items-center pl-6 gap-2">
              <input
                type="checkbox"
                :id="`tree-mkt-`+item.key"
                :value="item.key"
                x-model="selected"
                @change="update($refs)"
                class="text-blue-600 rounded shadow size-4 border-zinc-300 focus:ring-blue-600"
              />
              <label
                :for="`tree-mkt-`+item.key"
                class="text-sm text-zinc-600"
                x-text="item.label"
              ></label>
            </div>
          </template>
        </div>
        <!-- Community group -->
        <div class="flex flex-col gap-1">
          <div class="flex items-center gap-2">
            <input
              id="tree-com"
              type="checkbox"
              x-ref="comRef"
              @change="toggleGroup('community', $refs)"
              class="text-blue-600 rounded shadow size-4 border-zinc-300 focus:ring-blue-600"
            />
            <label for="tree-com" class="text-sm text-zinc-700"
              >Community</label
            >
          </div>
          <template
            x-for="item in groups.find(g => g.key==='community').items"
            :key="item.key"
          >
            <div class="flex items-center pl-6 gap-2">
              <input
                type="checkbox"
                :id="`tree-com-`+item.key"
                :value="item.key"
                x-model="selected"
                @change="update($refs)"
                class="text-blue-600 rounded shadow size-4 border-zinc-300 focus:ring-blue-600"
              />
              <label
                :for="`tree-com-`+item.key"
                class="text-sm text-zinc-600"
                x-text="item.label"
              ></label>
            </div>
          </template>
        </div>
      </div>
    </div>
  </div>
</div>

Where to go next

  • Rename or fetch groups from your CMS to offer personalized preference sets
  • List the currently selected labels below the tree to confirm what will be saved
  • Persist selected to local storage or your API so returning users see their previous picks

With this structure in place you now have a reliable, accessible foundation for any nested checkbox interface.

/Michael Andreuzza