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 AndreuzzaNested 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
selectedarray - 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>
groupssupplies the label text and unique keys used by each checkboxselectedcontains only the child keys, which keeps reactivity simple and predictableallItems()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
groupsfrom your CMS to offer personalized preference sets - List the currently selected labels below the tree to confirm what will be saved
- Persist
selectedto 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