Navigation
File Tree.
An accessible tree of folders and files with indentation guides, an orange selected border and status letters like M for modified.
Explorer
- hub-site
- index.luaModified
- pages
- guestbook.lua
- shop.luaAdded
- about.md
- assets
- README.txt
import { FileTree, type FileTreeItem } from "@/components/ui/file-tree";
const site: FileTreeItem[] = [
{
id: "hub",
name: "hub-site",
children: [
{ id: "index", name: "index.lua", badge: "M", badgeTone: "warning", badgeLabel: "Modified" },
{
id: "pages",
name: "pages",
children: [
{ id: "guestbook", name: "guestbook.lua" },
{ id: "shop", name: "shop.lua", badge: "A", badgeTone: "success", badgeLabel: "Added" },
{ id: "about", name: "about.md" },
],
},
{
id: "assets",
name: "assets",
children: [
{ id: "logo", name: "logo.nfp" },
{ id: "theme", name: "theme.json", badge: "M", badgeTone: "warning", badgeLabel: "Modified" },
],
},
{ id: "readme", name: "README.txt" },
],
},
];
export default function FileTreeDemo() {
return (
<div className="w-full max-w-xs rounded-lg border bg-card">
<div className="border-b px-4 py-2.5 font-mono text-[10px] tracking-[0.2em] text-success uppercase">Explorer</div>
<FileTree items={site} defaultExpanded={["hub", "pages"]} defaultSelectedId="index" aria-label="Site files" />
</div>
);
}Installation#
With the CLI
$npx lanterncn add file-treeThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/file-tree. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install lucide-reactCopy the source into your project
components/ui/file-tree.tsx"use client"; import * as React from "react"; import { ChevronRightIcon, FileCodeIcon, FileIcon, FileImageIcon, FileJsonIcon, FileTextIcon, FolderIcon, FolderOpenIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; type FileTreeItem = { id: string; name: string; /** Present (even empty) for folders. */ children?: FileTreeItem[]; icon?: React.ReactNode; /** Short status letter such as "M", "A" or "D". */ badge?: string; badgeTone?: "warning" | "success" | "destructive" | "muted"; /** Accessible description of the badge, e.g. "Modified". */ badgeLabel?: string; disabled?: boolean; }; const badgeTones = { warning: "text-warning", success: "text-success", destructive: "text-destructive", muted: "text-muted-foreground", } as const; function fileIcon(name: string) { const ext = name.split(".").pop()?.toLowerCase(); if (["lua", "ts", "tsx", "js", "jsx", "py", "sh"].includes(ext ?? "")) return <FileCodeIcon />; if (ext === "json") return <FileJsonIcon />; if (["png", "jpg", "jpeg", "gif", "svg", "webp", "nfp"].includes(ext ?? "")) return <FileImageIcon />; if (["md", "txt", "log"].includes(ext ?? "")) return <FileTextIcon />; return <FileIcon />; } type Visible = { item: FileTreeItem; parent: string | null }; function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (v: T) => void) { const [inner, setInner] = React.useState(defaultValue); const controlled = value !== undefined; const current = controlled ? value : inner; const set = React.useCallback( (v: T) => { if (!controlled) setInner(v); onChange?.(v); }, [controlled, onChange], ); return [current, set] as const; } /** An accessible file tree with arrow-key navigation, indentation guides and status badges. */ function FileTree({ className, items, selectedId: selectedProp, defaultSelectedId = null, onSelect, expanded: expandedProp, defaultExpanded = [], onExpandedChange, "aria-label": ariaLabel = "Files", ...props }: Omit<React.ComponentProps<"ul">, "onSelect"> & { items: FileTreeItem[]; selectedId?: string | null; defaultSelectedId?: string | null; onSelect?: (item: FileTreeItem) => void; expanded?: string[]; defaultExpanded?: string[]; onExpandedChange?: (ids: string[]) => void; }) { const [selectedId, setSelectedId] = useControllable<string | null>(selectedProp, defaultSelectedId); const [expanded, setExpanded] = useControllable(expandedProp, defaultExpanded, onExpandedChange); const expandedSet = React.useMemo(() => new Set(expanded), [expanded]); const [focusedId, setFocusedId] = React.useState<string | null>(null); const refs = React.useRef(new Map<string, HTMLLIElement>()); const visible = React.useMemo(() => { const out: Visible[] = []; const walk = (list: FileTreeItem[], parent: string | null) => { for (const item of list) { out.push({ item, parent }); if (item.children && expandedSet.has(item.id)) walk(item.children, item.id); } }; walk(items, null); return out; }, [items, expandedSet]); // The tab stop: focused item if still visible, else selected, else first. const tabId = (focusedId && visible.some((v) => v.item.id === focusedId) && focusedId) || (selectedId && visible.some((v) => v.item.id === selectedId) && selectedId) || visible[0]?.item.id; function focus(id: string) { setFocusedId(id); refs.current.get(id)?.focus(); } function toggle(id: string, open?: boolean) { const isOpen = expandedSet.has(id); const next = open ?? !isOpen; if (next === isOpen) return; setExpanded(next ? [...expanded, id] : expanded.filter((e) => e !== id)); } function select(item: FileTreeItem) { if (item.disabled) return; setSelectedId(item.id); onSelect?.(item); } function onKeyDown(event: React.KeyboardEvent<HTMLUListElement>) { const index = visible.findIndex((v) => v.item.id === tabId); const current = visible[index]; if (!current) return; const { item, parent } = current; const isFolder = !!item.children; let handled = true; switch (event.key) { case "ArrowDown": if (index < visible.length - 1) focus(visible[index + 1].item.id); break; case "ArrowUp": if (index > 0) focus(visible[index - 1].item.id); break; case "Home": focus(visible[0].item.id); break; case "End": focus(visible[visible.length - 1].item.id); break; case "ArrowRight": if (isFolder && !expandedSet.has(item.id)) toggle(item.id, true); else if (isFolder && item.children!.length) focus(item.children![0].id); break; case "ArrowLeft": if (isFolder && expandedSet.has(item.id)) toggle(item.id, false); else if (parent) focus(parent); break; case "Enter": case " ": select(item); if (isFolder) toggle(item.id); break; case "*": { const siblings = parent ? visible.find((v) => v.item.id === parent)?.item.children ?? [] : items; const folders = siblings.filter((s) => s.children).map((s) => s.id); setExpanded([...new Set([...expanded, ...folders])]); break; } default: { handled = false; // Type-ahead: jump to the next visible item starting with the typed character. if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { const ch = event.key.toLowerCase(); const order = [...visible.slice(index + 1), ...visible.slice(0, index + 1)]; const hit = order.find((v) => v.item.name.toLowerCase().startsWith(ch)); if (hit) { focus(hit.item.id); handled = true; } } } } if (handled) { event.preventDefault(); event.stopPropagation(); } } function renderItems(list: FileTreeItem[], depth: number): React.ReactNode { return list.map((item, i) => { const isFolder = !!item.children; const isOpen = isFolder && expandedSet.has(item.id); const isSelected = selectedId === item.id; const indent = depth * 16; return ( <li key={item.id} ref={(el) => { if (el) refs.current.set(item.id, el); else refs.current.delete(item.id); }} role="treeitem" aria-level={depth + 1} aria-setsize={list.length} aria-posinset={i + 1} aria-expanded={isFolder ? isOpen : undefined} aria-selected={isSelected} aria-disabled={item.disabled || undefined} tabIndex={item.id === tabId ? 0 : -1} data-slot="file-tree-item" onFocus={(e) => { e.stopPropagation(); setFocusedId(item.id); }} className="outline-none [&:focus-visible>[data-slot=file-tree-row]]:ring-2 [&:focus-visible>[data-slot=file-tree-row]]:ring-ring/40 [&:focus-visible>[data-slot=file-tree-row]]:ring-inset" > <div data-slot="file-tree-row" data-selected={isSelected || undefined} onClick={(e) => { e.stopPropagation(); focus(item.id); select(item); if (isFolder) toggle(item.id); }} style={{ paddingLeft: indent + 6 }} className={cn( "flex h-8 cursor-pointer items-center gap-1.5 border-l-2 border-transparent pr-2 text-[13px] text-muted-foreground transition-colors select-none hover:bg-secondary/60 hover:text-foreground", "data-selected:border-primary data-selected:bg-primary/10 data-selected:text-foreground", item.disabled && "pointer-events-none opacity-45", )} > <span aria-hidden="true" className="flex size-4 shrink-0 items-center justify-center"> {isFolder && ( <ChevronRightIcon className={cn("size-3.5 transition-transform duration-150", isOpen && "rotate-90")} /> )} </span> <span aria-hidden="true" className={cn( "flex shrink-0 [&_svg]:size-4", isFolder ? "text-primary" : "text-[#b7ca9e]", isSelected && !isFolder && "text-primary", )} > {item.icon ?? (isFolder ? isOpen ? <FolderOpenIcon /> : <FolderIcon /> : fileIcon(item.name))} </span> <span className={cn("min-w-0 flex-1 truncate", !isFolder && "font-mono text-[12.5px]")}>{item.name}</span> {item.badge && ( <span className={cn("shrink-0 font-mono text-[10px] font-semibold tracking-wider", badgeTones[item.badgeTone ?? "warning"])} title={item.badgeLabel} > <span aria-hidden={item.badgeLabel ? true : undefined}>{item.badge}</span> {item.badgeLabel && <span className="sr-only">{item.badgeLabel}</span>} </span> )} </div> {isFolder && isOpen && item.children!.length > 0 && ( <ul role="group" className="relative before:absolute before:inset-y-0 before:left-(--guide) before:w-px before:bg-input/55" style={{ "--guide": `${indent + 15}px` } as React.CSSProperties} > {renderItems(item.children!, depth + 1)} </ul> )} </li> ); }); } return ( <ul role="tree" aria-label={ariaLabel} data-slot="file-tree" onKeyDown={onKeyDown} className={cn("py-1", className)} {...props} > {renderItems(items, 0)} </ul> ); } export { FileTree, type FileTreeItem };Update the import paths to match your project
The source imports
cnfrom@/lib/utils.
Usage#
import { FileTree } from "@/components/ui/file-tree";<FileTree
items={[{ id: "pages", name: "pages", children: [{ id: "index", name: "index.lua" }] }]}
onSelect={(item) => open(item)}
/>Follows the WAI-ARIA tree pattern: Up and Down move, Right expands or enters a folder, Left collapses or moves to the parent, Home and End jump, Enter or Space selects, * expands siblings and typing a letter jumps to the next matching name.
An item with a children array is a folder. Selection and expansion can be controlled or uncontrolled.
Examples#
Controlled with selection readout
- startup.lua
- programs
- miner.luaModified
- farm.lua
- old
- logs
Selectednothing yet1 open
"use client";
import * as React from "react";
import { FileTree, type FileTreeItem } from "@/components/ui/file-tree";
const disk: FileTreeItem[] = [
{ id: "startup.lua", name: "startup.lua" },
{
id: "programs",
name: "programs",
children: [
{ id: "programs/miner.lua", name: "miner.lua", badge: "M", badgeTone: "warning", badgeLabel: "Modified" },
{ id: "programs/farm.lua", name: "farm.lua" },
{ id: "programs/old", name: "old", children: [{ id: "programs/old/tunnel.lua", name: "tunnel.lua", badge: "D", badgeTone: "destructive", badgeLabel: "Deleted" }] },
],
},
{ id: "logs", name: "logs", children: [{ id: "logs/run.log", name: "run.log" }] },
];
function pathOf(id: string) {
return "/" + id;
}
export default function FileTreeSelection() {
const [selected, setSelected] = React.useState<FileTreeItem | null>(null);
const [expanded, setExpanded] = React.useState<string[]>(["programs"]);
return (
<div className="grid w-full max-w-xs gap-3">
<div className="rounded-lg border bg-card">
<FileTree
items={disk}
expanded={expanded}
onExpandedChange={setExpanded}
selectedId={selected?.id ?? null}
onSelect={setSelected}
aria-label="Turtle disk"
/>
</div>
<div aria-live="polite" className="grid gap-1 rounded-md border bg-background/40 px-3 py-2.5 font-mono text-[12px]">
<span className="text-[9px] tracking-[0.2em] text-success uppercase">Selected</span>
<span className="truncate">
{selected ? (
<>
{pathOf(selected.id)}
<span className="text-muted-foreground">{selected.children ? " folder" : " file"}</span>
</>
) : (
<span className="text-muted-foreground">nothing yet</span>
)}
</span>
<span className="text-muted-foreground">{expanded.length} open</span>
</div>
</div>
);
}