Grouped checkbox trees show up everywhere in real projects — filter sidebars, permission editors, product configurators. The pattern looks simple but has a few sharp edges: keeping a parent "select all" checkbox in sync with its children, handling the indeterminate visual state when only some children are checked, and doing all of this without reaching for a heavyweight library.
Alpine.js and Tailwind CSS v4 are a natural fit here. Alpine's reactive x-data system handles the state logic with almost no boilerplate, and Tailwind's utility classes keep the markup self-documenting. This guide walks through the full implementation, from data shape to indeterminate DOM wiring, with copy-pasteable code at every step.
What you are building
A checkbox tree where items are organized into named groups. Each group has a header checkbox that selects or deselects all items beneath it. When only some items in a group are checked, the header checkbox shows the browser's native indeterminate state — that in-between dash that most users immediately understand. A live count of selected items sits above the tree for bonus feedback.
Setting up the data model
Start with the shape of the data. Each group needs a label and an array of items; each item needs an id (used as the checkbox value) and a label. Keep the selection state in a flat selected array — an array of checked item ids. That single source of truth makes computing "all checked" and "some checked" straightforward computed properties.
x-data="{
groups: [
{
key: 'frontend',
label: 'Front-end',
items: [
{ id: 'html', label: 'HTML' },
{ id: 'css', label: 'CSS' },
{ id: 'js', label: 'JavaScript' },
{ id: 'ts', label: 'TypeScript' }
]
},
{
key: 'backend',
label: 'Back-end',
items: [
{ id: 'node', label: 'Node.js' },
{ id: 'python', label: 'Python' },
{ id: 'go', label: 'Go' }
]
},
{
key: 'devops',
label: 'DevOps',
items: [
{ id: 'docker', label: 'Docker' },
{ id: 'k8s', label: 'Kubernetes' },
{ id: 'ci', label: 'CI/CD pipelines' }
]
}
],
selected: [],
/* helpers */
groupIds(group) {
return group.items.map(i => i.id);
},
allChecked(group) {
return this.groupIds(group).every(id => this.selected.includes(id));
},
someChecked(group) {
return this.groupIds(group).some(id => this.selected.includes(id));
},
toggleGroup(group) {
if (this.allChecked(group)) {
this.selected = this.selected.filter(id => !this.groupIds(group).includes(id));
} else {
const toAdd = this.groupIds(group).filter(id => !this.selected.includes(id));
this.selected = [...this.selected, ...toAdd];
}
}
}"
Three helper methods cover the logic you need: allChecked returns true when every item in the group is in the selected array, someChecked returns true when at least one is, and toggleGroup either selects all missing items or clears the whole group depending on current state.
Wiring the indeterminate state
The indeterminate property on a checkbox element is a DOM property, not an HTML attribute — you cannot set it with a static attribute like indeterminate="true". Alpine's :indeterminate binding (the colon prefix means "bind this as a DOM property, not an attribute") handles this correctly.
Alpine's x-ref is not usable inside x-for loops for dynamic keys (a documented V3 limitation), so use property binding directly on the input instead. The key is using :checked and :indeterminate together rather than x-model, because x-model on a checkbox expects either a boolean or an array binding — it does not play well with the computed "all children selected" logic here.
The full markup
The component below is self-contained. Drop it into any page that has Alpine.js and Tailwind CSS v4 loaded.
<div
x-data="{
groups: [
{
key: 'frontend',
label: 'Front-end',
items: [
{ id: 'html', label: 'HTML' },
{ id: 'css', label: 'CSS' },
{ id: 'js', label: 'JavaScript' },
{ id: 'ts', label: 'TypeScript' }
]
},
{
key: 'backend',
label: 'Back-end',
items: [
{ id: 'node', label: 'Node.js' },
{ id: 'python', label: 'Python' },
{ id: 'go', label: 'Go' }
]
},
{
key: 'devops',
label: 'DevOps',
items: [
{ id: 'docker', label: 'Docker' },
{ id: 'k8s', label: 'Kubernetes' },
{ id: 'ci', label: 'CI/CD pipelines' }
]
}
],
selected: [],
groupIds(g) { return g.items.map(i => i.id); },
allChecked(g) { return this.groupIds(g).every(id => this.selected.includes(id)); },
someChecked(g) { return this.groupIds(g).some(id => this.selected.includes(id)); },
toggleGroup(g) {
if (this.allChecked(g)) {
this.selected = this.selected.filter(id => !this.groupIds(g).includes(id));
} else {
const toAdd = this.groupIds(g).filter(id => !this.selected.includes(id));
this.selected = [...this.selected, ...toAdd];
}
}
}"
class="w-80 rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
>
<!-- Selection count -->
<p class="mb-4 text-sm text-gray-500">
<span x-text="selected.length" class="font-semibold text-gray-800"></span>
item<span x-show="selected.length !== 1">s</span> selected
</p>
<!-- Groups -->
<template x-for="group in groups" :key="group.key">
<div class="mb-4 last:mb-0">
<!-- Group header -->
<label class="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-gray-50">
<input
type="checkbox"
:checked="allChecked(group)"
:indeterminate="someChecked(group) && !allChecked(group)"
@change="toggleGroup(group)"
class="size-4 cursor-pointer rounded accent-indigo-600"
/>
<span class="text-sm font-semibold text-gray-800" x-text="group.label"></span>
<span
x-show="someChecked(group)"
x-text="groupIds(group).filter(id => selected.includes(id)).length + '/' + group.items.length"
class="ml-auto text-xs text-gray-400"
></span>
</label>
<!-- Child items -->
<template x-for="item in group.items" :key="item.id">
<label class="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1 pl-8 hover:bg-gray-50">
<input
type="checkbox"
:value="item.id"
x-model="selected"
class="size-4 cursor-pointer rounded accent-indigo-600"
/>
<span class="text-sm text-gray-700" x-text="item.label"></span>
</label>
</template>
</div>
</template>
</div>
How each piece works
Child checkboxes use x-model with an array
When multiple checkboxes share an x-model binding that points to an array, Alpine automatically adds the checkbox's :value to the array when checked and removes it when unchecked. That is the standard Alpine pattern — no custom event handlers needed on the children.
Parent checkboxes use :checked and :indeterminate
The group header uses :checked="allChecked(group)" rather than x-model. This lets you compute checked state from the children's state rather than maintaining a separate flag. The :indeterminate binding takes over when the condition is "some but not all" — this sets the DOM property directly, which is what the browser needs to render that middle state.
The @change handler calls toggleGroup, which either adds all missing items to selected or filters out all items in the group. Because selected is reactive, the child checkboxes update automatically.
The count badge
The inline expression inside the group header's badge — groupIds(group).filter(id => selected.includes(id)).length — re-evaluates any time selected changes, so it stays current with no extra bookkeeping.
Collapsible groups (optional enhancement)
Adding collapse/expand to each group takes about five lines. Add an open property to each group object and toggle it on click:
<!-- Inside the group header label, add a toggle button -->
<button
type="button"
@click.prevent="group.open = !group.open"
class="ml-auto p-1 text-gray-400 hover:text-gray-600"
:aria-expanded="group.open"
>
<svg
class="size-4 transition-transform duration-150"
:class="group.open ? 'rotate-0' : '-rotate-90'"
viewBox="0 0 16 16" fill="currentColor"
>
<path d="M4.5 6 8 9.5 11.5 6" stroke="currentColor" stroke-width="1.5"
fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<!-- Wrap the child template in an x-show -->
<div x-show="group.open" x-transition>
<template x-for="item in group.items" :key="item.id">
...
</template>
</div>
Initialize the groups with open: true in the data object if you want them expanded by default. The x-transition directive on the wrapping div adds a smooth height animation with no extra CSS.
Retrieving the selection
The selected array is the only thing you need to submit. If you are inside a form, render a hidden input for each selected id:
<template x-for="id in selected" :key="id">
<input type="hidden" name="skills[]" :value="id">
</template>
If you are posting JSON via fetch, just serialise selected directly — it is already a plain array of strings.
Tailwind v4 notes
The classes used above (rounded-xl, text-sm, size-4, accent-indigo-600, hover:bg-gray-50, and so on) are all standard Tailwind utilities that work identically in v4. In Tailwind v4 you no longer need a tailwind.config.js — configure your design tokens with the CSS-first @theme directive in your main stylesheet:
@import "tailwindcss";
@theme {
--color-brand: #6366f1;
--font-sans: "Inter", sans-serif;
}
If you want the group header checkbox to use your brand color instead of Tailwind's built-in indigo, replace accent-indigo-600 with a custom utility or set accent-color inline via Alpine's :style binding.
Accessibility checklist
- Wrap each checkbox in a
<label>so the entire row is clickable — the markup above already does this. - Add
aria-expandedto any collapse toggle button (shown in the collapsible example above). - The native
indeterminateDOM property is exposed to assistive technologies as a "mixed" state — no extra ARIA needed. - Use
fieldsetandlegendif the tree is a standalone form control, giving screen reader users group context without any visual change.
Wrapping up
The pattern comes down to three things: one flat selected array as the source of truth, x-model on child checkboxes to update that array automatically, and computed :checked / :indeterminate bindings on parent checkboxes to reflect group state. Alpine's reactivity makes those three things compose cleanly — change a child, the parent updates; click the parent, all children update. No component framework, no extra state management, around 40 lines of logic.