Mischief

30 / Documents

File Tree

An expandable tree of folders and files with full keyboard navigation and correct tree semantics.

contracts
2026
acme-msa.pdf1.2 MB
northwind-sow.pdf840 KB
readme.md2 KB

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/file-tree
import { FileTree } from "mischief-ui/file-tree"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/file-tree/file-tree.tsx
"use client" import * as React from "react"import { ChevronRight, File, Folder, FolderOpen } from "lucide-react"import { cn } from "@/lib/utils" export type FileTreeNode = {  id: string  name: string  kind?: "file" | "folder"  children?: FileTreeNode[]  meta?: React.ReactNode  icon?: React.ReactNode}

Usage

const nodes = [
  {
    id: "invoices",
    name: "invoices",
    kind: "folder",
    children: [{ id: "jan", name: "january.pdf" }],
  },
]

export function Files() {
  return <FileTree nodes={nodes} onSelect={openFile} />
}

Building the tree

Nodes nest through children. A node is treated as a folder when it has a children array -- including an empty one -- so an empty folder is spelled children: [] rather than left out. Set kind explicitly when you want a folder that has not loaded its contents yet to still look like a folder.

const nodes = [
  {
    id: "app",
    name: "app",
    children: [
      { id: "app/page.tsx", name: "page.tsx", meta: "2.4 kB" },
      { id: "app/api", name: "api", children: [] },
    ],
  },
  { id: "README.md", name: "README.md" },
]

Ids must be unique across the whole tree, not just among siblings, because expansion and selection are tracked by id. Paths make good ids for that reason.

Loading children on demand

The component renders the nodes it is given and does not fetch anything. To fill a folder when it opens, control expansion and replace that node's children as the answer arrives.

const [expandedIds, setExpandedIds] = useState<string[]>([])

async function onExpandedChange(ids: string[]) {
  const opened = ids.find((id) => !expandedIds.includes(id))
  setExpandedIds(ids)

  if (opened && !loaded.has(opened)) {
    setNodes(await withChildren(opened, await listDirectory(opened)))
  }
}
Give the folder a spinner in meta while its request is in flight.

API

nodesFileTreeNode[]Id, name, kind, optional children, meta, and icon.
expandedIds, defaultExpandedIdsstring[]Which folders are open, controlled or uncontrolled.
selectedId, defaultSelectedIdstring | nullThe selected node, controlled or uncontrolled.
onSelect(node: FileTreeNode) => voidRuns on selection.
onExpandedChange(ids: string[]) => voidRuns when a folder opens or closes.

FileTreeNode

idstringUnique across the whole tree. Paths work well.
namestringThe label.
kind"file" | "folder"Overrides the guess made from children.
childrenFileTreeNode[]Present, even empty, means a folder.
metaReactNodeTrailing detail such as a size or a status.
iconReactNodeReplaces the default file or folder mark.

Accessibility

The tree uses tree and treeitem roles with aria-level and aria-expanded on every row, so depth and state are announced rather than implied by indentation. There is one tab stop into the tree. Up and Down move between visible rows, Right opens a folder or steps into it, Left closes it or moves to its parent, Home and End jump to the ends, and Enter or Space selects.