Tree View Components
Interactive hierarchical tree structures for displaying files, folders, and nested data. Perfect for file explorers, navigation menus, and organizational charts.
Installation
1. Stimulus Controller Setup
Start by adding the following controller to your project:
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["icon", "content"];
static values = {
animate: { type: Boolean, default: true }, // Whether to animate the tree view
};
connect() {
this.isAnimating = false;
// Initialize any folders that should start open
this.element.querySelectorAll('[data-state="open"]').forEach((el) => {
const button = el.previousElementSibling;
if (button) {
const icon = button.querySelector('[data-tree-view-target="icon"]');
if (icon) {
icon.classList.add("folder-open");
icon.innerHTML = this.openFolderSvg;
}
}
});
this.addKeyboardListeners();
}
disconnect() {
// Clean up any remaining event listeners
if (this.onTransitionEndBound) {
this.element.querySelectorAll('[data-tree-view-target="content"]').forEach((content) => {
content.removeEventListener("transitionend", this.onTransitionEndBound);
});
}
this.element.removeEventListener("keydown", this.handleKeydownBound);
}
addKeyboardListeners() {
this.handleKeydownBound = this.handleKeydown.bind(this);
this.element.addEventListener("keydown", this.handleKeydownBound);
}
handleKeydown(event) {
const triggers = Array.from(this.element.querySelectorAll('button[data-action="click->tree-view#toggle"]'));
const currentIndex = triggers.indexOf(document.activeElement);
if (currentIndex === -1) return;
switch (event.key) {
case "ArrowUp":
event.preventDefault();
let prevIndex = currentIndex;
do {
prevIndex = (prevIndex - 1 + triggers.length) % triggers.length;
} while (prevIndex !== currentIndex && this.isElementHidden(triggers[prevIndex]));
triggers[prevIndex].focus();
break;
case "ArrowDown":
event.preventDefault();
let nextIndex = currentIndex;
do {
nextIndex = (nextIndex + 1) % triggers.length;
} while (nextIndex !== currentIndex && this.isElementHidden(triggers[nextIndex]));
triggers[nextIndex].focus();
break;
case "Enter":
case " ":
event.preventDefault();
triggers[currentIndex].click();
break;
}
}
isElementHidden(element) {
let current = element;
while (current && current !== this.element) {
if (current.hasAttribute("hidden") || current.classList.contains("hidden")) {
return true;
}
current = current.parentElement;
}
return false;
}
toggle(event) {
const button = event.currentTarget;
const contentId = button.getAttribute("aria-controls");
// Scope the lookup to this controller's element to avoid conflicts with other tree views on the same page
const content = this.element.querySelector(`#${contentId}`);
const icon = button.querySelector('[data-tree-view-target="icon"]');
// Prevent multiple animations from running simultaneously
if (this.isAnimating) return;
const isOpen = button.getAttribute("aria-expanded") === "true";
// Toggle aria attributes
button.setAttribute("aria-expanded", !isOpen);
content.setAttribute("data-state", isOpen ? "closed" : "open");
if (this.animateValue) {
this.isAnimating = true;
// Remove any existing transition listeners
content.removeEventListener("transitionend", this.onTransitionEndBound);
this.onTransitionEndBound = () => {
if (isOpen) {
content.setAttribute("hidden", "");
}
content.style.height = "";
content.style.transition = "";
content.removeEventListener("transitionend", this.onTransitionEndBound);
this.isAnimating = false;
};
content.addEventListener("transitionend", this.onTransitionEndBound);
if (isOpen) {
// Closing animation
const height = content.scrollHeight;
content.style.height = height + "px";
// Force a reflow
content.offsetHeight;
content.style.transition = "height 300ms ease-out";
content.style.height = "0";
} else {
// Opening animation
content.removeAttribute("hidden");
content.style.height = "0";
// Force a reflow
content.offsetHeight;
content.style.transition = "height 300ms ease-out";
const height = content.scrollHeight;
content.style.height = height + "px";
}
} else {
// No animation - just toggle visibility
if (isOpen) {
content.setAttribute("hidden", "");
} else {
content.removeAttribute("hidden");
}
}
// Update icons
if (isOpen) {
icon.classList.remove("folder-open");
icon.innerHTML = this.closedFolderSvg;
} else {
icon.classList.add("folder-open");
icon.innerHTML = this.openFolderSvg;
}
// Update button state
button.setAttribute("data-state", isOpen ? "closed" : "open");
}
get openFolderSvg() {
return `<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor">
<path d="M5,14.75h-.75c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626c1.105,0,2,.895,2,2v1"></path>
<path d="M16.148,13.27l.843-3.13c.257-.953-.461-1.89-1.448-1.89H6.15c-.678,0-1.272,.455-1.448,1.11l-.942,3.5c-.257,.953,.461,1.89,1.448,1.89H14.217c.904,0,1.696-.607,1.931-1.48Z"></path>
</g>`;
}
get closedFolderSvg() {
return `<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor">
<path d="M13.75,5.25c1.105,0,2,.895,2,2v5.5c0,1.105-.895,2-2,2H4.25c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626Z"></path>
</g>`;
}
}
Examples
Basic Tree View
A hierarchical file and folder structure with collapsible directories and smooth animations.
<div data-controller="tree-view" data-tree-view-animate-value="true" class="h-auto w-full max-w-md p-4 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800">
<!-- Root folder "src" -->
<div class="overflow-hidden">
<button type="button"
data-action="click->tree-view#toggle"
aria-controls="tree-content-src"
aria-expanded="true"
data-state="open"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 outline-hidden focus:underline">
<svg data-tree-view-target="icon" class="folder-open" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M5,14.75h-.75c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626c1.105,0,2,.895,2,2v1"></path><path d="M16.148,13.27l.843-3.13c.257-.953-.461-1.89-1.448-1.89H6.15c-.678,0-1.272,.455-1.448,1.11l-.942,3.5c-.257,.953,.461,1.89,1.448,1.89H14.217c.904,0,1.696-.607,1.931-1.48Z"></path></g></svg>
<span class="font-medium">src</span>
</button>
<div id="tree-content-src"
data-state="open"
role="region"
class="ml-4 pl-2 border-l border-neutral-200 dark:border-neutral-700 space-y-1 overflow-hidden transition-[height]"
data-tree-view-target="content">
<!-- Folder "app" -->
<div class="overflow-hidden">
<button type="button"
data-action="click->tree-view#toggle"
aria-controls="tree-content-app"
aria-expanded="true"
data-state="open"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 outline-hidden focus:underline">
<svg data-tree-view-target="icon" class="folder-open" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M5,14.75h-.75c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626c1.105,0,2,.895,2,2v1"></path><path d="M16.148,13.27l.843-3.13c.257-.953-.461-1.89-1.448-1.89H6.15c-.678,0-1.272,.455-1.448,1.11l-.942,3.5c-.257,.953,.461,1.89,1.448,1.89H14.217c.904,0,1.696-.607,1.931-1.48Z"></path></g></svg>
<span class="font-medium">app</span>
</button>
<div id="tree-content-app"
data-state="open"
role="region"
class="ml-4 pl-2 border-l border-neutral-200 dark:border-neutral-700 space-y-1 overflow-hidden transition-[height]"
data-tree-view-target="content">
<!-- File items in app -->
<button type="button" data-tree-view-item class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>layout.tsx</span>
</button>
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>page.tsx</span>
</div>
<!-- Folder "components" -->
<div class="overflow-hidden">
<button type="button"
data-action="click->tree-view#toggle"
aria-controls="tree-content-components"
aria-expanded="true"
data-state="open"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 outline-hidden focus:underline">
<svg data-tree-view-target="icon" class="folder-open" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M5,14.75h-.75c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626c1.105,0,2,.895,2,2v1"></path><path d="M16.148,13.27l.843-3.13c.257-.953-.461-1.89-1.448-1.89H6.15c-.678,0-1.272,.455-1.448,1.11l-.942,3.5c-.257,.953,.461,1.89,1.448,1.89H14.217c.904,0,1.696-.607,1.931-1.48Z"></path></g></svg>
<span class="font-medium">components</span>
</button>
<div id="tree-content-components"
data-state="open"
role="region"
class="ml-4 pl-2 border-l border-neutral-200 dark:border-neutral-700 space-y-1 overflow-hidden transition-[height]"
data-tree-view-target="content">
<!-- Folder "ui" -->
<div class="overflow-hidden">
<button type="button"
data-action="click->tree-view#toggle"
aria-controls="tree-content-ui"
aria-expanded="false"
data-state="closed"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 outline-hidden focus:underline">
<svg data-tree-view-target="icon" class="folder-closed" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M13.75,5.25c1.105,0,2,.895,2,2v5.5c0,1.105-.895,2-2,2H4.25c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626Z"></path></g></svg>
<span class="font-medium">ui</span>
</button>
<div id="tree-content-ui"
data-state="closed"
role="region"
class="ml-4 pl-2 border-l border-neutral-200 dark:border-neutral-700 space-y-1 overflow-hidden transition-[height]"
data-tree-view-target="content"
hidden>
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>button.tsx</span>
</div>
</div>
</div>
<!-- Files in "components" -->
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>header.tsx</span>
</div>
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>footer.tsx</span>
</div>
</div>
</div>
</div>
</div>
<!-- Folder "lib" -->
<div class="overflow-hidden">
<button type="button"
data-action="click->tree-view#toggle"
aria-controls="tree-content-lib"
aria-expanded="true"
data-state="open"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 outline-hidden focus:underline">
<svg data-tree-view-target="icon" class="folder-open" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M5,14.75h-.75c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626c1.105,0,2,.895,2,2v1"></path><path d="M16.148,13.27l.843-3.13c.257-.953-.461-1.89-1.448-1.89H6.15c-.678,0-1.272,.455-1.448,1.11l-.942,3.5c-.257,.953,.461,1.89,1.448,1.89H14.217c.904,0,1.696-.607,1.931-1.48Z"></path></g></svg>
<span class="font-medium">lib</span>
</button>
<div id="tree-content-lib"
data-state="open"
role="region"
class="ml-4 pl-2 border-l border-neutral-200 dark:border-neutral-700 space-y-1 overflow-hidden transition-[height]"
data-tree-view-target="content">
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>utils.ts</span>
</div>
</div>
</div>
</div>
</div>
</div>
Tree View with Checkboxes
A file tree with checkbox selection for each item. Select folders to automatically select all nested files and subfolders. Combines the tree-view and checkbox-select-all controllers.
<div data-controller="tree-view checkbox-select-all"
data-tree-view-animate-value="true"
data-checkbox-select-all-toggle-key-value="x"
class="h-auto w-full max-w-md p-4 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800">
<!-- Header with Select All -->
<div class="flex items-center gap-3 pb-3 mb-2 border-b border-neutral-200 dark:border-neutral-700">
<input
type="checkbox"
id="tree-select-all"
tabindex="0"
data-checkbox-select-all-target="selectAll"
data-action="click->checkbox-select-all#toggleAll">
<label for="tree-select-all" class="text-sm font-semibold text-neutral-900 dark:text-neutral-100 cursor-pointer select-none">
Select All Files
</label>
</div>
<!-- Root folder "src" -->
<div class="overflow-hidden" data-checkbox-select-all-target="parent">
<div class="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800">
<input
type="checkbox"
id="folder-src"
tabindex="0"
data-checkbox-select-all-target="checkbox"
data-action="click->checkbox-select-all#toggleChildren click->checkbox-select-all#toggle">
<button type="button"
data-action="click->tree-view#toggle"
aria-controls="tree-cb-content-src"
aria-expanded="true"
data-state="open"
class="flex flex-1 items-center gap-2 text-sm outline-hidden focus:underline">
<svg data-tree-view-target="icon" class="folder-open" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M5,14.75h-.75c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626c1.105,0,2,.895,2,2v1"></path><path d="M16.148,13.27l.843-3.13c.257-.953-.461-1.89-1.448-1.89H6.15c-.678,0-1.272,.455-1.448,1.11l-.942,3.5c-.257,.953,.461,1.89,1.448,1.89H14.217c.904,0,1.696-.607,1.931-1.48Z"></path></g></svg>
<span class="font-medium">src</span>
</button>
</div>
<div id="tree-cb-content-src"
data-state="open"
role="region"
class="ml-4 pl-2 border-l border-neutral-200 dark:border-neutral-700 space-y-1 overflow-hidden transition-[height]"
data-tree-view-target="content">
<!-- Folder "app" -->
<div class="overflow-hidden" data-checkbox-select-all-target="parent">
<div class="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800">
<input
type="checkbox"
id="folder-app"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggleChildren click->checkbox-select-all#toggle">
<button type="button"
data-action="click->tree-view#toggle"
aria-controls="tree-cb-content-app"
aria-expanded="true"
data-state="open"
class="flex flex-1 items-center gap-2 text-sm outline-hidden focus:underline">
<svg data-tree-view-target="icon" class="folder-open" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M5,14.75h-.75c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626c1.105,0,2,.895,2,2v1"></path><path d="M16.148,13.27l.843-3.13c.257-.953-.461-1.89-1.448-1.89H6.15c-.678,0-1.272,.455-1.448,1.11l-.942,3.5c-.257,.953,.461,1.89,1.448,1.89H14.217c.904,0,1.696-.607,1.931-1.48Z"></path></g></svg>
<span class="font-medium">app</span>
</button>
</div>
<div id="tree-cb-content-app"
data-state="open"
role="region"
class="ml-4 pl-2 border-l border-neutral-200 dark:border-neutral-700 space-y-1 overflow-hidden transition-[height]"
data-tree-view-target="content">
<!-- File: layout.tsx -->
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800 has-[:checked]:text-neutral-900 dark:has-[:checked]:text-neutral-50">
<input
type="checkbox"
name="files[]"
value="src/app/layout.tsx"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggle">
<button type="button" class="flex flex-1 items-center gap-2 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>layout.tsx</span>
</button>
</div>
<!-- File: page.tsx -->
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800 has-[:checked]:text-neutral-900 dark:has-[:checked]:text-neutral-50">
<input
type="checkbox"
name="files[]"
value="src/app/page.tsx"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggle">
<button type="button" class="flex flex-1 items-center gap-2 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>page.tsx</span>
</button>
</div>
<!-- Folder "components" -->
<div class="overflow-hidden" data-checkbox-select-all-target="parent">
<div class="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800">
<input
type="checkbox"
id="folder-components"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggleChildren click->checkbox-select-all#toggle">
<button type="button"
data-action="click->tree-view#toggle"
aria-controls="tree-cb-content-components"
aria-expanded="true"
data-state="open"
class="flex flex-1 items-center gap-2 text-sm outline-hidden focus:underline">
<svg data-tree-view-target="icon" class="folder-open" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M5,14.75h-.75c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626c1.105,0,2,.895,2,2v1"></path><path d="M16.148,13.27l.843-3.13c.257-.953-.461-1.89-1.448-1.89H6.15c-.678,0-1.272,.455-1.448,1.11l-.942,3.5c-.257,.953,.461,1.89,1.448,1.89H14.217c.904,0,1.696-.607,1.931-1.48Z"></path></g></svg>
<span class="font-medium">components</span>
</button>
</div>
<div id="tree-cb-content-components"
data-state="open"
role="region"
class="ml-4 pl-2 border-l border-neutral-200 dark:border-neutral-700 space-y-1 overflow-hidden transition-[height]"
data-tree-view-target="content">
<!-- Folder "ui" -->
<div class="overflow-hidden" data-checkbox-select-all-target="parent">
<div class="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800">
<input
type="checkbox"
id="folder-ui"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggleChildren click->checkbox-select-all#toggle">
<button type="button"
data-action="click->tree-view#toggle"
aria-controls="tree-cb-content-ui"
aria-expanded="false"
data-state="closed"
class="flex flex-1 items-center gap-2 text-sm outline-hidden focus:underline">
<svg data-tree-view-target="icon" class="folder-closed" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M13.75,5.25c1.105,0,2,.895,2,2v5.5c0,1.105-.895,2-2,2H4.25c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626Z"></path></g></svg>
<span class="font-medium">ui</span>
</button>
</div>
<div id="tree-cb-content-ui"
data-state="closed"
role="region"
class="ml-4 pl-2 border-l border-neutral-200 dark:border-neutral-700 space-y-1 overflow-hidden transition-[height]"
data-tree-view-target="content"
hidden>
<!-- File: button.tsx -->
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800 has-[:checked]:text-neutral-900 dark:has-[:checked]:text-neutral-50">
<input
type="checkbox"
name="files[]"
value="src/app/components/ui/button.tsx"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggle">
<button type="button" class="flex flex-1 items-center gap-2 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>button.tsx</span>
</button>
</div>
<!-- File: input.tsx -->
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800 has-[:checked]:text-neutral-900 dark:has-[:checked]:text-neutral-50">
<input
type="checkbox"
name="files[]"
value="src/app/components/ui/input.tsx"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggle">
<button type="button" class="flex flex-1 items-center gap-2 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>input.tsx</span>
</button>
</div>
</div>
</div>
<!-- File: header.tsx -->
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800 has-[:checked]:text-neutral-900 dark:has-[:checked]:text-neutral-50">
<input
type="checkbox"
name="files[]"
value="src/app/components/header.tsx"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggle">
<button type="button" class="flex flex-1 items-center gap-2 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>header.tsx</span>
</button>
</div>
<!-- File: footer.tsx -->
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800 has-[:checked]:text-neutral-900 dark:has-[:checked]:text-neutral-50">
<input
type="checkbox"
name="files[]"
value="src/app/components/footer.tsx"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggle">
<button type="button" class="flex flex-1 items-center gap-2 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>footer.tsx</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Folder "lib" -->
<div class="overflow-hidden" data-checkbox-select-all-target="parent">
<div class="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800">
<input
type="checkbox"
id="folder-lib"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggleChildren click->checkbox-select-all#toggle">
<button type="button"
data-action="click->tree-view#toggle"
aria-controls="tree-cb-content-lib"
aria-expanded="true"
data-state="open"
class="flex flex-1 items-center gap-2 text-sm outline-hidden focus:underline">
<svg data-tree-view-target="icon" class="folder-open" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M5,14.75h-.75c-1.105,0-2-.895-2-2V4.75c0-1.105,.895-2,2-2h1.825c.587,0,1.144,.258,1.524,.705l1.524,1.795h4.626c1.105,0,2,.895,2,2v1"></path><path d="M16.148,13.27l.843-3.13c.257-.953-.461-1.89-1.448-1.89H6.15c-.678,0-1.272,.455-1.448,1.11l-.942,3.5c-.257,.953,.461,1.89,1.448,1.89H14.217c.904,0,1.696-.607,1.931-1.48Z"></path></g></svg>
<span class="font-medium">lib</span>
</button>
</div>
<div id="tree-cb-content-lib"
data-state="open"
role="region"
class="ml-4 pl-2 border-l border-neutral-200 dark:border-neutral-700 space-y-1 overflow-hidden transition-[height]"
data-tree-view-target="content">
<!-- File: utils.ts -->
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800 has-[:checked]:text-neutral-900 dark:has-[:checked]:text-neutral-50">
<input
type="checkbox"
name="files[]"
value="src/lib/utils.ts"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggle">
<button type="button" class="flex flex-1 items-center gap-2 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>utils.ts</span>
</button>
</div>
<!-- File: constants.ts -->
<div class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-150 has-[:checked]:bg-neutral-100 dark:has-[:checked]:bg-neutral-800 has-[:checked]:text-neutral-900 dark:has-[:checked]:text-neutral-50">
<input
type="checkbox"
name="files[]"
value="src/lib/constants.ts"
tabindex="0"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggle">
<button type="button" class="flex flex-1 items-center gap-2 outline-hidden focus:underline">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path></g></svg>
<span>constants.ts</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
Configuration
The tree view component is powered by a Stimulus controller that provides keyboard navigation, smooth animations, and flexible folder/file management.
Controller Setup
Basic tree view structure with required data attributes:
<div data-controller="tree-view" data-tree-view-animate-value="true">
<div class="overflow-hidden">
<button type="button"
data-action="click->tree-view#toggle"
aria-controls="tree-content-folder"
aria-expanded="false"
data-state="closed">
<svg data-tree-view-target="icon"><!-- Folder icon --></svg>
<span>Folder Name</span>
</button>
<div id="tree-content-folder"
data-state="closed"
role="region"
data-tree-view-target="content"
hidden>
<!-- Nested content -->
</div>
</div>
</div>
Configuration Values
Targets
Actions
Required Attributes
Accessibility Features
- Keyboard Navigation: Use ↑ ↓ arrows to navigate between folders
-
ARIA Support: Proper
aria-expandedandaria-controlsattributes - Screen Reader Friendly: Semantic regions and proper focus management
- Keyboard Actions: Enter and Space to toggle folders
Icon Management
The controller automatically handles folder icon transitions:
- Closed Folders: Display a closed folder icon
- Open Folders: Display an open folder icon with papers/documents
- Automatic Switching: Icons change automatically based on folder state
Animation Control
The tree view supports smooth height animations:
- Smooth Expand: Content animates from height 0 to natural height
- Smooth Collapse: Content animates from natural height to 0
-
Disable Animations: Set
data-tree-view-animate-value="false"for instant toggling
Styling Classes
Key CSS classes used in the component:
-
overflow-hidden: Required for smooth height animations -
transition-[height]: Enables height transition animations -
border-l: Creates visual hierarchy lines -
ml-4 pl-2: Creates proper indentation for nested items
Usage Notes
-
Each folder button must have a unique
aria-controlsvalue -
Content areas must have matching
idattributes -
Use
data-state="open"to have folders start expanded - File items don't need the toggle action, only folder items do
- The controller handles keyboard navigation automatically for all clickable elements
Tree View with Checkbox Selection
Combine the tree-view controller with the checkbox-select-all controller to enable file/folder selection. This pattern is perfect for file managers, permission systems, or any hierarchical selection UI.
Combined Controller Setup
Add both controllers to the root element:
<div data-controller="tree-view checkbox-select-all"
data-tree-view-animate-value="true"
data-checkbox-select-all-toggle-key-value="x">
<!-- Select All Header -->
<input type="checkbox"
data-checkbox-select-all-target="selectAll"
data-action="click->checkbox-select-all#toggleAll">
<!-- Folder with Checkbox -->
<div data-checkbox-select-all-target="parent">
<div class="flex items-center gap-2">
<input type="checkbox"
data-checkbox-select-all-target="checkbox"
data-action="click->checkbox-select-all#toggleChildren click->checkbox-select-all#toggle">
<button data-action="click->tree-view#toggle"
aria-controls="folder-content"
aria-expanded="false">
<svg data-tree-view-target="icon"><!-- Folder icon --></svg>
<span>Folder Name</span>
</button>
</div>
<div id="folder-content" data-tree-view-target="content" hidden>
<!-- Child files with checkboxes -->
<input type="checkbox"
data-checkbox-select-all-target="checkbox child"
data-action="click->checkbox-select-all#toggle">
</div>
</div>
</div>