How to create a grouped checkbox tree with Alpine.js and Tailwind CSS

Learn how to build an interactive grouped checkbox tree component using Alpine.js for state management and Tailwind CSS for styling, perfect for notification preferences.

Published on October 14, 2025 by Michael Andreuzza

Grouped checkbox trees are useful for organizing related options in a hierarchical structure, allowing users to select entire groups or individual items. This tutorial shows how to build a flexible checkbox tree with group-level controls and individual item selection using Alpine.js and Tailwind CSS.

What we’ll build

We’ll create a checkbox tree that:

  • Organizes options into groups with sub-items
  • Includes a master “All” checkbox to select/deselect everything
  • Features group checkboxes that control all items in that group
  • Supports individual item selection
  • Updates checkbox states (checked, indeterminate) dynamically
  • Uses smooth, accessible styling

Prerequisites

Make sure Alpine.js and Tailwind CSS are installed in your project. If you’re using Astro, you can include this component directly in any page or layout.

How it works

The component uses Alpine.js to manage the hierarchical state. The x-data object contains:

  • groups: Array of group objects, each with a key, label, and array of items
  • selected: Array of selected item keys
  • allItems(): Returns all item keys across all groups
  • isGroupAll(key) / isGroupNone(key): Check if all/none items in a group are selected
  • toggleAll(): Selects/deselects all items
  • toggleGroup(key): Selects/deselects all items in a specific group
  • update(): Updates checkbox states (checked/indeterminate) based on selection

The component uses x-model for two-way binding on individual checkboxes and x-ref to access checkbox elements for state updates.

Using the component

Basic setup

Include the component in your HTML with the predefined groups:

<div
  class="flex flex-col w-full max-w-lg gap-2"
  x-data="{ /* component data */ }"
>
  <!-- component structure -->
</div>

Customizing groups

Modify the groups array to fit your needs:

groups: [
  {
    key: "category1",
    label: "Category 1",
    items: [
      { key: "item1", label: "Item 1" },
      { key: "item2", label: "Item 2" },
    ],
  },
  // more groups...
];

Accessing selected items

The selected array contains the keys of all selected items. You can use this for form submission or further processing:

console.log($data.selected); // ['login', 'promos', 'comments']

Customizing the component

You can modify the styling by changing Tailwind classes:

  • Change colors: Update text-blue-600 and border-zinc-300 to your brand colors
  • Adjust spacing: Modify gap-2, pl-6, etc.
  • Change sizes: Update size-4 for checkbox sizes

For different group structures, simply update the groups array in the x-data object.

Full code

Here’s the complete component code:

<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>

This component provides a user-friendly way to manage hierarchical selections, perfect for notification preferences, feature toggles, or any grouped options interface.

/Michael Andreuzza

Did you like this post? Please share it with your friends!