Componenteditors1.35 KB gzipZero dependencies
File Tree
Collapsible folder tree with active selection.
Preview
Live Preview
src
button.tsx
ui
README.md
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add file-treeOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzimport { FileTree } from "@/components/ui/file-tree";
<FileTree nodes={[{ id: "src", name: "src", kind: "folder" }]} />Usage guidance
Use File Tree when a ticket stub surface needs Collapsible folder tree with active selection. Keep props minimal and prefer composition with sibling stubs.
Avoid File Tree for long form flows or dense data grids where a dedicated layout block fits better.
Source code
file-tree.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
export interface FileTreeNode {
id: string;
name: string;
kind: "file" | "folder";
children?: FileTreeNode[];
}
export interface FileTreeProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
nodes: FileTreeNode[];
selectedId?: string;
defaultExpanded?: string[];
onSelect?: (node: FileTreeNode) => void;
}
export function FileTree({ nodes, selectedId: controlledSelected, defaultExpanded = [], onSelect, className, ...props }: FileTreeProps) {
const [innerSelected, setInnerSelected] = React.useState<string | null>(null);
const selected = controlledSelected ?? innerSelected;
const [expanded, setExpanded] = React.useState<Set<string>>(new Set(defaultExpanded));
const toggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const renderNode = (node: FileTreeNode, depth: number): React.ReactNode => {
const isOpen = expanded.has(node.id);
const isActive = selected === node.id;
return (
<div key={node.id}>
<div
role="treeitem"
aria-expanded={node.kind === "folder" ? isOpen : undefined}
aria-selected={isActive}
tabIndex={0}
onClick={() => {
if (node.kind === "folder") toggle(node.id);
setInnerSelected(node.id);
onSelect?.(node);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (node.kind === "folder") toggle(node.id);
setInnerSelected(node.id);
onSelect?.(node);
}
}}
style={{ paddingLeft: `${depth * 14 + 8}px` }}
className={cn(
"flex cursor-pointer items-center gap-1.5 rounded px-1 py-1 font-mono text-[13px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive ? "bg-accent font-bold text-accent-foreground" : "hover:bg-secondary",
)}
>
<span aria-hidden className="w-3 text-[10px] text-muted-foreground">
{node.kind === "folder" ? (isOpen ? "▾" : "▸") : "·"}
</span>
<span aria-hidden="true" className="text-muted-foreground">
{node.kind === "folder" ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4"><path d="M3.5 7a1.5 1.5 0 0 1 1.5-1.5h4.6l2 2.6h7.4a1.5 1.5 0 0 1 1.5 1.5V18a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 18z" /></svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4"><path d="M6.5 3.5H13l4.5 4.5v11a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-14.5a1 1 0 0 1 1-1z" /><path d="M13 3.5V8H17.5M9 12.5h6M9 15.8h6" /></svg>
)}
</span>
<span className="truncate">{node.name}</span>
</div>
{node.kind === "folder" && isOpen ? (
<div role="group">{(node.children ?? []).map((kid) => renderNode(kid, depth + 1))}</div>
) : null}
</div>
);
};
return (
<div role="tree" aria-label="File tree" className={cn("w-full rounded-lg border border-border bg-card p-2", className)} {...props}>
{nodes.map((node) => renderNode(node, 0))}
</div>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| nodes | FileTreeNode[] | Tree nodes (required). |
| selectedId | string | Controlled selection. |
| defaultExpanded | string[] | Initially open folders. |
| onSelect | (node: FileTreeNode) => void | Select callback. |