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

Karan Kashyap
July 16, 2026
![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%3D246%2C0%2C1109%2C739%26w%3D1200%26h%3D800%26q%3D85%26fit%3Dcrop%26auto%3Dformat&w=3840&q=75)
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:
1// components/vscode-portfolio/Editor.tsx (excerpt)23const 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:
1// components/vscode-portfolio/Editor.tsx (excerpt)23<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 <div8 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 isActive12 ? '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 <span21 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:
1/* app/globals.css */2@utility scrollbar-hide {3 -ms-overflow-style: none;4 scrollbar-width: none;56 &::-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:
1// components/vscode-portfolio/Editor.tsx (excerpt)23<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.
![Let's Build a VSCode-Style Interactive Portfolio from scratch in Next.js & TypeScript [Part 4]](/_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)