Build a Hierarchical Checkbox from Scratch with Vanilla JS
A complete guide to building a hierarchical (tri-state) checkbox tree using only HTML, CSS, and vanilla JavaScript. Covers parent-child propagation, indeterminate state, dynamic trees, and accessibility. Interview-ready implementation.
Build a Hierarchical Checkbox from Scratch with Vanilla JS
The hierarchical checkbox (also called a tri-state checkbox tree) is one of the most commonly asked UI component questions in frontend interviews. You've seen it everywhere — file explorers, permission panels, settings pages, category filters.
What makes it tricky is the bidirectional propagation: checking a parent checks all children, but checking some children puts the parent in an indeterminate (partially checked) state. Getting this right requires careful tree traversal.
By the end, you'll have:
- Parent → child propagation — checking a parent checks/unchecks all descendants
- Child → parent propagation — parent reflects the aggregate state of its children (checked, unchecked, or indeterminate)
- Indeterminate (tri-state) display — the "dash" state when some but not all children are checked
- Dynamic tree data — renders from a JSON data structure
- Keyboard accessible — full keyboard navigation with proper ARIA attributes
- Expand/collapse — tree nodes can be toggled open and closed
Why Interviewers Ask This
This question tests:
- Tree data structure traversal — recursion over nested data
- Bidirectional state propagation — changes flow both up and down
- The indeterminate checkbox state — many candidates don't know
checkbox.indeterminateexists - DOM manipulation — building a recursive tree structure from data
- Accessibility —
aria-expanded,role="tree",role="treeitem"
Step 1: HTML Structure
We only need a minimal shell — the tree is rendered dynamically from data.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hierarchical Checkbox</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="container">
<h1>Permissions</h1>
<div class="toolbar">
<button class="btn" id="expand-all">Expand All</button>
<button class="btn" id="collapse-all">Collapse All</button>
<button class="btn" id="get-selected">Get Selected</button>
</div>
<div id="tree-root" role="tree" aria-label="Permission settings"></div>
<pre id="output" class="output" aria-live="polite"></pre>
</div>
<script src="script.js"></script>
</body>
</html>Step 2: CSS Styling
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
padding: 2rem;
min-height: 100vh;
background: #1a1a2e;
color: #e0e0e0;
}
.container {
max-width: 500px;
width: 100%;
}
h1 {
font-size: 1.75rem;
margin-bottom: 1rem;
color: #e94560;
}
.toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.btn {
padding: 0.4rem 1rem;
font-size: 0.85rem;
border: 2px solid #e94560;
background: transparent;
color: #e94560;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn:hover {
background: #e94560;
color: #1a1a2e;
}
/* --- Tree Structure --- */
.tree-list {
list-style: none;
padding-left: 1.5rem;
}
.tree-list.root {
padding-left: 0;
}
.tree-item {
margin: 2px 0;
}
.tree-node {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 4px 6px;
border-radius: 4px;
cursor: default;
}
.tree-node:hover {
background: rgba(233, 69, 96, 0.1);
}
/* --- Toggle Button --- */
.toggle-btn {
width: 20px;
height: 20px;
background: none;
border: none;
color: #e0e0e0;
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.toggle-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.toggle-btn.expanded {
transform: rotate(90deg);
}
.toggle-btn.leaf {
visibility: hidden;
}
/* --- Checkbox --- */
.tree-checkbox {
width: 18px;
height: 18px;
accent-color: #e94560;
cursor: pointer;
flex-shrink: 0;
}
.tree-label {
font-size: 0.95rem;
cursor: pointer;
user-select: none;
}
/* --- Children Container --- */
.tree-children {
overflow: hidden;
transition: max-height 0.2s ease;
}
.tree-children.collapsed {
max-height: 0;
}
/* --- Output --- */
.output {
margin-top: 1rem;
padding: 1rem;
background: #16213e;
border-radius: 6px;
font-size: 0.85rem;
white-space: pre-wrap;
min-height: 2rem;
font-family: 'SF Mono', 'Fira Code', monospace;
}Step 3: JavaScript — Full Implementation
(function () {
'use strict';
// --- Sample Data ---
const treeData = [
{
id: 'admin',
label: 'Admin Panel',
children: [
{
id: 'users',
label: 'User Management',
children: [
{ id: 'users-view', label: 'View Users' },
{ id: 'users-create', label: 'Create Users' },
{ id: 'users-edit', label: 'Edit Users' },
{ id: 'users-delete', label: 'Delete Users' },
],
},
{
id: 'roles',
label: 'Role Management',
children: [
{ id: 'roles-view', label: 'View Roles' },
{ id: 'roles-create', label: 'Create Roles' },
{ id: 'roles-edit', label: 'Edit Roles' },
],
},
{ id: 'settings', label: 'System Settings' },
],
},
{
id: 'content',
label: 'Content',
children: [
{
id: 'posts',
label: 'Blog Posts',
children: [
{ id: 'posts-view', label: 'View Posts' },
{ id: 'posts-create', label: 'Create Posts' },
{ id: 'posts-publish', label: 'Publish Posts' },
],
},
{
id: 'media',
label: 'Media Library',
children: [
{ id: 'media-view', label: 'View Media' },
{ id: 'media-upload', label: 'Upload Media' },
{ id: 'media-delete', label: 'Delete Media' },
],
},
],
},
{
id: 'analytics',
label: 'Analytics',
children: [
{ id: 'analytics-view', label: 'View Reports' },
{ id: 'analytics-export', label: 'Export Data' },
],
},
];
// --- State ---
// We store checked state in a Map: id → boolean
// Indeterminate is derived, never stored
const checkedState = new Map();
// Node lookup for fast parent/child access
const nodeMap = new Map(); // id → { id, label, children?, parent? }
// --- Build Node Map ---
function buildNodeMap(nodes, parent) {
for (const node of nodes) {
node.parent = parent || null;
nodeMap.set(node.id, node);
checkedState.set(node.id, false);
if (node.children) {
buildNodeMap(node.children, node);
}
}
}
buildNodeMap(treeData, null);
// --- DOM References ---
const treeRoot = document.getElementById('tree-root');
const output = document.getElementById('output');
const expandAllBtn = document.getElementById('expand-all');
const collapseAllBtn = document.getElementById('collapse-all');
const getSelectedBtn = document.getElementById('get-selected');
// --- Render Tree ---
function renderTree(nodes, isRoot) {
const ul = document.createElement('ul');
ul.classList.add('tree-list');
if (isRoot) ul.classList.add('root');
ul.setAttribute('role', isRoot ? 'tree' : 'group');
for (const node of nodes) {
const li = document.createElement('li');
li.classList.add('tree-item');
li.setAttribute('role', 'treeitem');
li.setAttribute('aria-expanded', node.children ? 'true' : undefined);
li.dataset.id = node.id;
const row = document.createElement('div');
row.classList.add('tree-node');
// Toggle button
const toggle = document.createElement('button');
toggle.classList.add('toggle-btn');
if (node.children) {
toggle.textContent = '▶';
toggle.classList.add('expanded');
toggle.setAttribute('aria-label', `Collapse ${node.label}`);
toggle.addEventListener('click', () => toggleExpand(li, toggle, node));
} else {
toggle.classList.add('leaf');
toggle.setAttribute('aria-hidden', 'true');
toggle.tabIndex = -1;
}
// Checkbox
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.classList.add('tree-checkbox');
checkbox.id = `cb-${node.id}`;
checkbox.dataset.id = node.id;
checkbox.addEventListener('change', () => handleCheck(node.id, checkbox.checked));
// Label
const label = document.createElement('label');
label.classList.add('tree-label');
label.htmlFor = `cb-${node.id}`;
label.textContent = node.label;
row.appendChild(toggle);
row.appendChild(checkbox);
row.appendChild(label);
li.appendChild(row);
// Children
if (node.children) {
const childContainer = document.createElement('div');
childContainer.classList.add('tree-children');
childContainer.appendChild(renderTree(node.children, false));
li.appendChild(childContainer);
}
ul.appendChild(li);
}
return ul;
}
treeRoot.appendChild(renderTree(treeData, true));
// --- Check/Uncheck Logic ---
function handleCheck(id, checked) {
// 1. Set this node
checkedState.set(id, checked);
// 2. Propagate DOWN: set all descendants to the same value
const node = nodeMap.get(id);
propagateDown(node, checked);
// 3. Propagate UP: update all ancestors
propagateUp(node.parent);
// 4. Update the DOM to reflect new state
updateAllCheckboxes();
}
function propagateDown(node, checked) {
if (!node.children) return;
for (const child of node.children) {
checkedState.set(child.id, checked);
propagateDown(child, checked);
}
}
function propagateUp(node) {
if (!node) return;
const children = node.children;
if (!children) return;
const allChecked = children.every((c) => checkedState.get(c.id));
const someChecked = children.some(
(c) => checkedState.get(c.id) || isIndeterminate(c)
);
checkedState.set(node.id, allChecked);
// Continue up the tree
propagateUp(node.parent);
}
function isIndeterminate(node) {
if (!node.children) return false;
const childStates = node.children.map((c) => ({
checked: checkedState.get(c.id),
indeterminate: isIndeterminate(c),
}));
const allChecked = childStates.every((s) => s.checked && !s.indeterminate);
const noneChecked = childStates.every((s) => !s.checked && !s.indeterminate);
return !allChecked && !noneChecked;
}
function updateAllCheckboxes() {
const checkboxes = treeRoot.querySelectorAll('.tree-checkbox');
for (const cb of checkboxes) {
const id = cb.dataset.id;
const node = nodeMap.get(id);
cb.checked = checkedState.get(id);
cb.indeterminate = isIndeterminate(node);
}
}
// --- Expand/Collapse ---
function toggleExpand(li, toggleBtn, node) {
const childContainer = li.querySelector('.tree-children');
const isExpanded = !childContainer.classList.contains('collapsed');
if (isExpanded) {
childContainer.classList.add('collapsed');
toggleBtn.classList.remove('expanded');
li.setAttribute('aria-expanded', 'false');
toggleBtn.setAttribute('aria-label', `Expand ${node.label}`);
} else {
childContainer.classList.remove('collapsed');
toggleBtn.classList.add('expanded');
li.setAttribute('aria-expanded', 'true');
toggleBtn.setAttribute('aria-label', `Collapse ${node.label}`);
}
}
function setAllExpanded(expanded) {
const toggleBtns = treeRoot.querySelectorAll('.toggle-btn:not(.leaf)');
toggleBtns.forEach((btn) => {
const li = btn.closest('.tree-item');
const childContainer = li.querySelector('.tree-children');
const node = nodeMap.get(li.dataset.id);
if (expanded) {
childContainer.classList.remove('collapsed');
btn.classList.add('expanded');
li.setAttribute('aria-expanded', 'true');
btn.setAttribute('aria-label', `Collapse ${node.label}`);
} else {
childContainer.classList.add('collapsed');
btn.classList.remove('expanded');
li.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-label', `Expand ${node.label}`);
}
});
}
// --- Get Selected ---
function getSelected() {
const selected = [];
for (const [id, checked] of checkedState) {
const node = nodeMap.get(id);
// Only include leaf nodes or fully-checked parents
if (checked && (!node.children || node.children.every((c) => checkedState.get(c.id)))) {
selected.push({ id, label: node.label });
}
}
return selected;
}
// --- Event Listeners ---
expandAllBtn.addEventListener('click', () => setAllExpanded(true));
collapseAllBtn.addEventListener('click', () => setAllExpanded(false));
getSelectedBtn.addEventListener('click', () => {
const selected = getSelected();
output.textContent = selected.length
? selected.map((s) => `✓ ${s.label}`).join('\n')
: 'No items selected.';
});
})();Step 4: Code Walkthrough
The Core Problem: Bidirectional Propagation
When a user checks or unchecks a checkbox, state must flow in two directions:
[Parent] ← propagateUp: recalculate from children
/ | \
[Child] [Child] [Child] ← propagateDown: set all to same value
/ \
[L] [L] ← leaf nodes: source of truth
Downward propagation is simple — set all descendants to the same checked value:
function propagateDown(node, checked) {
if (!node.children) return;
for (const child of node.children) {
checkedState.set(child.id, checked);
propagateDown(child, checked);
}
}Upward propagation is trickier — each parent must derive its state from all its children:
function propagateUp(node) {
if (!node) return;
const allChecked = node.children.every((c) => checkedState.get(c.id));
checkedState.set(node.id, allChecked);
propagateUp(node.parent); // continue up to root
}The Indeterminate State
HTML checkboxes have three visual states:
- Unchecked —
checked = false,indeterminate = false - Checked —
checked = true,indeterminate = false - Indeterminate —
indeterminate = true(shows a dash/minus)
The indeterminate state cannot be set via HTML — it's only available through JavaScript:
checkbox.indeterminate = true;A parent is indeterminate when some but not all of its children are checked (or when any child is itself indeterminate):
function isIndeterminate(node) {
if (!node.children) return false;
const childStates = node.children.map((c) => ({
checked: checkedState.get(c.id),
indeterminate: isIndeterminate(c),
}));
const allChecked = childStates.every((s) => s.checked && !s.indeterminate);
const noneChecked = childStates.every((s) => !s.checked && !s.indeterminate);
return !allChecked && !noneChecked;
}Important insight: We never store the indeterminate state. We only store binary checked/unchecked for each node and derive indeterminate at render time. Storing it would create consistency bugs because indeterminate depends on the entire subtree below.
State Design Decision
We use a Map<id, boolean> for checked state rather than storing checked on the data objects. This keeps the data immutable and makes it easy to serialize/deserialize the selection state separately from the tree structure.
const checkedState = new Map();Alternative: you could store checked directly on each node object. That's simpler but couples your data model to your UI state.
The Node Map
We build a flat lookup map from the tree for O(1) access to any node by ID:
const nodeMap = new Map(); // id → node
function buildNodeMap(nodes, parent) {
for (const node of nodes) {
node.parent = parent || null;
nodeMap.set(node.id, node);
if (node.children) buildNodeMap(node.children, node);
}
}We also add a parent reference to each node so we can walk up the tree. This is the classic "doubly-linked tree" pattern — children point to parents and parents point to children.
Step 5: Interview Variations
"What if the tree has thousands of nodes? How do you optimize?"
Two key optimizations:
1. Virtual rendering — only render visible nodes. Keep collapsed subtrees out of the DOM entirely:
function renderTree(nodes, isRoot) {
const ul = document.createElement('ul');
// ...
for (const node of nodes) {
// Only render children if expanded
if (node.children && isExpanded(node.id)) {
childContainer.appendChild(renderTree(node.children, false));
}
}
return ul;
}2. Batch DOM updates — instead of updating every checkbox individually, use a single requestAnimationFrame:
function updateAllCheckboxes() {
requestAnimationFrame(() => {
for (const cb of treeRoot.querySelectorAll('.tree-checkbox')) {
const id = cb.dataset.id;
cb.checked = checkedState.get(id);
cb.indeterminate = isIndeterminate(nodeMap.get(id));
}
});
}"How would you make indeterminate computation efficient?"
The current isIndeterminate recomputes the full subtree every time. For large trees, cache the computation:
const indeterminateCache = new Map();
function isIndeterminate(node) {
if (indeterminateCache.has(node.id)) return indeterminateCache.get(node.id);
if (!node.children) {
indeterminateCache.set(node.id, false);
return false;
}
const allChecked = node.children.every(
(c) => checkedState.get(c.id) && !isIndeterminate(c)
);
const noneChecked = node.children.every(
(c) => !checkedState.get(c.id) && !isIndeterminate(c)
);
const result = !allChecked && !noneChecked;
indeterminateCache.set(node.id, result);
return result;
}
// Clear cache when any checkbox changes
function handleCheck(id, checked) {
indeterminateCache.clear();
// ... rest of logic
}"How would you load children lazily from an API?"
Replace static children with a loadChildren function:
async function toggleExpand(li, toggleBtn, node) {
if (!node.childrenLoaded && node.hasChildren) {
toggleBtn.textContent = '⏳';
const children = await fetch(`/api/tree/${node.id}/children`).then((r) => r.json());
node.children = children;
node.childrenLoaded = true;
buildNodeMap(children, node);
const childContainer = li.querySelector('.tree-children');
childContainer.appendChild(renderTree(children, false));
toggleBtn.textContent = '▶';
}
// ... rest of toggle logic
}"Support drag-and-drop reordering"
Use the native Drag and Drop API:
li.draggable = true;
li.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', node.id);
});
li.addEventListener('dragover', (e) => {
e.preventDefault();
li.classList.add('drag-over');
});
li.addEventListener('drop', (e) => {
e.preventDefault();
const draggedId = e.dataTransfer.getData('text/plain');
moveNode(draggedId, node.id);
reRender();
});Common Mistakes in Interviews
-
Forgetting the indeterminate state. This is the whole point of the question. If you only support checked/unchecked, you're missing the core requirement.
-
Storing indeterminate as state. Indeterminate should be derived from children, not stored. Storing it leads to inconsistencies when children change.
-
Not propagating upward. When a leaf is checked, every ancestor up to the root must be recalculated. Forgetting this leaves parent checkboxes stale.
-
Infinite loops in propagation. Be careful not to trigger change events during propagation. Update the DOM state directly (
checkbox.checked = true) rather than dispatching synthetic events, which would re-trigger the handler. -
Not handling the "check parent when all children are checked" case. If you check the last unchecked child, the parent should become fully checked (not indeterminate).
Key Takeaway
The hierarchical checkbox is fundamentally about derived state. Only leaf checked/unchecked states are "real" — every parent's state is derived from its descendants. This is the same pattern behind React's "single source of truth," database normalization, and state management generally. Whenever you're tempted to store the same information in two places, ask yourself: can one be derived from the other?
You might also like
Build Connect Four from Scratch with Vanilla JS
A complete guide to building Connect Four using only HTML, CSS, and vanilla JavaScript. Covers gravity-based piece dropping, win detection across rows/columns/diagonals, and AI opponent. Interview-ready implementation.
FrontendBuild Snake from Scratch with Vanilla JS
A complete guide to building the classic Snake game using only HTML, CSS, and vanilla JavaScript. Covers game loops, collision detection, growing mechanics, and responsive canvas rendering. Interview-ready implementation.
FrontendBuild Tic-Tac-Toe from Scratch with Vanilla JS
A step-by-step guide to building a complete Tic-Tac-Toe game using only HTML, CSS, and vanilla JavaScript. Covers game state management, win detection, and AI opponent — perfect for frontend interview prep.