ehsan.blog
~/blog/building-a-crt-terminal-portfolio — zsh
cat building-a-crt-terminal-portfolio.md

Building a CRT-terminal portfolio with React 19 and vite-react-ssg

·5 min read

My portfolio doesn’t scroll the way websites usually scroll. It’s a full-screen horizontal slideshow — five panels you page through sideways with the wheel, arrow keys, or a swipe — wrapped in a terminal/CRT aesthetic with scanlines, a boot sequence, and a glitching name reveal. It also had to be fully crawlable and pass a static SEO audit. Those two goals fight each other more than you’d think, and reconciling them drove almost every technical decision. Here’s how it actually works.

Why prerendering, and why vite-react-ssg

A portfolio’s entire job is to be found. That means the content has to exist in the HTML that arrives before any JavaScript runs — not painted in by React on the client after the fact. A single-page React app served as an empty <div id="root"> is invisible to anything that doesn’t execute JS well, and it’s a bad first-paint experience on slow connections.

So the site is statically prerendered with vite-react-ssg in single-page mode. At build time it renders the entire React tree once in Node, bakes the result into dist/index.html, and then hydrates on the client. The entry point looks nothing like a normal React app — there’s no createRoot().render():

// src/main.tsx
import { ViteReactSSG } from "vite-react-ssg/single-page"
import App from "./App.tsx"

export const createRoot = ViteReactSSG(
  <ErrorBoundary>
    <App />
  </ErrorBoundary>,
)

The moment you adopt build-time rendering, a rule falls out of the sky: no browser globals at module load or during render. window, document, localStorage — none of them exist in Node. Touching any of them while the tree renders crashes the build. They’re only safe inside effects and event handlers, which run on the client after hydration. This is why dark mode is set with a hard-coded class="dark" in index.html instead of a localStorage-driven theme script: the class has to be correct in the static HTML, before a single line of my JS runs, or you get a flash of the wrong theme.

The horizontal scroll is not React state

Here’s the part people find surprising. The slideshow does not work by storing the current panel in state and letting React re-render a transform. It mutates the DOM directly:

const goTo = useCallback((index: number) => {
  const clamped = Math.max(0, Math.min(totalSections - 1, index))
  setCurrent(clamped) // only drives nav dots + progress bar
  if (trackRef.current) {
    trackRef.current.style.transform = `translateX(-${clamped * 100}vw)`
    trackRef.current.style.transition =
      "transform 0.8s cubic-bezier(0.77, 0, 0.175, 1)"
  }
}, [totalSections])

The current state exists only so the navigation dots and the progress bar can react to it. The actual movement — the thing your eye tracks — is a direct style.transform write on a ref. Why not let React own it? Because this is a high-frequency, animation-critical interaction. Routing every wheel tick through a state update and reconciliation adds latency and risks React batching or interrupting a transform mid-flight. Writing the transform imperatively makes the motion feel instant and lets the CSS transition own the easing. React is great at describing UI; it is not the right tool for driving a 60fps transform off raw wheel deltas.

A 900ms lock debounces the wheel so one gesture advances exactly one panel:

if (isScrolling.current) return
isScrolling.current = true
// ...advance one panel...
setTimeout(() => { isScrolling.current = false }, 900)

That number is pure feel. Shorter and a single scroll flick blows through two panels; longer and it feels sticky. It’s the kind of constant you can only tune by using it.

Desktop and mobile are two different apps

The whole wheel/touch/transform machinery is desktop-only. On mobile (< 768px) the exact same section components render in a plain vertical flex-col, and every scroll handler is disabled. The branch is total:

{isMobile ? (
  <div className="flex flex-col">{/* sections stacked vertically */}</div>
) : (
  <div className="h-scroll-wrapper">
    <div ref={trackRef} className="h-scroll-track">{/* sections */}</div>
  </div>
)}

The trap this creates: any layout change has to be checked in both modes. A tweak that looks right in the horizontal track can be broken in the vertical stack, and vice versa. It’s the single most common way I’ve broken this site on myself.

Making one-at-a-time UI crawlable

A prerendering constraint I didn’t anticipate: some sections only render the active item. The experience section is a tabbed interface — click a job, see that job. But if only the active job is in the DOM, only one job is in the static HTML, and a crawler (or a screen reader) never sees the other four.

The fix is a visually-hidden mirror. The visible UI shows one job; an sr-only block renders the full dataset for machines:

<h2 id="experience-heading" className="sr-only">
  Work experience
</h2>
{/* sr-only: full list of every role, always in the DOM */}

Same idea powers the typing animations. useTypedText seeds its initial state with the complete string, so the finished text is present in the prerendered HTML and hydration matches — then the typing effect replays on the client purely as decoration. The content is never actually gated behind an animation.

What I’d tell myself before starting

  • Decide on prerendering first. It’s not a bolt-on. It changes where every piece of code is allowed to run, and retrofitting it means auditing every module for browser globals.
  • Reach for direct DOM mutation deliberately, not reflexively. It’s the right call for the transform track and wrong for almost everything else. State still owns the dots, the progress bar, and the entrance animations.
  • Treat “does it exist in view-source” as the real SEO test. Not “does it look right in the browser” — the browser runs your JS. Google’s first pass largely doesn’t.

The result is a site that feels like a toy — a little terminal you scrub through sideways — but audits like a boring, correct, static document. That gap between how it feels and how it’s built is the part I’m proudest of.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.