← Back to all posts
Tutorial

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

Karan Kashyap

Karan Kashyap

July 16, 2026

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

By the end of Part 2 the sidebar calls onOpenFile when you click a file, but nothing listens yet. Today: the editor pane that does — tabs across the top, syntax-highlighted content in the middle, a status bar along the bottom.

Picking a highlighter

prism-react-renderer wraps PrismJS in a render-prop component that hands you already-tokenized lines and lets you control every bit of the markup — no injected <pre> block you have to fight with CSS, no client-side dangerouslySetInnerHTML. That control matters later in this series (Part 4 leans on it directly), which is the reason to reach for it over a heavier all-in-one code block library when all you need is tokens in, JSX out.

Mapping our Language to Prism's language ids

Our Language union from Part 1 (markdown, typescript, go, …) mostly lines up with Prism's grammar names, but not perfectly — Prism doesn't have a "text" grammar, for instance. A small lookup table keeps the two vocabularies decoupled, so a future rename of our internal Language values doesn't have to match Prism's naming:

components/vscode-portfolio/Editor.tsx
typescript
1// components/vscode-portfolio/Editor.tsx (excerpt)
2
3const PRISM_LANGUAGE: Record<Language, string> = {
4 markdown: 'markdown',
5 json: 'json',
6 javascript: 'javascript',
7 typescript: 'typescript',
8 jsx: 'jsx',
9 tsx: 'tsx',
10 python: 'python',
11 go: 'go',
12 yaml: 'yaml',
13 bash: 'bash',
14 sql: 'sql',
15 graphql: 'graphql',
16 text: 'text',
17};

Same trick as FileIcon's ICONS table in Part 2: because it's typed as Record<Language, string>, adding a language to lib/data.ts without updating this table is a compile error, not a silent fallback to plain text in production.

Tabs and the open-files list

Tabs render from whatever openFiles the parent (VSCodeWorkspace, built in Part 4) hands down — the editor doesn't own that state, it just renders it and reports clicks back up:

components/vscode-portfolio/Editor.tsx
typescript
1// components/vscode-portfolio/Editor.tsx (excerpt)
2
3<div className="flex overflow-x-auto bg-[#252526] scrollbar-hide flex-shrink-0">
4 {openFiles.map((file) => {
5 const isActive = activeFileId === file.id;
6 return (
7 <div
8 key={file.id}
9 onClick={() => onSelectFile(file.id)}
10 className={`flex items-center h-[35px] px-3 min-w-[120px] max-w-[200px] cursor-pointer border-r border-[#1e1e1e] group ${
11 isActive
12 ? 'bg-[#1e1e1e] border-t border-t-[#007acc] text-white'
13 : 'bg-[#2d2d2d] border-t border-t-transparent text-[#969696] hover:bg-[#2b2b2c]'
14 }`}
15 >
16 <span className="mr-2 opacity-80">
17 <FileIcon language={file.language} />
18 </span>
19 <span className="truncate flex-1 text-[13px]">{file.name}</span>
20 <span
21 className="ml-2 p-[2px] rounded-md hover:bg-[#444444] opacity-0 group-hover:opacity-100"
22 onClick={(e) => onCloseFile(file.id, e)}
23 >
24 <X size={14} />
25 </span>
26 </div>
27 );
28 })}
29</div>

scrollbar-hide isn't a Tailwind v4 built-in — it's a small custom utility (below) so a wide row of tabs scrolls horizontally without an ugly scrollbar competing with VSCode's own chrome:

css
css
1/* app/globals.css */
2@utility scrollbar-hide {
3 -ms-overflow-style: none;
4 scrollbar-width: none;
5
6 &::-webkit-scrollbar {
7 display: none;
8 }
9}

The highlighted content pane

Highlight from prism-react-renderer takes code and language and gives you back tokens grouped by line, plus prop-getters for the container, each line, and each token. Line numbers are generated separately from tokens.length — a sticky column, not part of the highlighted text, so copy-pasting code out of the editor never drags line numbers along with it:

components/vscode-portfolio/Editor.tsx
typescript
1// components/vscode-portfolio/Editor.tsx (excerpt)
2
3<Highlight theme={themes.vsDark} code={activeFile.content ?? ''} language={language}>
4 {({ className, style, tokens, getLineProps, getTokenProps }) => (
5 <div className={`flex min-h-full min-w-full ${className}`} style={style}>
6 <div className="w-[50px] flex-shrink-0 text-right pr-4 py-4 text-[#858585] text-[13px] select-none font-mono border-r border-[#404040]">
7 {tokens.map((_, i) => (
8 <div key={i} className="leading-6">{i + 1}</div>
9 ))}
10 </div>
11 <div className="flex-1 p-4 text-[14px] leading-6 font-mono whitespace-pre-wrap break-words">
12 {tokens.map((line, lineIndex) => {
13 const { key: lineKey, ...lineProps } = getLineProps({ line, key: lineIndex });
14 return (
15 <div key={lineKey as React.Key} {...lineProps}>
16 {line.map((token, tokenIndex) => {
17 const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex });
18 return <span key={tokenKey as React.Key} {...tokenProps} />;
19 })}
20 </div>
21 );
22 })}
23 </div>
24 </div>
25 )}
26</Highlight>

This is enough to render syntax-highlighted .ts, .go, .py, .json, whatever — open the dev server, click a few files in the sidebar, and everything except markdown should already look right.

Where markdown breaks

Open your README.md node and you'll see it: any [link text](url) renders as literal bracket-and-paren text instead of a clickable link, and # Headings render as plain text with no weight. Prism's markdown grammar tokenizes a heading as one opaque title token and a link as several small url/content leaves — neither maps cleanly onto "here's a <a> tag" the way our JSON or TypeScript tokens map onto "here's a colored <span>."

That's deliberately where this part stops. It's a real enough bug that it deserves its own part rather than a rushed fix bolted onto the end of this one — and it's the part of this build most tutorials skip, because it only shows up once you throw real markdown content at a generic syntax highlighter instead of a toy example.

Next up: why markdown links break under Prism, the fix, and wiring Sidebar + Editor together into a finished, deployable portfolio.

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.