See how different frameworks handle parent-child relationships
Switch to desktop view to compare frameworks side by side
Click and drag framework cards to re-order them
Contributed by Yawar Amin
<fieldset class="checkbox-demo">
<legend><label><input type="checkbox"> Parent</label></legend>
<div class="checkbox-children">
<label><input type="checkbox"> Child 1</label>
<label><input type="checkbox"> Child 2</label>
<label><input type="checkbox"> Child 3</label>
</div>
</fieldset>
<script is:inline>
for (const container of document.querySelectorAll('[data-framework="vanillajs"] .checkbox-demo')) {
const parent = container.querySelector('legend input');
const children = [...container.querySelectorAll('.checkbox-children input')];
parent.addEventListener('change', () => {
for (const child of children) child.checked = parent.checked;
});
for (const child of children) {
child.addEventListener('change', () => {
parent.checked = children.every(c => c.checked);
parent.indeterminate = !parent.checked && children.some(c => c.checked);
});
}
}
</script> <script>
import Alpine from "alpinejs";
Alpine.start();
</script>
<fieldset class="checkbox-demo" x-data="{
checkboxes: [
{ label: 'Child 1', checked: false },
{ label: 'Child 2', checked: false },
{ label: 'Child 3', checked: false }
]
}">
<legend>
<label>
<input
type="checkbox"
:checked="checkboxes.every(c => c.checked)"
@change="checkboxes.forEach(c => c.checked = $event.target.checked)"
x-effect="$el.indeterminate = checkboxes.some(c => c.checked) && !checkboxes.every(c => c.checked)"
> Parent
</label>
</legend>
<div class="checkbox-children">
<template x-for="checkbox in checkboxes" :key="checkbox.label">
<label>
<input type="checkbox" x-model="checkbox.checked">
<span x-text="checkbox.label"></span>
</label>
</template>
</div>
</fieldset> <script setup>
import { computed, ref } from "vue";
const checkboxes = ref([
{ id: "child1-vue", label: "Child 1", checked: false },
{ id: "child2-vue", label: "Child 2", checked: false },
{ id: "child3-vue", label: "Child 3", checked: false },
]);
const allChecked = computed(() => checkboxes.value.every((c) => c.checked));
const someChecked = computed(() => checkboxes.value.some((c) => c.checked));
function toggleParent(e) {
const newValue = e.target.checked;
for (const c of checkboxes.value) c.checked = newValue;
}
</script>
<template>
<fieldset class="checkbox-demo">
<legend>
<label>
<input type="checkbox" :checked="allChecked"
:indeterminate="someChecked && !allChecked" @change="toggleParent" /> Parent
</label>
</legend>
<div class="checkbox-children">
<label v-for="checkbox in checkboxes" :key="checkbox.id">
<input type="checkbox" v-model="checkbox.checked" /> {{ checkbox.label }}
</label>
</div>
</fieldset>
</template> import { useCallback, useEffect, useRef, useState } from "react";
function Checkbox({ label, checked, indeterminate, onChange }) {
const ref = useRef();
useEffect(() => {
if (ref.current) {
ref.current.indeterminate = indeterminate;
}
}, [indeterminate]);
return (
<label>
<input ref={ref} type="checkbox" checked={checked} onChange={onChange} />{" "}
{label}
</label>
);
}
export default function NestedCheckboxes() {
const [checkboxes, setCheckboxes] = useState([
{ id: "child1-react", label: "Child 1", checked: false },
{ id: "child2-react", label: "Child 2", checked: false },
{ id: "child3-react", label: "Child 3", checked: false },
]);
const allChecked = checkboxes.every((item) => item.checked);
const someChecked = checkboxes.some((item) => item.checked);
const handleParentChange = useCallback((e) => {
const newValue = e.target.checked;
setCheckboxes((boxes) =>
boxes.map((box) => ({ ...box, checked: newValue })),
);
}, []);
const handleChildChange = useCallback(
(id) => (e) => {
setCheckboxes((boxes) =>
boxes.map((box) =>
box.id === id ? { ...box, checked: e.target.checked } : box,
),
);
},
[],
);
return (
<fieldset className="checkbox-demo">
<legend>
<Checkbox
label="Parent"
checked={allChecked}
indeterminate={someChecked && !allChecked}
onChange={handleParentChange}
/>
</legend>
<div className="checkbox-children">
{checkboxes.map((item) => (
<Checkbox
key={item.id}
label={item.label}
checked={item.checked}
onChange={handleChildChange(item.id)}
/>
))}
</div>
</fieldset>
);
} <fieldset class="checkbox-demo">
<legend>
<label>
<input
type="checkbox"
checked={allChecked}
indeterminate={isIndeterminate}
onchange={toggleAll}
/> Parent
</label>
</legend>
<div class="checkbox-children">
{#each checkboxes as checkbox}
<label>
<input type="checkbox" bind:checked={checkbox.value} /> {checkbox.label}
</label>
{/each}
</div>
</fieldset>
<script>
let checkboxes = $state([
{ label: "Child 1", value: false },
{ label: "Child 2", value: false },
{ label: "Child 3", value: false },
]);
const allChecked = $derived(checkboxes.every((v) => v.value));
const someChecked = $derived(checkboxes.some((v) => v.value));
const isIndeterminate = $derived(someChecked && !allChecked);
function toggleAll() {
const newValue = !allChecked;
for (const v of checkboxes) v.value = newValue;
}
</script> Contributed by htmx
<script src="https://unpkg.com/hyperscript.org@0.9.13"></script>
<fieldset class="checkbox-demo">
<legend>
<label>
<input
type="checkbox"
_="on click
set the checked of <input[type=checkbox]/> in the next <div/> to my checked
on click from the next <div/>
set my checked to no <input:not(:checked)/> in it
set my indeterminate to <:checked/> in it exists and <input:not(:checked)/> in it exists"
/> Parent
</label>
</legend>
<div class="checkbox-children">
<label><input type="checkbox" /> Child 1</label>
<label><input type="checkbox" /> Child 2</label>
<label><input type="checkbox" /> Child 3</label>
</div>
</fieldset> <fieldset class="checkbox-demo checkbox-group">
<legend><label><input type="checkbox" class="parent-checkbox"> Parent</label></legend>
<div class="checkbox-children">
<label><input type="checkbox" class="child-checkbox"> Child 1</label>
<label><input type="checkbox" class="child-checkbox"> Child 2</label>
<label><input type="checkbox" class="child-checkbox"> Child 3</label>
</div>
</fieldset>
<style>
/*
Pure CSS can't write another input's `checked`, so the parent toggle works as
an XOR over the children: each child LOOKS checked when (parent XOR child).
- Parent toggle inverts every child's appearance. From all-empty that reads as
"select all"; from all-checked it reads as "deselect all".
- Clicking a child always flips just that child's glyph, even while the parent
is engaged — so you can build, edit, and clear any combination.
- The parent glyph is derived purely from how many children LOOK checked:
none -> empty, some -> dash, all -> check.
This is the most behaviour we can cover deterministically. The one trade-off
vs. the JS versions: clicking the parent from a PARTIAL state inverts the
children instead of selecting all (a self-inverse toggle can't do both), and
the children's DOM `checked` state stays literal, so assistive tech doesn't
see the XOR-faked appearance.
*/
.checkbox-group {
--accent: rgb(37 99 235);
--check: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
--dash: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M3 8h10' stroke='white' stroke-width='2'/%3e%3c/svg%3e");
}
.checkbox-group input[type="checkbox"] {
appearance: none;
border: 1px solid rgb(126 137 147);
border-radius: 0.2rem;
position: relative;
background: transparent no-repeat center;
}
/* Children look checked when (parent XOR child):
- master OFF: a checked child looks checked
- master ON: the appearance inverts, so an UNchecked child looks checked */
.checkbox-group:has(.parent-checkbox:not(:checked)) .child-checkbox:checked,
.checkbox-group:has(.parent-checkbox:checked) .child-checkbox:not(:checked) {
background-color: var(--accent);
border-color: var(--accent);
background-image: var(--check);
}
/* Parent CHECK: every child looks checked
- master OFF + all children checked
- master ON + no child checked (all inverted to checked) */
.checkbox-group:has(.parent-checkbox:not(:checked)):has(.child-checkbox:checked):not(:has(.child-checkbox:not(:checked)))
.parent-checkbox,
.checkbox-group:has(.parent-checkbox:checked):not(:has(.child-checkbox:checked))
.parent-checkbox {
background-color: var(--accent);
border-color: var(--accent);
background-image: var(--check);
}
/* Parent DASH: children are mixed. Inverting a mixed set is still mixed, so
this holds whether the master is on or off. */
.checkbox-group:has(.child-checkbox:checked):has(.child-checkbox:not(:checked))
.parent-checkbox {
background-color: var(--accent);
border-color: var(--accent);
background-image: var(--dash);
}
.checkbox-group input[type="checkbox"]:focus {
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px rgb(255 255 255), 0 0 0 4px rgb(59 130 246);
}
</style> <fieldset class="checkbox-demo">
<legend><label><input type="checkbox"> Parent</label></legend>
<div class="checkbox-children">
<label><input type="checkbox"> Child 1</label>
<label><input type="checkbox"> Child 2</label>
<label><input type="checkbox"> Child 3</label>
</div>
</fieldset>
<script>
import $ from 'jquery';
$('[data-framework="jquery"] .checkbox-demo').each(function() {
const $container = $(this);
const $parent = $container.find('legend input');
const $children = $container.find('.checkbox-children input');
function updateParent() {
const total = $children.length;
const checked = $children.filter(':checked').length;
$parent.prop('checked', checked === total);
$parent.prop('indeterminate', checked > 0 && checked < total);
}
$parent.on('change', () => {
$children.prop('checked', $parent.prop('checked'));
});
$children.on('change', updateParent);
});
</script> Contributed by Eduardo Bohrer
<fieldset data-controller="checkbox-group" class="checkbox-demo">
<legend>
<label>
<input type="checkbox" data-checkbox-group-target="parent" data-action="checkbox-group#parentToggle" /> Parent
</label>
</legend>
<div class="checkbox-children">
{ [1, 2, 3].map((i) =>
<label>
<input type="checkbox" data-checkbox-group-target="child" data-action="checkbox-group#childToggle" /> Child {i}
</label> )}
</div>
</fieldset>
<script>
import { Application, Controller } from "@hotwired/stimulus"
window.Stimulus = Application.start()
window.Stimulus.register("checkbox-group", class extends Controller {
static targets = [ 'parent', 'child' ]
declare readonly parentTarget: HTMLInputElement
declare readonly childTargets: HTMLInputElement[]
parentToggle() {
const checked = this.parentTarget.checked
this.childTargets.forEach(child => child.checked = checked)
}
childToggle() {
const allChecked = this.childTargets.every(child => child.checked)
const someChecked = this.childTargets.some(child => child.checked)
this.parentTarget.checked = allChecked || someChecked
this.parentTarget.indeterminate = someChecked && !allChecked
}
})
</script> Contributed by Karthikeyan B
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.1/bundles/datastar.js"></script>
<fieldset
class="checkbox-demo"
data-signals="{ child1: false, child2: false, child3: false }"
data-computed:checked="($child1?1:0)+($child2?1:0)+($child3?1:0)"
data-computed:parent="$checked === 3"
data-computed:indeterminate="$checked > 0 && $checked < 3"
>
<legend>
<label>
<input
type="checkbox"
data-ref:parent-input
data-bind:parent
data-on:change="$child1=$child2=$child3=event.target.checked"
data-effect="$parentInput.indeterminate = $indeterminate"
/> Parent
</label>
</legend>
<div class="checkbox-children">
<label><input type="checkbox" data-bind:child1 /> Child 1</label>
<label><input type="checkbox" data-bind:child2 /> Child 2</label>
<label><input type="checkbox" data-bind:child3 /> Child 3</label>
</div>
</fieldset>