Tree View
Tree View for a useful, keyboard-friendly product interface.
Make it yours.
Install the editable source and its dependencies into your React + Tailwind project.
Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/tree-view.jsonUse it
Scroll horizontally for long lines
"use client";
import {useState} from 'react';
import {TreeView} from '@/components/jez-ui/ui/tree-view';
export default function Example(){
const [notice,setNotice]=useState('');
return <><div className="w-full max-w-sm"><div className="mb-7"><h3 className="text-2xl tracking-tight">Everything in its place.</h3><p className="mt-2 text-sm leading-relaxed text-muted-foreground">A clear path through your source.</p></div><TreeView nodes={[{id:'src',label:'Source',children:[{id:'components',label:'Components',children:[{id:'button',label:'button.tsx'},{id:'card',label:'card.tsx'}]},{id:'theme',label:'theme.css'}]}]} onSelect={n=>setNotice(n.label+' selected')}/></div><p role="status">{notice}</p></>;
}Props & types
Scroll horizontally for long lines
export type TreeNode = { id: string; label: string; children?: TreeNode[] };
{
nodes: TreeNode[];
onSelect?: (node: TreeNode) => void;
className?: string;
}Source
View 2 source files
registry/ui/tree-view.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { ChevronRight, Folder, FolderOpen, FileCode2 } from "lucide-react";
import { cn } from "./utils";
export type TreeNode = { id: string; label: string; children?: TreeNode[] };
export function TreeView({
nodes,
onSelect,
className,
}: {
nodes: TreeNode[];
onSelect?: (node: TreeNode) => void;
className?: string;
}) {
const [closed, setClosed] = React.useState<Set<string>>(new Set());
const [selected, setSelected] = React.useState<string>();
const [focused, setFocused] = React.useState(nodes[0]?.id);
const root = React.useRef<HTMLUListElement>(null);
const visible: { node: TreeNode; depth: number; parent?: string }[] = [];
function flatten(list: TreeNode[], depth = 0, parent?: string) {
for (const node of list) {
visible.push({ node, depth, parent });
if (node.children && !closed.has(node.id))
flatten(node.children, depth + 1, node.id);
}
}
flatten(nodes);
function toggle(id: string) {
setClosed((v) => {
const n = new Set(v);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
}
function focus(id?: string) {
if (!id) return;
setFocused(id);
root.current
?.querySelectorAll<HTMLLIElement>('[role="treeitem"]')
.forEach((el) => {
if (el.dataset.id === id) el.focus();
});
}
return (
<ul
ref={root}
role="tree"
aria-label="Files"
className={cn("grid w-full gap-0.5 text-sm", className)}
>
{visible.map(({ node, depth, parent }, index) => (
<li
key={node.id}
data-id={node.id}
role="treeitem"
aria-level={depth + 1}
aria-expanded={node.children ? !closed.has(node.id) : undefined}
aria-selected={selected === node.id}
tabIndex={focused === node.id ? 0 : -1}
onFocus={() => setFocused(node.id)}
onClick={() => {
setSelected(node.id);
if (node.children) toggle(node.id);
else onSelect?.(node);
}}
onKeyDown={(e) => {
if (
[
"ArrowDown",
"ArrowUp",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"Enter",
" ",
].includes(e.key)
)
e.preventDefault();
if (e.key === "ArrowDown") focus(visible[index + 1]?.node.id);
if (e.key === "ArrowUp") focus(visible[index - 1]?.node.id);
if (e.key === "Home") focus(visible[0]?.node.id);
if (e.key === "End") focus(visible.at(-1)?.node.id);
if (e.key === "ArrowRight" && node.children) {
if (closed.has(node.id)) toggle(node.id);
else focus(node.children[0]?.id);
}
if (e.key === "ArrowLeft") {
if (node.children && !closed.has(node.id)) toggle(node.id);
else focus(parent);
}
if (e.key === "Enter" || e.key === " ") {
setSelected(node.id);
if (node.children) toggle(node.id);
else onSelect?.(node);
}
}}
style={{ paddingLeft: 12 + depth * 20 }}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md py-2 pr-3 transition-colors hover:bg-muted",
selected === node.id && "bg-primary/8 text-primary",
)}
>
<ChevronRight
size={13}
className={cn(
"shrink-0 transition-transform",
!node.children && "invisible",
node.children && !closed.has(node.id) && "rotate-90",
)}
/>
{node.children ? (
closed.has(node.id) ? (
<Folder size={16} />
) : (
<FolderOpen size={16} />
)
) : (
<FileCode2 size={16} className="text-muted-foreground" />
)}
<span className="truncate">{node.label}</span>
{node.children && (
<span className="ml-auto text-xs text-muted-foreground">
{node.children.length}
</span>
)}
</li>
))}
</ul>
);
}
registry/ui/utils.ts
Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
return twMerge(clsx(values));
}
Dependencies
lucide-react@0.577.0 · clsx@2.1.1 · tailwind-merge@3.5.0
Accessibility & behaviour
Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.
Read the accessibility and performance guide →