You Might Also Like
File Explorer Tree View — Free HTML CSS JS Snippet
File Explorer Tree View · Navigation · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
File Explorer Tree View — Recursive Rendering, Roving Tabindex & Ancestor-Preserving Filtering

Tree views are the component people most often reach for a library to get, and the two things that actually make one good — correct keyboard navigation and a filter that does not orphan matches — are both about a hundred lines of plain JavaScript. This snippet implements the full ARIA tree interaction pattern over a nested data structure, with no dependencies.
A flat row list derived from a nested tree
The data is nested, but keyboard navigation is linear: Arrow Down goes to the next *visible* row regardless of how deep it sits or which branch it belongs to. Every render walks the tree and, alongside building the DOM, pushes each visible node into a flat rows array. Movement then becomes index arithmetic on that array rather than tree traversal, which is what makes Arrow Up from the first child of a folder land on the folder itself with no special-casing. Rebuilding the list on every render also means it can never disagree with what is on screen.
Roving tabindex, not fifty tab stops
Exactly one row carries tabindex="0"; every other row is -1. Tab moves into the tree once and out again, and arrow keys move within it — the standard tree pattern, and the reason a large tree does not force keyboard users through dozens of stops to reach the content after it. Moving focus updates which row is tabbable, so returning to the tree later restores the last position rather than resetting to the top.
Arrow keys that behave like a real explorer
Right on a closed folder opens it; on an already-open folder it steps into the first child. Left on an open folder closes it; on anything else it jumps to the parent, found by scanning upward for the nearest row at a lower depth. That asymmetry is what makes tree navigation feel correct, and it is specified in the ARIA authoring practices precisely because implementations tend to skip it and use Left/Right as plain expand and collapse.
Filtering that keeps ancestors
The naive filter shows only matching nodes, which strands a matched file with no visible parent and destroys the structure the tree exists to convey. keeps() is recursive: a node survives if it matches *or if any descendant matches*, so the whole ancestor chain down to a deep match stays visible. While a filter is active every folder renders open, because typing a filter is an unambiguous statement that you want to see the matches rather than the folders that contain them.
Indentation from one custom property
Each row writes its depth into a --d custom property and a single CSS rule computes padding-left: calc(8px + var(--d) * 15px). There are no per-level selectors and no nested-margin arithmetic, so the tree renders correctly at any depth and the indent step is one number to change. Names are HTML-escaped before search highlighting is inserted, in that order, since filenames are untrusted data in any real explorer.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Paste this snippet into an AI assistant like Claude and ask it to add lazy loading, where a directory fetches its children the first time it is expanded and shows an inline spinner on that row — that is the change that makes a tree usable against a real filesystem or bucket rather than a constant. Other natural extensions: add type-ahead so pressing a letter jumps to the next visible node starting with it, which the ARIA tree pattern also specifies; add multi-select with Shift and Ctrl over the flat row list; add drag-and-drop to move nodes between folders with a validity check preventing a folder being dropped into its own descendant; or virtualise the row list so a tree with tens of thousands of nodes only renders the visible window.
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 file explorer tree view in plain HTML, CSS, and JavaScript — no frameworks or libraries.
Requirements:
- Render recursively from a nested array of nodes, each with a name, a type of 'dir' or 'file', an optional open flag and optional children. Any depth must work.
- On every render, build a FLAT array of the currently visible rows alongside the DOM, and drive all keyboard movement with index arithmetic on that array rather than by traversing the tree.
- Implement a roving tabindex: exactly one row has tabindex="0" and all others have -1, so the entire tree is a single tab stop. Update it as focus moves so returning to the tree restores the last position.
- Implement the full ARIA tree keyboard pattern: Arrow Up/Down move between visible rows; Arrow Right opens a closed folder or steps into the first child if already open; Arrow Left closes an open folder or jumps to the parent (found by scanning upward for the nearest row at a lower depth); Enter and Space activate; Home and End jump to first and last.
- Use role="tree" on the container, role="treeitem" on each node, role="group" on child lists, and keep aria-expanded accurate on folders.
- Add a text filter where a node is kept if its own name matches OR any descendant matches, so a deep match keeps its entire ancestor chain visible. Auto-open all folders while a filter is active and highlight the matched substring with <mark> — escaping the name to HTML FIRST and inserting the mark tags into the escaped string afterwards.
- Indent rows by writing the depth into a CSS custom property and computing padding-left with one calc() rule; do not write per-level selectors or use nested margins.
- Show recursive file counts on folders, a selected-path readout in the footer, and Expand all / Collapse all buttons.
- Style it as a dark sidebar panel with a rotating caret for open folders, distinct folder and file icons, hover and selected row states, and a visible :focus-visible ring.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Click to open folders and select filesClicking a folder toggles it open or closed; clicking a file selects it and writes its full path into the footer. Folders show a count of the files beneath them, including nested ones.
- 2Tab once to enter the treeOnly one row is in the tab order at a time, so the tree is a single tab stop rather than dozens. Tab again and focus leaves the tree entirely.
- 3Move with Arrow Up and DownMovement is linear across whatever is currently visible, so Arrow Down from the last child of a folder continues to the next sibling of that folder without any extra keystrokes.
- 4Open and close with Arrow Right and LeftRight opens a closed folder, or steps into the first child if it is already open. Left closes an open folder, or jumps up to the parent if the row is a file or an already-closed folder.
- 5Filter without losing structureTyping in the filter keeps every node whose name matches and every ancestor of a match, so a deep result still shows the path that leads to it. Matches are highlighted and folders auto-open while filtering.
- 6Expand or collapse everythingThe two header buttons walk the whole tree setting the open flag, then re-render — useful for getting an overview or for resetting after a deep exploration.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Exactly one row has tabindex="0" while all others have -1, so the browser treats the whole tree as a single tab stop and arrow keys handle movement inside it. Without it, a fifty-node tree adds fifty tab stops between whatever precedes it and whatever follows, which makes keyboard navigation of the surrounding page painful. It is the pattern the ARIA authoring practices specify for trees, tablists and menus.
Because that is the specified tree behaviour and it is what makes navigation efficient. On a closed folder, Right opens it. On a folder that is already open, there is nothing left to open, so Right steps into the first child. Left mirrors it: close if open, otherwise jump to the parent. Treating Left and Right as plain expand and collapse is the most common shortcut, and it leaves users unable to move up the hierarchy by keyboard.
keeps() is recursive: a node is kept if its own name matches OR if any descendant is kept. That means a match five levels deep keeps every ancestor above it visible, so you can see where the result actually lives. Filtering to matches alone produces a flat list of orphaned names, which defeats the purpose of a tree.
Replace the TREE constant with your fetched data in the same shape — objects with name, type, optional open and optional children — and call render(). For lazy loading, give directories a loaded flag, fetch children on first expand, assign them to node.children, then re-render; the flat row list and keyboard logic need no changes because they are derived from whatever the tree currently contains.
Each row writes its depth into a --d custom property and one CSS rule computes padding-left: calc(8px + var(--d) * 15px). There are no per-level selectors, so any depth renders correctly and changing the indent step is a single number. It also keeps the row background spanning the full width, which nested margins would break.
Yes. Hold the tree and the set of open paths in state and render recursively with a component that calls itself for children. Keep the flat visible-row list as a memo derived from that state for keyboard movement, and manage the roving tabindex by storing the focused path in state and setting tabIndex={path === focusedPath ? 0 : -1}, calling .focus() through a ref in an effect when it changes.