Recursive folder structure, expand/collapse animation and drag-target highlighting with Tailwind CSS
A browser-based file explorer has to render a folder structure of unknown depth without the indentation spiraling out of control visually. This pattern walks through building a recursive tree with Tailwind CSS and Alpine.js: from the indentation logic through animated expand/collapse icons and file-type icons to highlighting selection and drop targets during drag-and-drop moves.
Table of Contents
- 1. Why a file tree needs its own way of thinking
- 2. The recursive tree component with Alpine
- 3. Indentation per nesting depth
- 4. Expand/collapse icons with a rotation transition
- 5. File-type dependent icons
- 6. Selection highlight for the active entry
- 7. Drag-target highlighting while moving files
- 8. Performance with very large tree structures
- 9. Limits of the pattern and common pitfalls
- 10. Summary
- 11. FAQ
1. Why a file tree needs its own way of thinking
Unlike a flat list or a simple grid, a file tree has to represent a structure whose depth is not known ahead of time: a folder can be empty, contain five files, or itself nest ten subfolders that each nest subfolders of their own. That unknown depth cannot be solved in HTML with a fixed number of nested div elements, it requires a recursive component that calls itself again with a folder's children until no further subfolders remain.
Beyond the pure structure, several interactive requirements come into play that feel obvious in a native desktop file explorer but each need to be built explicitly in the browser: expanding and collapsing individual folders with visual feedback, a clear at-a-glance distinction between file types, a marker for the currently selected entry, and a highlight on the target folder while a file is being dragged over it. Tailwind supplies the utility classes for that, Alpine.js handles the per-node state logic.
2. The recursive tree component with Alpine
The core of the pattern is an Alpine component that references itself recursively through a template tag with x-if and a call to itself nested inside. Each node gets its own x-data with a boolean for the expand/collapse state, so any folder can open or close independently of its siblings and its parent node. A folder's children only render once the folder itself is open, so a large tree doesn't dump its entire structure into the DOM immediately.
It matters that the recursive component is embedded as a reusable Alpine template via x-if inside a template element and registered as an Alpine component, rather than trying to recursively call a web component or a server-side templating engine. That keeps all the logic in a single file and makes the tree independent of how many levels actually exist, without needing a separate template for every possible depth.
<template x-if="true">
<ul class="space-y-0.5" :style="`padding-left: ${depth * 1}rem`">
<template x-for="node in nodes" :key="node.id">
<li>
<div
class="group flex items-center gap-1.5 rounded px-1.5 py-1 text-sm hover:bg-slate-100"
:class="node.id === selectedId && 'bg-blue-50 text-blue-700'"
@click="node.type === 'file' ? select(node.id) : toggle(node)"
>
<svg
x-show="node.type === 'folder'"
class="h-3.5 w-3.5 shrink-0 transition-transform duration-150"
:class="node.open && 'rotate-90'"
><!-- Chevron --></svg>
<span x-text="fileIcon(node)" class="w-4 shrink-0 text-center"></span>
<span x-text="node.name" class="truncate"></span>
</div>
<template x-if="node.type === 'folder' && node.open">
<div x-html="renderChildren(node.children, depth + 1)"></div>
</template>
</li>
</template>
</ul>
</template>
3. Indentation per nesting depth
Indentation needs to grow proportionally with depth so the hierarchy stays readable at a glance, without eating up all the available horizontal space on very deep structures. In practice a value between 0.875rem and 1.25rem per level works well, computed via an inline style with the current depth as an Alpine variable, since Tailwind's static utility classes can't express a dynamic multiplication by a number that is unknown at build time.
For very deep trees, say beyond eight or nine levels, capping the maximum indentation is worth doing so deeply nested entries don't drift entirely out of the visible area. An extra vertical guide line, implemented as a left border on the wrapping container of each level, additionally helps group related entries visually even at greater depth, so indentation alone doesn't have to carry the entire orientation burden.
4. Expand/collapse icons with a rotation transition
A single chevron icon per folder is enough to represent both the closed and open states, as long as it visibly rotates on open. With transition-transform and a conditional rotate-90 class, that works without needing two separate icon sets: closed, the chevron points right; open, it points down, expressed through a 90-degree rotation. The transition duration should stay short, around 150 milliseconds, otherwise users clicking through many folders quickly would end up waiting on the animation.
Folders without children should not show a chevron at all, otherwise the structure feels inconsistent when clicking an apparently expandable icon does nothing. The cleanest fix is conditional visibility based on whether the node's children array is empty, rather than rendering the icon grayed out but still clickable.
5. File-type dependent icons
So users can recognize a file's type without reading the filename all the way to its extension, every file entry gets an icon that depends on that extension. In practice this is handled with a JavaScript lookup table that maps common extensions like .js, .css, .md or .png to a matching icon or icon color, with a generic icon as a fallback for unknown extensions so an empty placeholder never appears.
For color, a limited palette works better than an individually distinct color for every file type, for example yellow for configuration files, blue for code files, and gray for text files, so the overview doesn't tip into visual clutter once many different extensions are present. Folder icons should stand out clearly from that palette, usually via a muted yellow tone, so the basic distinction between folder and file is instantly clear even at a quick glance.
6. Selection highlight for the active entry
A selected entry needs a clearly visible but not overwhelming highlight, since the tree often sits next to a preview pane showing the selected file's content. A light background color combined with a darker text color for the icon and name works reliably; what matters is that the selected node's ID lives centrally in a shared Alpine store or a parent component rather than being stored redundantly in every individual node.
With multi-selection, for example holding Ctrl or Shift, complexity grows noticeably, since a set of IDs needs managing instead of a single ID, and shift-selection has to account for the last-clicked range within the visible, currently expanded order. For most use cases, though, single selection is enough; multi-selection only pays off once actions like moving or deleting are actually applied to several files at once.
7. Drag-target highlighting while moving files
While dragging a file, the target folder currently hovered over needs to be clearly highlighted, so the user can be confident which folder the file would land in if released right now. The native HTML5 drag events dragover and dragleave wire up directly with Alpine, where dragover must call preventDefault(), otherwise the browser refuses any drop on that element by default.
Visually, a clear border in an accent color combined with a slightly different background color for the current drop target is enough. A common mistake is dragleave firing even when the mouse merely moves over a child element within the same folder, which makes the highlight flicker unwantedly. A counter, incremented on every dragenter and decremented on every dragleave, fixes this by only removing the highlight once the counter reaches zero.
<div
x-data="{ dragCount: 0 }"
@dragenter.prevent="dragCount++"
@dragleave.prevent="dragCount--"
@dragover.prevent
@drop.prevent="dragCount = 0; moveInto(node.id)"
class="rounded px-1.5 py-1"
:class="dragCount > 0 && 'bg-blue-100 ring-1 ring-blue-400'"
>
<span x-text="node.name"></span>
</div>
8. Performance with very large tree structures
With several thousand files and folders, naively rendering the entire tree, even with subfolders collapsed, becomes noticeably slow, since Alpine has to set up reactive watchers for every node as soon as it exists in the DOM. A first improvement is to actually remove a folder's children from the DOM, or not render them at all, until first expanded, instead of merely hiding them via CSS, since hidden but still present elements still cost memory and processing time.
For truly large structures, say an entire project directory with tens of thousands of files, client-side rendering eventually stops being enough regardless of how sparingly DOM nodes are kept. Here either server-side pagination per folder level or genuine virtualization of visible rows is worth it, where only entries currently within the viewport actually exist in the DOM and the rest loads dynamically while scrolling.
9. Limits of the pattern and common pitfalls
A common pitfall is circular or extremely deep nesting in the input data, for example from a broken symbolic link pointing back to itself or a parent folder. Without a depth cap in the recursive component, that can cause an infinite render loop that completely freezes the browser tab. A simple maximum recursion depth as a safety net, showing a warning instead of another recursive call once exceeded, reliably prevents this scenario.
A second limit concerns drag-and-drop interaction on touch devices: native HTML5 drag events don't work reliably on most mobile browsers, which means a file tree that also needs to work on tablets needs a touch-based alternative on top, for example pointer events or an explicit move action in a context menu instead of pure dragging. Anyone only expecting desktop usage can skip this extra work, but should document it as a deliberate limitation.
| Element | State | Tailwind classes | Purpose |
|---|---|---|---|
| Folder chevron | open (per node) | rotate-90, transition-transform | Visual feedback on expand/collapse |
| Indentation | depth (per level) | inline style padding-left: depth * 1rem | Make hierarchy visible proportional to depth |
| Selected entry | selectedId (central) | bg-blue-50, text-blue-700 | Clearly highlight the current selection |
| Drop target | dragCount > 0 | bg-blue-100, ring-1 ring-blue-400 | Highlight the target folder while dragging a file |
Mironsoft
Tailwind CSS architecture, design systems, and performance
Tailwind frontends that stay maintainable despite thousands of utility classes?
We review existing Tailwind projects for bloated class lists, inconsistent design tokens, and unused CSS remnants, then build a design system that scales cleanly instead of getting messier with every component.
Design System Review
Checking tokens, spacing scale, and component consistency for maintainability.
Performance Optimization
Systematically reducing CSS bundle size, purge configuration, and load times.
Component Architecture
Building reusable, well-structured components instead of sprawling class lists.
10. Summary
File Tree Explorer with Tailwind: The Essentials at a Glance
Recursion
An Alpine component calls itself again via a template with x-if until no subfolders remain.
Indentation
Inline style with the current depth as a variable, since static Tailwind classes can't express dynamic multiplication.
File-type icons
A lookup table from file extension to icon and color, with a generic fallback icon for unknown extensions.
Drag target
A counter instead of a plain boolean for dragenter/dragleave, to avoid flicker when crossing child elements.