Collapsible Nested Tree Nav

A recursive folder tree with expand/collapse chevrons, nested indentation, active highlight, and per-row hover affordance. Folders toggle on click; pages select on click. Inspired by Notion's sidebar.

Navigation & SidebarsInspired by Notion
Live PreviewInteractive
Workspace · Pages
Workspace

Click to expand folders · Active page is highlighted · Hover a row for the + affordance

CollapsibleTreeNav.tsx
// Dependencies: react ^18, lucide-react
import React, { useState } from 'react';
import { ChevronRight, FileText, Folder, FolderOpen, Plus } from 'lucide-react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

type TreeNode = {
  id: string;
  label: string;
  type: 'page' | 'folder';
  children?: TreeNode[];
};

const TREE: TreeNode[] = [
  {
    id: 'n-work',
    label: 'Work',
    type: 'folder',
    children: [
      {
        id: 'n-eng',
        label: 'Engineering',
        type: 'folder',
        children: [
          { id: 'n-arch', label: 'Architecture notes', type: 'page' },
          { id: 'n-onb', label: 'Onboarding', type: 'page' },
          {
            id: 'n-rfcs',
            label: 'RFCs',
            type: 'folder',
            children: [
              { id: 'n-rfc-1', label: 'RFC-001 — Search rewrite', type: 'page' },
              { id: 'n-rfc-2', label: 'RFC-002 — Permissions v2', type: 'page' },
            ],
          },
        ],
      },
      {
        id: 'n-prod',
        label: 'Product',
        type: 'folder',
        children: [
          { id: 'n-roadmap', label: 'Roadmap', type: 'page' },
          { id: 'n-okrs', label: 'Q3 OKRs', type: 'page' },
        ],
      },
      { id: 'n-meeting', label: 'Meeting notes', type: 'page' },
    ],
  },
  {
    id: 'n-personal',
    label: 'Personal',
    type: 'folder',
    children: [
      { id: 'n-journal', label: 'Journal', type: 'page' },
      { id: 'n-reading', label: 'Reading list', type: 'page' },
    ],
  },
  { id: 'n-inbox', label: 'Inbox', type: 'page' },
];

const DEFAULT_EXPANDED = new Set<string>(['n-work', 'n-eng']);

export function CollapsibleTreeNav() {
  const [expanded, setExpanded] = useState<Set<string>>(DEFAULT_EXPANDED);
  const [activeId, setActiveId] = useState<string>('n-arch');

  const toggle = (id: string) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  return (
    <div className="w-full max-w-[280px] h-full bg-[#191919] border border-white/[0.06] rounded-xl overflow-hidden flex flex-col">
      <div className="flex items-center justify-between px-3 py-2.5 border-b border-white/[0.06]">
        <span className="text-xs font-semibold text-zinc-300">Workspace</span>
        <button className="w-5 h-5 rounded flex items-center justify-center text-zinc-500 hover:text-white hover:bg-white/[0.06] transition-colors">
          <Plus className="w-3 h-3" />
        </button>
      </div>

      <div className="flex-1 overflow-y-auto py-1.5">
        {TREE.map((node) => (
          <TreeNodeRow
            key={node.id}
            node={node}
            level={0}
            expanded={expanded}
            activeId={activeId}
            onToggle={toggle}
            onSelect={setActiveId}
          />
        ))}
      </div>
    </div>
  );
}

type RowProps = {
  node: TreeNode;
  level: number;
  expanded: Set<string>;
  activeId: string;
  onToggle: (id: string) => void;
  onSelect: (id: string) => void;
};

function TreeNodeRow({ node, level, expanded, activeId, onToggle, onSelect }: RowProps) {
  const isFolder = node.type === 'folder';
  const isOpen = expanded.has(node.id);
  const isActive = activeId === node.id;

  const handleClick = () => {
    onSelect(node.id);
    if (isFolder) onToggle(node.id);
  };

  const Icon = isFolder ? (isOpen ? FolderOpen : Folder) : FileText;

  return (
    <>
      <button
        onClick={handleClick}
        className={cn(
          'group w-full flex items-center gap-1 pr-2 py-1 text-left transition-colors',
          isActive
            ? 'bg-white/[0.07] text-white'
            : 'text-zinc-400 hover:bg-white/[0.04] hover:text-zinc-200'
        )}
        style={{ paddingLeft: `${8 + level * 14}px` }}
      >
        {isFolder ? (
          <ChevronRight
            className={cn(
              'w-3 h-3 text-zinc-500 transition-transform shrink-0',
              isOpen && 'rotate-90'
            )}
          />
        ) : (
          <span className="w-3 h-3 shrink-0" />
        )}
        <Icon
          className={cn(
            'w-3.5 h-3.5 shrink-0',
            isFolder ? 'text-amber-400/70' : 'text-zinc-500'
          )}
        />
        <span className="text-[13px] truncate flex-1">{node.label}</span>
        <span className="opacity-0 group-hover:opacity-100 transition-opacity">
          <Plus className="w-3 h-3 text-zinc-500 hover:text-white" />
        </span>
      </button>

      {isFolder && isOpen && node.children && (
        <div>
          {node.children.map((child) => (
            <TreeNodeRow
              key={child.id}
              node={child}
              level={level + 1}
              expanded={expanded}
              activeId={activeId}
              onToggle={onToggle}
              onSelect={onSelect}
            />
          ))}
        </div>
      )}
    </>
  );
}

Unlock to copy

Free access to all patterns