← Back to all posts
Tutorial

Let's Build a VSCode-Style Interactive Portfolio from scratch in Next.js & TypeScript [Part 2]

Karan Kashyap

Karan Kashyap

July 16, 2026

Let's Build a VSCode-Style Interactive Portfolio from scratch in Next.js & TypeScript [Part 2]

Part 1 got us a typed fileTree and a scaffolded Next.js project. Nothing renders yet. The sidebar — VSCode's activity bar plus a file explorer that turns fileTree into something a visitor can actually click through.

The icon problem

Real VSCode ships hundreds of file-type icons as image assets. Copying that pattern into a portfolio means either shipping a folder of SVGs (and picking one for every language you might ever add) or reaching for an icon font. Both are more setup than the payoff justifies for a handful of file types.

lucide-react is already a dependency of most Next.js starters, ships as tree-shakeable React components, and covers file-type-adjacent icons (FileCode2, FileJson2, Terminal, Database) well enough that a single lookup table does the job:

components/vscode-portfolio/FileIcon.tsx
typescript
1// components/vscode-portfolio/FileIcon.tsx
2
3import {
4 FileCode2,
5 FileJson2,
6 FileText,
7 FileType2,
8 Terminal,
9 Database,
10 Braces,
11 File as FileIconFallback,
12 type LucideIcon,
13} from 'lucide-react';
14import type { Language } from '@/lib/data';
15
16const ICONS: Record<Language, { icon: LucideIcon; color: string }> = {
17 markdown: { icon: FileText, color: '#519aba' },
18 json: { icon: FileJson2, color: '#cbcb41' },
19 javascript: { icon: Braces, color: '#f0db4f' },
20 typescript: { icon: FileCode2, color: '#3178c6' },
21 jsx: { icon: FileCode2, color: '#61dafb' },
22 tsx: { icon: FileCode2, color: '#61dafb' },
23 python: { icon: FileCode2, color: '#3776ab' },
24 go: { icon: FileCode2, color: '#00add8' },
25 yaml: { icon: FileType2, color: '#cb171e' },
26 bash: { icon: Terminal, color: '#89e051' },
27 sql: { icon: Database, color: '#e38c00' },
28 graphql: { icon: Braces, color: '#e10098' },
29 text: { icon: FileIconFallback, color: '#cccccc' },
30};
31
32export default function FileIcon({ language, size = 14 }: { language?: Language; size?: number }) {
33 const entry = language ? ICONS[language] : undefined;
34 const Icon = entry?.icon ?? FileIconFallback;
35 return <Icon size={size} color={entry?.color ?? '#cccccc'} strokeWidth={2} />;
36}

Record<Language, ...> is doing real work here, not just documenting intent — because ICONS is typed against the exact same Language union from lib/data.ts, TypeScript refuses to compile if you add a language to the data model and forget to give it an icon. That's a five-second error at build time instead of a silent blank icon in production.

The sidebar: activity bar + explorer

The sidebar is two fixed-width panels sitting side by side: a 50px activity bar (icons only — home, GitHub, LinkedIn) and a 250px file explorer. The explorer keeps exactly one top-level directory open at a time — opening projects/ closes skills/ if it was open — which is a one-line piece of state, openDirId, rather than a set of open IDs:

components/vscode-portfolio/Sidebar.tsx
typescript
1// components/vscode-portfolio/Sidebar.tsx
2'use client';
3
4import { useState } from 'react';
5import Link from 'next/link';
6import { ChevronRight, ChevronDown, Files, Github, Linkedin, Home } from 'lucide-react';
7import type { FileNode } from '@/lib/data';
8import { siteConfig } from '@/lib/config';
9import FileIcon from './FileIcon';
10
11type SidebarProps = {
12 fileTree: FileNode[];
13 onOpenFile: (file: FileNode) => void;
14 activeFileId: string | null;
15};
16
17export default function Sidebar({ fileTree, onOpenFile, activeFileId }: SidebarProps) {
18 const [openDirId, setOpenDirId] = useState<string | null>(null);
19 const toggleDir = (id: string) => setOpenDirId((prev) => (prev === id ? null : id));
20
21 return (
22 <div className="flex h-full border-r border-[#252526] select-none">
23 {/* Activity bar */}
24 <div className="w-[50px] bg-[#333333] flex flex-col justify-between py-2 items-center">
25 <div className="flex flex-col gap-4">
26 <div className="p-3 border-l-2 border-white">
27 <Files size={22} className="text-white" />
28 </div>
29 <Link href={siteConfig.homeHref} className="p-3 border-l-2 border-transparent opacity-40 hover:opacity-100">
30 <Home size={22} className="text-white" />
31 </Link>
32 <a href={siteConfig.githubHref} target="_blank" rel="noopener noreferrer" className="p-3 border-l-2 border-transparent opacity-40 hover:opacity-100">
33 <Github size={22} className="text-white" />
34 </a>
35 <a href={siteConfig.linkedinHref} target="_blank" rel="noopener noreferrer" className="p-3 border-l-2 border-transparent opacity-40 hover:opacity-100">
36 <Linkedin size={22} className="text-white" />
37 </a>
38 </div>
39 </div>
40
41 {/* Explorer */}
42 <div className="w-[250px] bg-[#252526] flex flex-col">
43 <div className="px-4 py-2 text-xs font-semibold tracking-wider uppercase text-[#cccccc]">
44 {siteConfig.name}
45 </div>
46 <div className="px-4 py-1 text-xs font-bold uppercase tracking-wider bg-[#37373d] flex items-center">
47 <ChevronDown size={14} className="mr-1" />
48 {siteConfig.rootLabel}
49 </div>
50 <div className="flex-1 overflow-y-auto overflow-x-hidden pt-2 pb-4">
51 {fileTree.map((node) => (
52 <TreeItem
53 key={node.id}
54 node={node}
55 depth={1}
56 onOpenFile={onOpenFile}
57 activeFileId={activeFileId}
58 openDirId={openDirId}
59 onToggleDir={toggleDir}
60 />
61 ))}
62 </div>
63 </div>
64 </div>
65 );
66}

