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.
Click ▸ to expand folders · Active page is highlighted · Hover a row for the + affordance
// 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
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Deployment Status Card
Vercel
Empty State
Vercel
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Properties Panel
Notion + Linear
Inline Data Table
Notion + Linear