How to build an interactive quest-style portfolio

Most developer portfolios are a scroll of cards. This one is a map: a winding path, a pixel sprite you steer with the arrow keys, and quest nodes that open a dialogue box instead of a section. Below is exactly how it is built — the SVG geometry, the narrative structure, the performance and accessibility trade-offs — so you can adapt the idea to your own site.

Written by Supreetha R · React 19 · TanStack Start · SVG · Tailwind

TL;DR

  • One screen, no long scroll: the map is the navigation.
  • The path is a single SVG cubic-bezier; nodes are points sampled along it.
  • Each node is a "quest" record — title, blurb, image, prose — so content stays data-driven.
  • Sprite movement is keyboard + click, animated by interpolating along the same path.
  • Everything degrades: the same content exists as plain HTML for crawlers and screen readers.

1. Why a quest instead of sections

A portfolio has one job: make a stranger care enough to read your CV. A card grid asks for patience. A game asks for curiosity, and curiosity is cheaper to spend. Framing your work as a quest gives you three things a template cannot:

  • Order. A path is inherently sequenced, so you control the story beats instead of hoping people scroll in order.
  • Progress. Visited nodes can be ticked off, which nudges completion the way a checklist does.
  • Differentiation. For a freelancer, the site itself becomes the work sample — the interaction proves what the bullet points claim.

Constraint that makes it work: the game must never be the only copy. Write the real prose first, then wrap the map around it.

2. The winding path: one SVG, sampled

The path is a single <path> with cubic-bezier segments drawn in a normalised 0–100 view box, then scaled to whatever width the container has. Using preserveAspectRatio="none" plus a percentage view box means it stretches responsively without recomputing coordinates.

const D = "M 6 78 C 20 40, 30 88, 44 56 S 66 20, 78 52 S 92 74, 96 34";

<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-full w-full">
  <path d={D} fill="none" stroke="currentColor" strokeWidth={0.8}
        strokeDasharray="3 2" vectorEffect="non-scaling-stroke" />
</svg>

Node positions are not hand-placed. Measure the rendered path and sample it, so nodes always sit exactly on the line at any viewport size:

const el = pathRef.current!;
const total = el.getTotalLength();
const nodes = quests.map((q, i) => {
  const p = el.getPointAtLength((total * (i + 0.5)) / quests.length);
  return { ...q, x: p.x, y: p.y };
});

getPointAtLength is the whole trick. It gives you node placement, sprite travel, and the "distance travelled" progress bar from one source of truth. Recompute it in a ResizeObserver so rotating a phone keeps everything aligned.

3. Quests as data, not JSX

Keep every chapter in one typed array. Adding a project then means adding an object — the map, the dialogue, the counter, and the structured data all follow automatically.

type Quest = {
  id: string;
  title: string;
  blurb: string;      // hover tooltip, so visitors know before clicking
  lines: string[];    // dialogue-box prose
  image?: string;
  icon: LucideIcon;
};

The blurb field matters more than it looks: an interactive layout fails when people cannot tell what is behind a click. Show a one-line preview on hover and focus, and add a short summary strip at the top for anyone who will not open a single node.

4. Moving the character

Movement is an animated interpolation between two lengths along the path, driven by requestAnimationFrame with an ease-in-out curve. Clicking a node and pressing an arrow key both resolve to "walk to node index n", so mouse, keyboard, and touch share one code path.

useEffect(() => {
  const from = posRef.current, to = nodeLengths[target];
  let raf = 0, start = performance.now();
  const tick = (t: number) => {
    const k = Math.min(1, (t - start) / 700);
    const e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
    setLength(from + (to - from) * e);
    if (k < 1) raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(raf);
}, [target]);

The sprite itself is a small grid of coloured rectangles — pixel art in SVG. It scales crisply, needs no image request, and is trivial to restyle. Add a two-frame bob with a CSS keyframe so it feels alive while idle, and respect @media (prefers-reduced-motion: reduce) by dropping the animation and snapping to the target.

5. The dialogue box is your layout

Instead of stacked sections, one panel renders whichever quest is active. On desktop it sits beside the map; on mobile it starts closed so the map and sprite are the first impression, then opens after a few idle seconds unless the visitor interacts. Rules that kept it usable:

  • Always offer Back / Next so the panel is a linear read for people who do not want to explore.
  • Never truncate the chapter title — stack the header vertically instead of using ellipsis.
  • Mark visited nodes with a tick, not a text label, so the map stays quiet.
  • Give the last node a real call to action: a mail link, not "thanks for visiting".

6. Performance and media

Games get heavy fast. What actually moved the Lighthouse numbers on this build:

  • Convert chapter photos to WebP and size them to their rendered box (6.4 MB → ~366 KB).
  • loading="lazy" and decoding="async" on everything except the first painted image.
  • Trim web-font weights to the ones you actually set; each extra weight is render-blocking.
  • For video, serve transcoded H.264/AAC MP4s with a poster JPEG and a #t=0.1 fragment so a frame appears before the file streams. Show a spinner only after ~1s so fast connections never flash it.
  • Draw decoration with SVG and CSS rather than PNG overlays.

7. Accessibility and SEO for a non-scrolling page

An interactive page can still be a good document. The checks worth doing:

  • One <h1> containing your name; chapter headings are <h2>.
  • Nodes are real <button> elements with accessible names, focus rings, and arrow-key support.
  • Descriptive alt text on every photo; decorative SVG gets aria-hidden.
  • Because the copy lives in a data array, it is server-rendered into the HTML — crawlers read the whole story even though the UI reveals it one node at a time.
  • Add ProfilePage + Person JSON-LD, a self-referencing canonical, and a sitemap entry. Keep titles in the 50–60 character range.
  • Provide a printable CV route so recruiters can save a PDF without playing anything.

8. Adapt it in an afternoon

  1. Write 4–6 chapters of honest prose first.
  2. Draw one bezier path in a 0–100 view box until the shape pleases you.
  3. Sample node points from the path; render buttons at those coordinates.
  4. Add the dialogue panel with Back / Next, then wire clicks and arrow keys.
  5. Animate a pixel sprite along the path; add a visited tick and a progress count.
  6. Optimise images, add head metadata and JSON-LD, and test with a keyboard only.

Further reading on portfolio strategy rather than mechanics: Matt Olpinski's portfolio-website articles.

See it running

The portfolio described here is live — walk the map, meet the sprite, and download the CV.