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

Karan Kashyap
July 16, 2026
![Let's Build a VSCode-Style Interactive Portfolio from scratch in Next.js & TypeScript [Part 2]](/_next/image/?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3e1sexdu%2Fproduction%2F229694f09ce3eb856898efb7a580df4b9c1a4cf9-1600x739.png%3Frect%3D246%2C0%2C1109%2C739%26w%3D1200%26h%3D800%26q%3D85%26fit%3Dcrop%26auto%3Dformat&w=3840&q=75)
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:
1// components/vscode-portfolio/FileIcon.tsx23import {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';1516const 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};3132export 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:
1// components/vscode-portfolio/Sidebar.tsx2'use client';34import { 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';1011type SidebarProps = {12 fileTree: FileNode[];13 onOpenFile: (file: FileNode) => void;14 activeFileId: string | null;15};1617export 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));2021 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>4041 {/* 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 <TreeItem53 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:
1// lib/config.ts23export 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:
1// components/vscode-portfolio/Sidebar.tsx (continued)23function 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;2122 return (23 <div>24 <div25 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>4041 {isDir && isOpen && node.children && (42 <div>43 {node.children.map((child) => (44 <TreeItem45 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.
![Let's Build a VSCode-Style Interactive Portfolio from scratch in Next.js & TypeScript [Part 3]](/_next/image/?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3e1sexdu%2Fproduction%2F229694f09ce3eb856898efb7a580df4b9c1a4cf9-1600x739.png%3Frect%3D61%2C0%2C1478%2C739%26w%3D800%26h%3D400%26q%3D85%26fit%3Dcrop%26auto%3Dformat&w=3840&q=75)