siteConfig (your name and social links) lives in its own file rather than hardcoded in the sidebar for the same reason hidden is a data flag and not a deletion — it's the one place you edit to reskin the whole activity bar:

lib/config.ts
typescript
1// lib/config.ts
2
3export const siteConfig = {
4 name: 'Jane Doe',
5 rootLabel: 'my-portfolio',
6 homeHref: '/',
7 githubHref: 'https://github.com/example',
8 linkedinHref: 'https://linkedin.com/in/example',
9};

TreeItem: recursion, not depth-tracking state

The file tree is arbitrarily nested — a directory can contain directories. Rather than flattening fileTree into a list with indent levels computed ahead of time, TreeItem renders itself recursively, passing depth + 1 to its own children:

components/vscode-portfolio/Sidebar.tsx
typescript
1// components/vscode-portfolio/Sidebar.tsx (continued)
2
3function TreeItem({
4 node,
5 depth,
6 onOpenFile,
7 activeFileId,
8 openDirId,
9 onToggleDir,
10}: {
11 node: FileNode;
12 depth: number;
13 onOpenFile: (file: FileNode) => void;
14 activeFileId: string | null;
15 openDirId: string | null;
16 onToggleDir: (id: string) => void;
17}) {
18 const isDir = node.type === 'directory';
19 const isOpen = isDir && node.id === openDirId;
20 const isActive = activeFileId === node.id;
21
22 return (
23 <div>
24 <div
25 className={`flex items-center px-2 py-[2px] cursor-pointer text-[13px] hover:bg-[#2a2d2e] ${
26 isActive ? 'bg-[#37373d] text-white' : 'text-[#cccccc]'
27 }`}
28 style={{ paddingLeft: `${depth * 12}px` }}
29 onClick={() => (isDir ? onToggleDir(node.id) : onOpenFile(node))}
30 >
31 <span className="w-4 flex justify-center mr-1">
32 {isDir ? (
33 isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />
34 ) : (
35 <FileIcon language={node.language} />
36 )}
37 </span>
38 <span className="truncate">{node.name}</span>
39 </div>
40
41 {isDir && isOpen && node.children && (
42 <div>
43 {node.children.map((child) => (
44 <TreeItem
45 key={child.id}
46 node={child}
47 depth={depth + 1}
48 onOpenFile={onOpenFile}
49 activeFileId={activeFileId}
50 openDirId={openDirId}
51 onToggleDir={onToggleDir}
52 />
53 ))}
54 </div>
55 )}
56 </div>
57 );
58}

The paddingLeft: depth * 12 is the entire indentation system — no nested <ul> markup, no CSS :has() tricks. A three-level-deep file sits at 36px of padding because it's three recursive calls deep, and that's the only place depth is tracked.

At this point you have a fully clickable file tree — clicking a directory toggles it open, clicking a file calls onOpenFile, which doesn't do anything yet because there's no editor to open it in. That's Part 3.

Next up: the editor pane — tabs, a Prism-highlighted code view, and a status bar, wired to the sidebar's onOpenFile callback.

Blog series · 4 parts

Let's Build a VSCode-Style Interactive Portfolio from scratch in Next.js & TypeScript

with Next.js & Typescript

View on GitHub
Next.jsTypeScriptVercel

Ready to Build Something Extraordinary?

Let's discuss your idea. We'll show you how AI-powered development can compress your timeline and budget — without cutting corners.

We respond within 24 hours. No sales pitch — just a straight conversation about your project.