File Tree

Expandable and collapsible tree view for file structures.


Installation

CLI

npx shadcn@latest add "https://ui.xaclabs.dev/r/file-tree.json"

Usage

import { FileTree } from "@/registry/components/file-tree"
View component source
file-tree.tsx
tsx
"use client"

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 FileTreeItem {
  name: string
  type: "file" | "folder"
  children?: FileTreeItem[]
  icon?: React.ComponentType<{ className?: string }>
  highlight?: boolean
}

export interface FileTreeProps {
  items: FileTreeItem[]
  onFileClick?: (name: string, path: string) => void
  className?: string
  defaultExpanded?: boolean
}

interface FileTreeNodeProps {
  item: FileTreeItem
  depth: number
  path: string
  onFileClick?: (name: string, path: string) => void
  defaultExpanded: boolean
}

function FileTreeNode({
  item,
  depth,
  path,
  onFileClick,
  defaultExpanded,
}: FileTreeNodeProps) {
  const [expanded, setExpanded] = React.useState(defaultExpanded)
  const fullPath = path ? `${path}/${item.name}` : item.name
  const isFolder = item.type === "folder"
  const Icon = item.icon ?? (isFolder ? (expanded ? FolderOpen : Folder) : File)

  const handleClick = () => {
    if (isFolder) {
      setExpanded((prev) => !prev)
    } else {
      onFileClick?.(item.name, fullPath)
    }
  }

  return (
    <li>
      <button
        type="button"
        onClick={handleClick}
        className={cn(
          "flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
          item.highlight && "bg-accent/50 font-medium",
        )}
        style={{ paddingLeft: `${depth * 16 + 8}px` }}
      >
        {isFolder && (
          <motion.span
            animate={{ rotate: expanded ? 90 : 0 }}
            transition={{ duration: 0.15 }}
            className="flex shrink-0"
          >
            <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
          </motion.span>
        )}
        {!isFolder && <span className="w-3.5 shrink-0" />}
        <Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
        <span className="truncate">{item.name}</span>
      </button>

      <AnimatePresence initial={false}>
        {isFolder && expanded && item.children && (
          <motion.ul
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.2 }}
            className="overflow-hidden"
          >
            {item.children.map((child) => (
              <FileTreeNode
                key={child.name}
                item={child}
                depth={depth + 1}
                path={fullPath}
                onFileClick={onFileClick}
                defaultExpanded={defaultExpanded}
              />
            ))}
          </motion.ul>
        )}
      </AnimatePresence>
    </li>
  )
}

export function FileTree({
  items,
  onFileClick,
  className,
  defaultExpanded = false,
}: FileTreeProps) {
  return (
    <div
      role="tree"
      className={cn(
        "rounded-lg border bg-card p-2 text-card-foreground",
        className,
      )}
    >
      {items.map((item) => (
        <FileTreeNode
          key={item.name}
          item={item}
          depth={0}
          path=""
          onFileClick={onFileClick}
          defaultExpanded={defaultExpanded}
        />
      ))}
    </div>
  )
}