Components
File Tree
A component for displaying hierarchical file structures with expandable folders and selectable items.
Last updated on
Basic
import { FileTree } from "@/components/ui/file-tree";
const basicTreeData = [
{
id: "src",
name: "src",
type: "folder" as const,
children: [
{
id: "components",
name: "components",
type: "folder" as const,
children: [
{ id: "button", name: "Button.tsx", type: "file" as const },
{ id: "input", name: "Input.tsx", type: "file" as const },
],
},
{ id: "utils", name: "utils.ts", type: "file" as const },
],
},
{ id: "package", name: "package.json", type: "file" as const },
{ id: "readme", name: "README.md", type: "file" as const },
];
export function FileTreeBasicDemo() {
return (
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">Basic File Tree</h3>
<FileTree data={basicTreeData} />
</div>
);
}Expandable
import { FileTree } from "@/components/ui/file-tree";
const expandableTreeData = [
{
id: "src",
name: "src",
type: "folder" as const,
children: [
{
id: "components",
name: "components",
type: "folder" as const,
children: [
{ id: "button", name: "Button.tsx", type: "file" as const },
{ id: "input", name: "Input.tsx", type: "file" as const },
{ id: "card", name: "Card.tsx", type: "file" as const },
],
},
{
id: "hooks",
name: "hooks",
type: "folder" as const,
children: [
{ id: "use-theme", name: "use-theme.ts", type: "file" as const },
{ id: "use-mobile", name: "use-mobile.ts", type: "file" as const },
],
},
{ id: "utils", name: "utils.ts", type: "file" as const },
],
},
{
id: "public",
name: "public",
type: "folder" as const,
children: [
{ id: "favicon", name: "favicon.ico", type: "file" as const },
{ id: "logo", name: "logo.svg", type: "file" as const },
],
},
{ id: "package", name: "package.json", type: "file" as const },
{ id: "readme", name: "README.md", type: "file" as const },
];
export function FileTreeExpandableDemo() {
return (
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">Expandable File Tree</h3>
<FileTree
data={expandableTreeData}
defaultExpandedIds={["src", "components"]}
/>
</div>
);
}Fully Expanded
import { FileTree } from "@/components/ui/file-tree";
const fullExpandedTreeData = [
{
id: "src",
name: "src",
type: "folder" as const,
children: [
{
id: "components",
name: "components",
type: "folder" as const,
children: [
{ id: "button", name: "Button.tsx", type: "file" as const },
{ id: "input", name: "Input.tsx", type: "file" as const },
],
},
{
id: "utils",
name: "utils",
type: "folder" as const,
children: [
{ id: "cn", name: "cn.ts", type: "file" as const },
{ id: "format", name: "format.ts", type: "file" as const },
],
},
{ id: "app", name: "App.tsx", type: "file" as const },
],
},
{
id: "public",
name: "public",
type: "folder" as const,
children: [
{ id: "favicon", name: "favicon.ico", type: "file" as const },
{ id: "robots", name: "robots.txt", type: "file" as const },
],
},
{ id: "package", name: "package.json", type: "file" as const },
{ id: "tsconfig", name: "tsconfig.json", type: "file" as const },
];
export function FileTreeFullExpandedDemo() {
return (
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">Fully Expanded File Tree</h3>
<FileTree data={fullExpandedTreeData} expandAllByDefault />
</div>
);
}Selectable
import { useState } from "react";
import { FileTree, type TreeNode } from "@/components/ui/file-tree";
const selectableTreeData: TreeNode[] = [
{
id: "src",
name: "src",
type: "folder",
children: [
{
id: "components",
name: "components",
type: "folder",
children: [
{ id: "button", name: "Button.tsx", type: "file" },
{ id: "input", name: "Input.tsx", type: "file" },
],
},
{ id: "utils", name: "utils.ts", type: "file" },
],
},
{ id: "package", name: "package.json", type: "file" },
{ id: "readme", name: "README.md", type: "file" },
];
export function FileTreeSelectableDemo() {
const [selectedNode, setSelectedNode] = useState<TreeNode | null>(null);
const handleSelect = (node: TreeNode) => {
setSelectedNode(node);
};
return (
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">Selectable File Tree</h3>
<FileTree data={selectableTreeData} onSelect={handleSelect} />
{selectedNode && (
<p className="text-muted-foreground text-sm">
Selected: {selectedNode.name} ({selectedNode.type})
</p>
)}
</div>
);
}Custom Icons
import { FileCode, FileJson, Image, Package, Settings } from "lucide-react";
import { FileTree } from "@/components/ui/file-tree";
const customIconsTreeData = [
{
id: "src",
name: "src",
type: "folder" as const,
children: [
{
id: "components",
name: "components",
type: "folder" as const,
children: [
{
id: "button",
name: "Button.tsx",
type: "file" as const,
icon: <FileCode className="h-4 w-4 text-blue-400" />,
},
{
id: "input",
name: "Input.tsx",
type: "file" as const,
icon: <FileCode className="h-4 w-4 text-blue-400" />,
},
],
},
{
id: "utils",
name: "utils.ts",
type: "file" as const,
icon: <FileCode className="h-4 w-4 text-yellow-400" />,
},
],
},
{
id: "public",
name: "public",
type: "folder" as const,
children: [
{
id: "logo",
name: "logo.svg",
type: "file" as const,
icon: <Image className="h-4 w-4 text-pink-400" />,
},
{
id: "favicon",
name: "favicon.ico",
type: "file" as const,
icon: <Image className="h-4 w-4 text-pink-400" />,
},
],
},
{
id: "package",
name: "package.json",
type: "file" as const,
icon: <Package className="h-4 w-4 text-green-400" />,
},
{
id: "tsconfig",
name: "tsconfig.json",
type: "file" as const,
icon: <FileJson className="h-4 w-4 text-yellow-500" />,
},
{
id: "vite",
name: "vite.config.ts",
type: "file" as const,
icon: <Settings className="h-4 w-4 text-purple-400" />,
},
];
export function FileTreeCustomIconsDemo() {
return (
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">File Tree with Custom Icons</h3>
<FileTree data={customIconsTreeData} />
</div>
);
}Installation
CLI
npx shadcn@latest add "https://jolyui.dev/r/file-tree"Manual
Install the following dependencies:
npm install lucide-react motionCopy and paste the following code into your project.
import { ChevronRight, File, Folder, FolderOpen } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TreeNode {
id: string;
name: string;
type: "file" | "folder";
children?: TreeNode[];
icon?: React.ReactNode;
}
interface FileTreeContextValue {
selectedId: string | null;
setSelectedId: (id: string | null) => void;
expandedIds: Set<string>;
toggleExpanded: (id: string) => void;
}
const FileTreeContext = React.createContext<FileTreeContextValue | null>(null);
function useFileTree() {
const context = React.useContext(FileTreeContext);
if (!context) {
throw new Error("useFileTree must be used within a FileTree");
}
return context;
}
function getAllFolderIds(nodes: TreeNode[]): string[] {
const ids: string[] = [];
for (const node of nodes) {
if (node.type === "folder") {
ids.push(node.id);
if (node.children) {
ids.push(...getAllFolderIds(node.children));
}
}
}
return ids;
}
interface FileTreeProps {
data: TreeNode[];
className?: string;
defaultExpandedIds?: string[];
expandAllByDefault?: boolean;
onSelect?: (node: TreeNode) => void;
}
export function FileTree({
data,
className,
defaultExpandedIds = [],
expandAllByDefault = false,
onSelect,
}: FileTreeProps) {
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(
new Set(expandAllByDefault ? getAllFolderIds(data) : defaultExpandedIds),
);
const toggleExpanded = React.useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const handleSelect = React.useCallback(
(node: TreeNode) => {
setSelectedId(node.id);
onSelect?.(node);
},
[onSelect],
);
return (
<FileTreeContext.Provider
value={{
selectedId,
setSelectedId: (id) => setSelectedId(id),
expandedIds,
toggleExpanded,
}}
>
<div
className={cn(
"rounded-lg border border-border bg-card p-2 font-mono text-sm",
className,
)}
role="tree"
aria-label="File tree"
>
<TreeNodeList nodes={data} level={0} onSelect={handleSelect} />
</div>
</FileTreeContext.Provider>
);
}
interface TreeNodeListProps {
nodes: TreeNode[];
level: number;
onSelect: (node: TreeNode) => void;
}
function TreeNodeList({ nodes, level, onSelect }: TreeNodeListProps) {
return (
<ul className="space-y-0.5" role="group">
{nodes.map((node, index) => (
<TreeNodeItem
key={node.id}
node={node}
level={level}
onSelect={onSelect}
isLast={index === nodes.length - 1}
/>
))}
</ul>
);
}
interface TreeNodeItemProps {
node: TreeNode;
level: number;
onSelect: (node: TreeNode) => void;
isLast: boolean;
}
function TreeNodeItem({
node,
level,
onSelect,
isLast: _isLast,
}: TreeNodeItemProps) {
const { selectedId, expandedIds, toggleExpanded } = useFileTree();
const isExpanded = expandedIds.has(node.id);
const isSelected = selectedId === node.id;
const hasChildren =
node.type === "folder" && node.children && node.children.length > 0;
const handleClick = () => {
if (node.type === "folder") {
toggleExpanded(node.id);
}
onSelect(node);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
if (e.key === "ArrowRight" && node.type === "folder" && !isExpanded) {
e.preventDefault();
toggleExpanded(node.id);
}
if (e.key === "ArrowLeft" && node.type === "folder" && isExpanded) {
e.preventDefault();
toggleExpanded(node.id);
}
};
const getFileIcon = () => {
if (node.icon) return node.icon;
if (node.type === "folder") {
return isExpanded ? (
<FolderOpen className="h-4 w-4 text-tree-folder" />
) : (
<Folder className="h-4 w-4 text-tree-folder" />
);
}
return <File className="h-4 w-4 text-tree-file" />;
};
return (
<li
role="treeitem"
aria-expanded={node.type === "folder" ? isExpanded : undefined}
tabIndex={0}
>
<motion.div
className={cn(
"group relative flex cursor-pointer select-none items-center gap-1 rounded-md px-2 py-1.5 transition-colors",
isSelected
? "bg-tree-selected-bg text-foreground"
: "text-muted-foreground hover:bg-tree-hover hover:text-foreground",
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={0}
style={{ paddingLeft: `${level * 12 + 8}px` }}
whileTap={{ scale: 0.98 }}
layout
>
{/* Indent lines */}
{level > 0 && (
<div
className="absolute top-0 left-0 h-full"
style={{ width: `${level * 12}px` }}
>
{Array.from({ length: level }).map((_, i) => (
<div
key={i}
className="absolute top-0 h-full w-px bg-tree-line opacity-30"
style={{ left: `${i * 12 + 12}px` }}
/>
))}
</div>
)}
{/* Chevron for folders */}
{node.type === "folder" ? (
<motion.div
animate={{ rotate: isExpanded ? 90 : 0 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="flex h-4 w-4 shrink-0 items-center justify-center"
>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
</motion.div>
) : (
<div className="h-4 w-4 shrink-0" />
)}
{/* Icon */}
<motion.div
className="shrink-0"
initial={false}
animate={{ scale: isSelected ? 1.1 : 1 }}
transition={{ duration: 0.15 }}
>
{getFileIcon()}
</motion.div>
{/* Name */}
<span className="truncate text-[13px]">{node.name}</span>
{/* Selection indicator */}
{isSelected && (
<motion.div
className="absolute inset-y-0 left-0 w-0.5 rounded-full bg-tree-selected"
layoutId="selection-indicator"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
</motion.div>
{/* Children */}
<AnimatePresence initial={false}>
{hasChildren && isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { duration: 0.2, ease: [0.4, 0, 0.2, 1] },
opacity: { duration: 0.15, ease: "easeOut" },
}}
style={{ overflow: "hidden" }}
>
<TreeNodeList
nodes={node.children || []}
level={level + 1}
onSelect={onSelect}
/>
</motion.div>
)}
</AnimatePresence>
</li>
);
}
export { FileTreeContext, useFileTree };API Reference
FileTree
The main file tree component that renders a hierarchical structure of files and folders.
Prop
Type
TreeNode
The data structure for individual nodes in the file tree.
Prop
Type
How is this guide?