Tree View
An expandable, collapsible tree structure for displaying hierarchical data.
<!-- Tree View -->
<!-- An Alpine.js and Tailwind CSS component by https://pinemix.com -->
<div
x-data="{
// Tree structure data
nodes: [
{
id: 1, label: 'src', type: 'folder', expanded: true, children: [
{
id: 2, label: 'components', type: 'folder', expanded: false, children: [
{ id: 3, label: 'Button.tsx', type: 'file', children: [] },
{ id: 4, label: 'Modal.tsx', type: 'file', children: [] },
{ id: 5, label: 'Dropdown.tsx', type: 'file', children: [] }
]
},
{
id: 6, label: 'pages', type: 'folder', expanded: false, children: [
{ id: 7, label: 'Home.tsx', type: 'file', children: [] },
{ id: 8, label: 'About.tsx', type: 'file', children: [] }
]
},
{ id: 9, label: 'app.tsx', type: 'file', children: [] },
{ id: 10, label: 'main.css', type: 'file', children: [] }
]
},
{
id: 11, label: 'public', type: 'folder', expanded: false, children: [
{ id: 12, label: 'favicon.ico', type: 'file', children: [] },
{ id: 13, label: 'index.html', type: 'file', children: [] }
]
},
{ id: 14, label: 'package.json', type: 'file', children: [] },
{ id: 15, label: 'README.md', type: 'file', children: [] }
],
// Currently selected node id
selected: null,
// Flattened visible nodes for rendering
flatNodes: [],
init() {
this.refreshList();
},
// Rebuild the flat list from the tree
refreshList() {
this.flatNodes = [];
this.buildFlatList(this.nodes, 0);
},
buildFlatList(nodes, depth) {
for (const node of nodes) {
this.flatNodes.push({ node, depth });
if (node.type === 'folder' && node.expanded && node.children.length > 0) {
this.buildFlatList(node.children, depth + 1);
}
}
},
isFolder(node) {
return node.type === 'folder' && node.children.length > 0;
},
toggle(node) {
if (this.isFolder(node)) {
node.expanded = !node.expanded;
this.refreshList();
}
this.selected = node.id;
},
expandAll() {
this.setAllExpanded(this.nodes, true);
this.refreshList();
},
collapseAll() {
this.setAllExpanded(this.nodes, false);
this.refreshList();
},
setAllExpanded(nodes, state) {
for (const node of nodes) {
if (this.isFolder(node)) {
node.expanded = state;
this.setAllExpanded(node.children, state);
}
}
},
handleKeydown(event) {
const index = this.flatNodes.findIndex(item => item.node.id === this.selected);
if (event.key === 'ArrowDown') {
event.preventDefault();
const next = index < this.flatNodes.length - 1 ? index + 1 : 0;
this.selected = this.flatNodes[next >= 0 ? next : 0].node.id;
} else if (event.key === 'ArrowUp') {
event.preventDefault();
const prev = index > 0 ? index - 1 : 0;
this.selected = this.flatNodes[prev].node.id;
} else if (event.key === 'ArrowRight' && index >= 0) {
event.preventDefault();
const node = this.flatNodes[index].node;
if (this.isFolder(node) && !node.expanded) {
node.expanded = true;
this.refreshList();
}
} else if (event.key === 'ArrowLeft' && index >= 0) {
event.preventDefault();
const node = this.flatNodes[index].node;
if (this.isFolder(node) && node.expanded) {
node.expanded = false;
this.refreshList();
}
} else if ((event.key === 'Enter' || event.key === ' ') && index >= 0) {
event.preventDefault();
this.toggle(this.flatNodes[index].node);
}
}
}"
class="mx-auto w-full max-w-80"
>
<!-- Tree View Container -->
<div
class="overflow-hidden rounded-xl border border-zinc-200 bg-white ring-3 ring-zinc-200/50 dark:border-zinc-700 dark:bg-zinc-800/50 dark:ring-zinc-800"
>
<!-- Toolbar -->
<div
class="flex items-center justify-between gap-1 border-b border-zinc-200/75 px-4 py-2.5 dark:border-zinc-700/75"
>
<h3 class="text-sm font-semibold text-zinc-800 dark:text-zinc-200">
Files
</h3>
<div class="flex items-center gap-1">
<!-- Expand All Button -->
<button
x-on:click="expandAll()"
type="button"
title="Expand all"
class="flex size-6.5 items-center justify-center rounded-md text-zinc-500 hover:bg-zinc-100 hover:text-zinc-600 active:opacity-75 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="hi-micro hi-bars-arrow-down inline-block size-4"
>
<path
fill-rule="evenodd"
d="M2 2.75A.75.75 0 0 1 2.75 2h9.5a.75.75 0 0 1 0 1.5h-9.5A.75.75 0 0 1 2 2.75ZM2 6.25a.75.75 0 0 1 .75-.75h5.5a.75.75 0 0 1 0 1.5h-5.5A.75.75 0 0 1 2 6.25Zm0 3.5A.75.75 0 0 1 2.75 9h3.5a.75.75 0 0 1 0 1.5h-3.5A.75.75 0 0 1 2 9.75ZM14.78 11.47a.75.75 0 0 1 0 1.06l-2.25 2.25a.75.75 0 0 1-1.06 0l-2.25-2.25a.75.75 0 1 1 1.06-1.06l.97.97V6.75a.75.75 0 0 1 1.5 0v5.69l.97-.97a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
<!-- END Expand All Button -->
<!-- Collapse All Button -->
<button
x-on:click="collapseAll()"
type="button"
title="Collapse all"
class="flex size-6.5 items-center justify-center rounded-md text-zinc-500 hover:bg-zinc-100 hover:text-zinc-600 active:opacity-75 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="hi-micro hi-bars-arrow-up inline-block size-4"
>
<path
fill-rule="evenodd"
d="M2 2.75A.75.75 0 0 1 2.75 2h9.5a.75.75 0 0 1 0 1.5h-9.5A.75.75 0 0 1 2 2.75ZM2 6.25a.75.75 0 0 1 .75-.75h5.5a.75.75 0 0 1 0 1.5h-5.5A.75.75 0 0 1 2 6.25Zm0 3.5A.75.75 0 0 1 2.75 9h3.5a.75.75 0 0 1 0 1.5h-3.5A.75.75 0 0 1 2 9.75ZM9.22 9.53a.75.75 0 0 1 0-1.06l2.25-2.25a.75.75 0 0 1 1.06 0l2.25 2.25a.75.75 0 0 1-1.06 1.06l-.97-.97v5.69a.75.75 0 0 1-1.5 0V8.56l-.97.97a.75.75 0 0 1-1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
<!-- END Collapse All Button -->
</div>
</div>
<!-- END Toolbar -->
<!-- Tree Items -->
<div
x-on:keydown="handleKeydown($event)"
tabindex="0"
role="tree"
class="p-2 focus:outline-hidden"
>
<template x-for="item in flatNodes" :key="item.node.id">
<button
x-on:click="toggle(item.node)"
type="button"
role="treeitem"
x-bind:aria-expanded="isFolder(item.node) ? item.node.expanded : undefined"
x-bind:aria-selected="selected === item.node.id"
x-bind:aria-level="item.depth + 1"
x-bind:class="{
'bg-zinc-100 text-zinc-900 dark:bg-zinc-700/50 dark:text-white': selected === item.node.id,
'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700/30': selected !== item.node.id
}"
x-bind:style="{ paddingInlineStart: (item.depth * 1.25 + 0.5) + 'rem' }"
class="flex w-full items-center gap-1.5 rounded-lg py-1.5 pr-3 text-left text-sm"
>
<!-- Expand/Collapse Chevron -->
<svg
x-show="isFolder(item.node)"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="hi-micro hi-chevron-right inline-block size-4 flex-none opacity-50 transition-transform duration-150 rtl:rotate-180"
x-bind:class="{ 'rotate-90': item.node.expanded }"
x-cloak
>
<path
fill-rule="evenodd"
d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
<span x-show="!isFolder(item.node)" class="size-4 flex-none"></span>
<!-- END Expand/Collapse Chevron -->
<!-- Folder Icon (closed) -->
<svg
x-show="isFolder(item.node) && !item.node.expanded"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="hi-mini hi-folder inline-block size-5 flex-none text-zinc-400 dark:text-zinc-500"
x-cloak
>
<path
d="M3.75 3A1.75 1.75 0 0 0 2 4.75v3.26a3.235 3.235 0 0 1 1.75-.51h12.5c.644 0 1.245.188 1.75.51V6.75A1.75 1.75 0 0 0 16.25 5h-4.836a.25.25 0 0 1-.177-.073L9.823 3.513A1.75 1.75 0 0 0 8.586 3H3.75ZM3.75 9A1.75 1.75 0 0 0 2 10.75v4.5c0 .966.784 1.75 1.75 1.75h12.5A1.75 1.75 0 0 0 18 15.25v-4.5A1.75 1.75 0 0 0 16.25 9H3.75Z"
/>
</svg>
<!-- END Folder Icon (closed) -->
<!-- Folder Icon (open) -->
<svg
x-show="isFolder(item.node) && item.node.expanded"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="hi-mini hi-folder-open inline-block size-5 flex-none text-zinc-600 dark:text-zinc-300"
x-cloak
>
<path
d="M4.75 3A1.75 1.75 0 0 0 3 4.75v2.752l.104-.002h13.792c.035 0 .07 0 .104.002V6.75A1.75 1.75 0 0 0 15.25 5h-3.836a.25.25 0 0 1-.177-.073L9.823 3.513A1.75 1.75 0 0 0 8.586 3H4.75ZM3.104 9a1.75 1.75 0 0 0-1.673 2.265l1.385 4.5A1.75 1.75 0 0 0 4.488 17h11.023a1.75 1.75 0 0 0 1.673-1.235l1.384-4.5A1.75 1.75 0 0 0 16.896 9H3.104Z"
/>
</svg>
<!-- END Folder Icon (open) -->
<!-- File Icon -->
<svg
x-show="!isFolder(item.node)"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="hi-mini hi-document inline-block size-5 flex-none text-zinc-300 dark:text-zinc-600"
x-cloak
>
<path
d="M3 3.5A1.5 1.5 0 0 1 4.5 2h6.879a1.5 1.5 0 0 1 1.06.44l4.122 4.12A1.5 1.5 0 0 1 17 7.622V16.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 16.5v-13Z"
/>
</svg>
<!-- END File Icon -->
<!-- Label -->
<span x-text="item.node.label" class="truncate"></span>
</button>
</template>
</div>
<!-- END Tree Items -->
</div>
<!-- END Tree View Container -->
</div>
<!-- END Tree View -->
Checkable
<!-- Tree View -->
<!-- An Alpine.js and Tailwind CSS component by https://pinemix.com -->
<div
x-data="{
// Tree structure with checkable nodes
nodes: [
{
id: 1, label: 'Permissions', expanded: true, checked: false, children: [
{
id: 2, label: 'Users', expanded: true, checked: false, children: [
{ id: 3, label: 'Create users', checked: false, children: [] },
{ id: 4, label: 'Edit users', checked: false, children: [] },
{ id: 5, label: 'Delete users', checked: false, children: [] }
]
},
{
id: 6, label: 'Content', expanded: true, checked: false, children: [
{ id: 7, label: 'Create posts', checked: true, children: [] },
{ id: 8, label: 'Edit posts', checked: true, children: [] },
{ id: 9, label: 'Delete posts', checked: false, children: [] },
{ id: 10, label: 'Publish posts', checked: true, children: [] }
]
},
{
id: 11, label: 'Settings', expanded: false, checked: false, children: [
{ id: 12, label: 'General settings', checked: false, children: [] },
{ id: 13, label: 'Security settings', checked: false, children: [] }
]
}
]
}
],
// Flattened visible nodes for rendering
flatNodes: [],
init() {
this.refreshList();
},
// Rebuild the flat list from the tree
refreshList() {
this.flatNodes = [];
this.buildFlatList(this.nodes, 0);
},
buildFlatList(nodes, depth) {
for (const node of nodes) {
this.flatNodes.push({ node, depth });
if (node.children.length > 0 && node.expanded) {
this.buildFlatList(node.children, depth + 1);
}
}
},
isParent(node) {
return node.children && node.children.length > 0;
},
toggleExpand(node) {
if (this.isParent(node)) {
node.expanded = !node.expanded;
this.refreshList();
}
},
// Check state helpers using leaf nodes
getAllLeaves(node) {
if (node.children.length === 0) return [node];
let leaves = [];
for (const child of node.children) {
leaves = leaves.concat(this.getAllLeaves(child));
}
return leaves;
},
isChecked(node) {
if (node.children.length === 0) return node.checked;
return this.getAllLeaves(node).every(leaf => leaf.checked);
},
isIndeterminate(node) {
if (node.children.length === 0) return false;
const leaves = this.getAllLeaves(node);
const checkedCount = leaves.filter(l => l.checked).length;
return checkedCount > 0 && checkedCount < leaves.length;
},
// Toggle check state and propagate to children
toggleCheck(node) {
const newState = !this.isChecked(node);
this.setChecked(node, newState);
},
setChecked(node, state) {
if (node.children.length === 0) {
node.checked = state;
} else {
for (const child of node.children) {
this.setChecked(child, state);
}
}
},
// Count helpers
getSelectedCount() {
return this.getAllLeaves({ children: this.nodes }).filter(l => l.checked).length;
},
getTotalCount() {
return this.getAllLeaves({ children: this.nodes }).length;
},
selectAll() {
this.setChecked({ children: this.nodes }, true);
},
deselectAll() {
this.setChecked({ children: this.nodes }, false);
}
}"
class="mx-auto w-full max-w-80"
>
<!-- Tree View Container -->
<div
class="overflow-hidden rounded-xl border border-zinc-200 bg-white ring-3 ring-zinc-200/50 dark:border-zinc-700 dark:bg-zinc-800/50 dark:ring-zinc-800"
>
<!-- Toolbar -->
<div
class="flex items-center justify-between border-b border-zinc-200/75 px-4 py-2.5 dark:border-zinc-700/75"
>
<div class="flex items-center gap-1">
<h3 class="text-sm font-semibold text-zinc-800 dark:text-zinc-200">
Permissions
</h3>
<span
class="rounded-full bg-zinc-100 px-1.5 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300"
x-text="getSelectedCount() + '/' + getTotalCount()"
></span>
</div>
<div class="flex flex-none items-center gap-0.5 text-xs">
<!-- Select All Button -->
<button
x-on:click="selectAll()"
type="button"
class="flex h-6.5 items-center justify-center rounded-md px-1.5 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-600 active:opacity-75 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
>
Select all
</button>
<!-- END Select All Button -->
<span class="text-zinc-300 dark:text-zinc-600">|</span>
<!-- Deselect All Button -->
<button
x-on:click="deselectAll()"
type="button"
class="flex h-6.5 items-center justify-center rounded-md px-1.5 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-600 active:opacity-75 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
>
Clear
</button>
<!-- END Deselect All Button -->
</div>
</div>
<!-- END Toolbar -->
<!-- Tree Items -->
<div role="tree" class="p-2">
<template x-for="item in flatNodes" :key="item.node.id">
<div
role="treeitem"
x-bind:aria-expanded="isParent(item.node) ? item.node.expanded : undefined"
x-bind:aria-checked="isChecked(item.node)"
x-bind:aria-level="item.depth + 1"
x-bind:style="{ paddingInlineStart: (item.depth * 1.25 + 0.5) + 'rem' }"
class="flex items-center gap-1.5 rounded-lg py-1.5 pr-3 text-sm text-zinc-700 dark:text-zinc-300"
>
<!-- Expand/Collapse Chevron -->
<button
x-show="isParent(item.node)"
x-on:click="toggleExpand(item.node)"
type="button"
class="flex-none rounded p-0.5 text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="hi-micro hi-chevron-right inline-block size-4 flex-none opacity-50 transition-transform duration-150 rtl:rotate-180"
x-bind:class="{ 'rotate-90': item.node.expanded }"
>
<path
fill-rule="evenodd"
d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
<span x-show="!isParent(item.node)" class="size-5 flex-none"></span>
<!-- END Expand/Collapse Chevron -->
<!-- Checkbox -->
<input
type="checkbox"
x-bind:checked="isChecked(item.node)"
x-on:change="toggleCheck(item.node)"
x-effect="$el.indeterminate = isIndeterminate(item.node)"
class="size-4 flex-none cursor-pointer rounded border-zinc-300 text-zinc-800 checked:border-zinc-900 indeterminate:border-zinc-900 focus:ring-2 focus:ring-zinc-500/50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-500 dark:checked:border-zinc-600 dark:checked:bg-zinc-600 dark:indeterminate:border-zinc-600 dark:indeterminate:bg-zinc-600 dark:focus:ring-zinc-400/50"
/>
<!-- END Checkbox -->
<!-- Label -->
<button
x-on:click="isParent(item.node) ? toggleExpand(item.node) : toggleCheck(item.node)"
type="button"
class="cursor-pointer truncate text-left"
x-bind:class="{
'font-medium text-zinc-800 dark:text-zinc-200': isParent(item.node),
'text-zinc-600 dark:text-zinc-400': !isParent(item.node)
}"
x-text="item.node.label"
></button>
</div>
</template>
</div>
<!-- END Tree Items -->
</div>
<!-- END Tree View Container -->
</div>
<!-- END Tree View -->
Props
The available data properties for this component.
| Property | Default | Description |
|---|---|---|
| nodes | [] | A nested object array representing the tree. Each node needs 'id', 'label', 'children', and either 'type' and 'expanded' (file system) or 'checked' and 'expanded' (checkable) |
| selected | null | The currently selected node id (file system variant) |
About this component
Designed with
Tailkit
2,000+ Tailwind CSS code snippets for HTML, React, Vue.js and Alpine.js. AI-powered development with MCP Server.
Unlock 15+ free templates
Join our pixelcave newsletter to get them now & we'll also keep you updated about any new Pinemix components!