More Navigation Snippets
Tree Menu — Free HTML CSS JS Snippet
Collapsible Tree Menu · Navigation · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Collapsible Tree Menu — Folder Toggle, max-height Animation, Active File & Collapse All

Open a file explorer with two thousand files dumped into one flat list and you'd never find anything — fold the same two thousand into folders-within-folders, and suddenly they're navigable, because at any moment you only have to look at the dozen or so that share your current context. This snippet builds that exact structure: a nested list of folders and files, each folder a click away from expanding or collapsing with a smooth height animation, a chevron that rotates to mirror its state, single-file selection, and a "collapse all" shortcut for when a dozen open folders have turned the sidebar into its own thing to scroll through.
Animating a height nobody can know in advance
CSS transitions height cleanly between two known values, but a folder's expanded height depends entirely on how many files and subfolders happen to be inside it — a number this snippet has no way to know ahead of time. The workaround is max-height: collapsed subtrees sit at max-height: 0 with overflow: hidden, and .open ones jump to max-height: 1000px — not because any real subtree is that tall, but because it's comfortably larger than any realistic one. transition: max-height 0.25s ease animates between the two, and because the browser only ever paints the *actual* content height, the motion reads as if it's measuring the real size, even though it's really racing toward an arbitrary ceiling and stopping early. It's the same trick the Accordion / FAQ snippet leans on to expand its answer panels.
One class name driving two animations at once
toggle() does exactly one meaningful thing: it flips .open on the folder's list item. Everything visible after that — the subtree sliding open via max-height, and the chevron rotating via transform: rotate(90deg) on .tree-node.folder.open .chevron — is pure CSS reacting to that single class change. There's no separate "now rotate the chevron" call to keep in sync with "now expand the subtree": if one fires, so does the other, because both are just selectors watching the same toggle land.
Indentation that comes from nesting, not from counting
Working out how deep a node sits and assigning it a matching padding-left in JavaScript would mean walking the tree and threading a depth counter through every recursive call. This snippet skips that bookkeeping with descendant selectors instead: .subtree .node-row gets one padding value, .subtree .subtree .node-row gets a larger one, and so on — the *selector itself* encodes the nesting depth, so a folder three levels down ends up indented correctly purely because of where its markup physically sits in the document.
Selecting one file, and only one
selectFile() opens by stripping .active from every node that currently carries it — document.querySelectorAll('.tree-node.file.active').forEach(n => n.classList.remove('active')) — and only then adds it to the file just clicked. That "clear everything, then set the one thing" order is the simplest possible way to guarantee a single-selection invariant: there's no special case for "what if two were already active," because the first line makes that state impossible to reach. Right-click a node in a real file explorer, though, and you'd expect a menu of actions to appear — precisely what the Context Menu snippet adds on top of a tree like this one.
Closing everything in one pass
collapseAll() runs the inverse of toggle() across every currently open folder at once — stripping .open, adding .closed to each subtree, and resetting aria-expanded back to "false". It's a short function, but it fixes a real annoyance: after wandering deep into a tree, getting back to a clean overview shouldn't mean clicking a dozen folders shut by hand, one breadcrumb at a time.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Instead of reasoning through every selector by hand, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to walk through why the subtree max-height is set to a flat 1000px rather than any measured value, and what that arbitrary ceiling means for the closing animation's timing on a very short branch versus a very tall one. It's also worth asking it to optimize the indentation approach — the fixed .subtree .subtree descendant selectors only cover a few nesting levels, so ask what happens (and what to change) for a tree ten levels deep. For extending it, have it add keyboard arrow-key navigation between rows, lazy-loaded children fetched on first expand, or a right-click context menu using the pattern from the separate context menu snippet. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a collapsible file-tree menu in plain HTML, CSS, and vanilla JavaScript, using only CSS for the expand/collapse animation (no JS height measurement, no libraries).
Requirements:
- A nested unordered list where each folder is a list item containing a clickable row and a nested ul.subtree of its own children; each file is a leaf list item with no subtree.
- Collapsed subtrees must be styled with max-height: 0 and overflow: hidden, and open ones with a fixed max-height comfortably larger than any realistic subtree (e.g. 1000px), with a CSS transition on max-height so toggling reads as a smooth slide rather than an instant snap — the actual rendered height must still be determined by the real content, not by the max-height ceiling.
- A single toggle() function must add or remove one "open" class on the folder's list item; a chevron icon must rotate 90 degrees purely via a CSS selector reacting to that same class, with no separate JS call to rotate it.
- Indentation for each nesting level must come from CSS descendant selectors (e.g. .subtree .node-row, .subtree .subtree .node-row) rather than inline styles or a JS-computed depth value.
- Clicking a file row must clear the "active" class from every other file first, then add it to the clicked file, guaranteeing only one file is ever marked active.
- A "collapse all" button must find every currently open folder and close them all in a single pass, resetting aria-expanded to false on each.
- Use role="tree" and role="treeitem" with aria-expanded reflecting each folder's state.Step by step
How to Use
- 1Click folder names to expand or collapseClicking any folder row toggles it open or closed with a smooth max-height animation. The chevron rotates 90 degrees to indicate the state. Click the collapse-all icon to close all open folders at once.
- 2Click file names to select themClicking a file highlights it with an indigo background (active state). Only one file can be active at a time. The active file remains highlighted until another is clicked.
- 3Update the tree structure with your own filesAdd .tree-node.folder li elements for directories and .tree-node.file li elements for files. Nest .subtree ul lists inside folder nodes. Update .node-icon-leaf emojis or SVG icons to match your file types.
- 4Add more nesting levelsThe CSS handles up to 3 nesting levels via .subtree selectors. For deeper nesting, add .subtree .subtree .subtree .node-row { padding-left: 54px; } and extend the pattern.
- 5Add right-click context menuListen for contextmenu on each .node-row: row.addEventListener("contextmenu", e => { e.preventDefault(); showContextMenu(e, node); }). See the Context Menu snippet in the Navigation category for the full implementation.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component with an items array prop, or "Tailwind" for a React + Tailwind CSS version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Subtrees have max-height: 1000px when open (which accommodates any realistic tree depth) and max-height: 0 when closed. CSS transition: max-height 0.25s ease animates between these values. The browser interpolates from 0 to 1000px — even though the actual content might only be 120px, the transition appears to stop when the content is fully visible. The trade-off: the closing animation is always 0.25s regardless of actual height, which can feel slightly off for very deep trees.
Define a buildTree(nodes, container) function that iterates an array of {name, type, children} objects. For folders: create an li with .tree-node.folder, add a .node-row div, create a .subtree ul, and recurse. For files: create an li with .tree-node.file and a .node-row.leaf div. Append each li to the container. Call toggle(row) event listeners after generation. This pattern renders any nested data structure as a tree UI.
Add a keydown listener to the tree: for ArrowDown, find the next visible node and focus it. For ArrowUp, find the previous visible node. For ArrowRight, expand the current folder. For ArrowLeft, collapse the current folder or move to its parent. For Enter, select the current item. Track the focused node with a data-focused attribute. Apply focus styles with .node-row:focus-visible rules.
Click "JSX" to download. Define a TreeNode component that accepts name, type, and children props. Manage open state with useState(initiallyOpen). The subtree div has style={{ maxHeight: open ? 1000 : 0, overflow: "hidden", transition: "max-height 0.25s ease" }}. For file selection, lift the selectedFile state to the parent Tree component and pass setSelected as a callback.