Skip to content
Components

Tree View

Tree View for a useful, keyboard-friendly product interface.

Open ↗

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.json

Use 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 →