Components

File Tree

A component for displaying hierarchical file structures with expandable folders and selectable items.

Last updated on

Edit on GitHub

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 motion

Copy 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?

On this page