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
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Error State with Retry
Notion
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Usage / Quota Meter
Vercel
Skeleton Loading Grid
Linear
Activity Heatmap
Notion
Sparkline Trend Cards
Linear
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Bottom Sheet
Stripe
Toast / Snackbar Stack
Vercel
Notification Center
Linear
Issue / Task Card
Linear
Subtask Checklist
Linear
Kanban Board Column
Linear
Activity Feed
Linear + Slack
Comment Thread
Linear
Audit Log Timeline
Vercel
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Tag Multi-Select
Linear
Toggle Switch Group
Stripe
File Upload Dropzone
Vercel
Inline Data Table
Notion + Linear
Pagination
Vercel
Sortable / Selectable Table
Linear
Row Detail Drawer
Stripe
Editable Cell
Notion
Status Badge System
Linear
Segmented Control
Notion