A file explorer in a dark panel. Folders open and close with a smooth change in height, a guide line ties each folder's contents to it, and file icons change with the file type. Choosing a file highlights it and updates the panel beside the tree with its path, a short description and when it last changed. A button at the top collapses or expands every folder. On phones the tree sits above the details panel.
// Adapted from Magic UI (https://magicui.design/docs/components/file-tree)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, File, FileCode, FileJson, FileText, Folder, FolderOpen, Image } from "lucide-react";
type TreeNode = { id: string; name: string; about?: string; lines?: number; edited?: string; children?: TreeNode[] };
const tree: TreeNode[] = [
{
id: "src",
name: "src",
children: [
{
id: "app",
name: "app",
children: [
{ id: "layout", name: "layout.tsx", about: "Shell around every page: header, cart button and footer.", lines: 48, edited: "Mina, 2 days ago" },
{ id: "home", name: "page.tsx", about: "The shop front with this week's roasts.", lines: 112, edited: "Arlo, yesterday" },
{
id: "checkout",
name: "checkout",
children: [
{ id: "checkout-page", name: "page.tsx", about: "Address, delivery slot and payment in one screen.", lines: 186, edited: "Mina, 3 hours ago" },
{ id: "summary", name: "order-summary.tsx", about: "Line items, shipping and the total, kept in view while you pay.", lines: 74, edited: "Arlo, last week" },
],
},
],
},
{
id: "components",
name: "components",
children: [
{ id: "cart", name: "cart-drawer.tsx", about: "Slides in from the right with quantity steppers.", lines: 139, edited: "Sefa, 5 days ago" },
{ id: "price", name: "price-tag.tsx", about: "Formats a price and shows the old one struck through on sale.", lines: 31, edited: "Sefa, last month" },
],
},
{
id: "lib",
name: "lib",
children: [{ id: "stock", name: "stock.ts", about: "Asks the warehouse how many bags are left before checkout.", lines: 57, edited: "Mina, 2 weeks ago" }],
},
],
},
{
id: "public",
name: "public",
children: [{ id: "og", name: "share-card.png", about: "The picture shown when the shop is shared in a message.", lines: 0, edited: "Arlo, in March" }],
},
{ id: "pkg", name: "package.json", about: "Scripts and the short list of packages the shop depends on.", lines: 34, edited: "Sefa, 5 days ago" },
{ id: "readme", name: "README.md", about: "How to run the shop on your own machine in three commands.", lines: 62, edited: "Mina, last month" },
];
const START_OPEN = ["src", "app", "checkout"];
const START_FILE = "checkout-page";
function iconFor(name: string) {
if (/\.(tsx|ts|js|jsx)$/.test(name)) return FileCode;
if (name.endsWith(".json")) return FileJson;
if (name.endsWith(".md")) return FileText;
if (/\.(png|jpg|svg)$/.test(name)) return Image;
return File;
}
/** Every folder id in the tree, for the expand-all button. */
function folderIds(nodes: TreeNode[]): string[] {
return nodes.flatMap((n) => (n.children ? [n.id, ...folderIds(n.children)] : []));
}
/** The files you can currently see, top to bottom, each with its path. */
function visibleFiles(nodes: TreeNode[], open: string[], path = ""): { node: TreeNode; path: string }[] {
return nodes.flatMap((n) => {
const here = `${path}${n.name}`;
if (!n.children) return [{ node: n, path: here }];
return open.includes(n.id) ? visibleFiles(n.children, open, `${here}/`) : [];
});
}
function allFiles(nodes: TreeNode[], path = ""): { node: TreeNode; path: string }[] {
return nodes.flatMap((n) => (n.children ? allFiles(n.children, `${path}${n.name}/`) : [{ node: n, path: `${path}${n.name}` }]));
}
type BranchProps = { nodes: TreeNode[]; open: string[]; selected: string; onToggle: (id: string) => void; onSelect: (id: string) => void; animate: boolean };
function Branch({ nodes, open, selected, onToggle, onSelect, animate }: BranchProps) {
const row = "flex min-h-[36px] w-full items-center gap-2 rounded-lg px-2 text-left text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-400";
return (
<ul className="flex flex-col gap-0.5">
{nodes.map((n) => {
if (!n.children) {
const Icon = iconFor(n.name);
const active = selected === n.id;
return (
<li key={n.id}>
<button type="button" aria-current={active ? "true" : undefined} onClick={() => onSelect(n.id)} className={`${row} transition-colors ${active ? "bg-teal-400/15 text-teal-200" : "text-slate-300 hover:bg-white/5 hover:text-white"}`}>
<Icon aria-hidden="true" className="h-4 w-4 shrink-0" />
<span className="truncate">{n.name}</span>
</button>
</li>
);
}
const isOpen = open.includes(n.id);
return (
<li key={n.id}>
<button type="button" aria-expanded={isOpen} onClick={() => onToggle(n.id)} className={`${row} font-medium text-slate-100 transition-colors hover:bg-white/5`}>
<ChevronRight aria-hidden="true" className={`h-3.5 w-3.5 shrink-0 text-slate-500 transition-transform duration-200 motion-reduce:transition-none ${isOpen ? "rotate-90" : ""}`} />
{isOpen ? <FolderOpen aria-hidden="true" className="h-4 w-4 shrink-0 text-amber-300" /> : <Folder aria-hidden="true" className="h-4 w-4 shrink-0 text-amber-300" />}
<span className="truncate">{n.name}</span>
<span className="ml-auto text-xs font-normal text-slate-500">{n.children.length}</span>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div key="content" initial={animate ? { height: 0, opacity: 0 } : false} animate={{ height: "auto", opacity: 1 }} exit={animate ? { height: 0, opacity: 0 } : undefined} transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }} className="overflow-hidden">
{/* The left border is the guide line that ties children to their folder. */}
<div className="ml-[15px] border-l border-slate-700 py-0.5 pl-2">
<Branch nodes={n.children} open={open} selected={selected} onToggle={onToggle} onSelect={onSelect} animate={animate} />
</div>
</motion.div>
)}
</AnimatePresence>
</li>
);
})}
</ul>
);
}
export default function FileTreeWithCollapsibleFolders() {
const reduce = useReducedMotion();
const [open, setOpen] = useState<string[]>(START_OPEN);
const [selected, setSelected] = useState(START_FILE);
const [touched, setTouched] = useState(false);
const everyFolder = useMemo(() => folderIds(tree), []);
const files = useMemo(() => allFiles(tree), []);
const current = files.find((f) => f.node.id === selected) ?? files[0];
const anyOpen = open.length > 0;
// Until someone uses the tree, step through the visible files so the preview is alive.
useEffect(() => {
if (touched || reduce) return;
const timer = window.setInterval(() => {
setSelected((id) => {
const list = visibleFiles(tree, open);
if (!list.length) return id;
const at = list.findIndex((f) => f.node.id === id);
return list[(at + 1) % list.length].node.id;
});
}, 2000);
return () => window.clearInterval(timer);
}, [touched, reduce, open]);
const toggle = (id: string) => setOpen((list) => (list.includes(id) ? list.filter((x) => x !== id) : [...list, id]));
const CurrentIcon = iconFor(current.node.name);
return (
<section className="bg-slate-100 px-5 py-16 sm:px-6 sm:py-20">
<div className="mx-auto max-w-5xl">
<div className="max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-widest text-teal-700">Project files</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl">Find any file in two clicks</h2>
<p className="mt-3 text-base leading-7 text-slate-600">Open a folder to see what is inside, pick a file to read what it does. This one is the code behind a small coffee shop.</p>
</div>
<div className="mt-8 grid overflow-hidden rounded-3xl bg-slate-900 shadow-2xl shadow-slate-900/20 md:grid-cols-[300px_1fr]" onPointerDown={() => setTouched(true)} onKeyDown={() => setTouched(true)}>
<nav aria-label="Project files" className="border-b border-slate-800 p-3 md:border-b-0 md:border-r">
<div className="flex items-center justify-between px-2 pb-2">
<span className="text-xs font-semibold uppercase tracking-widest text-slate-400">roastery-shop</span>
<button
type="button"
onClick={() => setOpen(anyOpen ? [] : everyFolder)}
className="flex h-10 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-slate-300 transition-colors hover:bg-white/5 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-400"
>
{anyOpen ? <ChevronsDownUp aria-hidden="true" className="h-4 w-4" /> : <ChevronsUpDown aria-hidden="true" className="h-4 w-4" />}
{anyOpen ? "Collapse all" : "Expand all"}
</button>
</div>
<Branch nodes={tree} open={open} selected={selected} onToggle={toggle} onSelect={setSelected} animate={!reduce} />
</nav>
<div className="flex min-h-[320px] flex-col p-6 sm:p-8" aria-live="polite">
<p className="break-all font-mono text-xs text-slate-400">{current.path}</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div key={current.node.id} initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={reduce ? undefined : { opacity: 0, y: -6 }} transition={{ duration: 0.18 }} className="flex flex-1 flex-col">
<h3 className="mt-4 flex items-center gap-3 text-2xl font-semibold tracking-tight text-white">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-teal-400/15 text-teal-300">
<CurrentIcon aria-hidden="true" className="h-5 w-5" />
</span>
{current.node.name}
</h3>
<p className="mt-3 max-w-md text-base leading-7 text-slate-300">{current.node.about}</p>
{/* A stand-in for the file's contents: bars the length of code lines. */}
<div aria-hidden="true" className="mt-6 space-y-2.5 rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
{[62, 38, 74, 50, 28, 66].map((w, i) => (
<div key={i} className="flex items-center gap-3">
<span className="w-4 text-right font-mono text-xs text-slate-600">{i + 1}</span>
<span className={`h-2 rounded-full ${i % 3 === 0 ? "bg-teal-400/60" : i % 3 === 1 ? "bg-slate-600" : "bg-amber-300/50"}`} style={{ width: `${(w + current.node.name.length * 3) % 80 + 15}%`, marginLeft: i === 0 || i === 5 ? 0 : 16 }} />
</div>
))}
</div>
<dl className="mt-auto flex flex-wrap gap-x-8 gap-y-2 pt-6 text-sm">
<div className="flex gap-2">
<dt className="text-slate-400">Length</dt>
<dd className="text-slate-200">{current.node.lines ? `${current.node.lines} lines` : "Image"}</dd>
</div>
<div className="flex gap-2">
<dt className="text-slate-400">Last change</dt>
<dd className="text-slate-200">{current.node.edited}</dd>
</div>
</dl>
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/file-tree-with-collapsible-folders.json
The contents of a folder are wrapped in a `motion.div` that animates its height between `0` and `auto` and fades at the same time. `AnimatePresence` keeps the contents on the page long enough to play the closing animation before removing them.
`Branch` is a component that renders a list of nodes and, for each open folder, renders another `Branch` for its children. There is no limit to the depth other than the width you give it.
Yes. In the `toggle` function, fetch the children for that id the first time it opens and store them in state. Show a small loading row in the folder while you wait.
Not out of the box. Every row is a button, so Tab, Shift+Tab, Enter and Space all work. If you need full arrow-key support, add `role="tree"` and `role="treeitem"` with a roving tab index.
`allFiles` walks the tree once and returns every file with its full path. The demo uses it to show the path at the top of the panel.
Adapted from File Tree in Magic UI, used under its open-source licence. Licences and credits
Related: App Shell with Sidebar and Top Bar, Underline Tabs with Icons and Three Panels, FAQ Accordion with Working Open and Close.
More tabs and navigation or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.