Case Study
Interactive Resume
A résumé you interact with instead of read — data-driven, schema-validated, and animated with intent.
The Motive
A résumé is usually a PDF — a static document that asserts skills. For a frontend engineer that's a category error: the medium can't demonstrate the thing it's claiming. The Interactive Resume exists to close that gap and make the résumé itself the proof.
But "animated résumé" is a trap if it stops at decoration. The engineering problems I actually set out to solve:
- Content must not be code. A résumé's content changes often; its layout rarely does. Coupling them makes every content edit a code change and a deploy risk. Content should be data, and the layout a pure function of it.
- One source, many audiences. The same person is read very differently by someone exploring the site and by a recruiter who needs to scan and print. The data-driven core had to drive both an interactive experience and a plain, ATS-friendly CV — no duplicated content, no chance of the two drifting apart.
- It must never ship broken. Once content is data, a typo — a skill referenced that doesn't exist, an out-of-range date — can silently corrupt the page. With no server to catch it at runtime, correctness has to be a build-time guarantee.
- Motion has to mean something. Animation on a résumé is either the whole point or actively harmful. I wanted a principled system — reusable presets, one choreography gate, no flash of unstyled motion — not a pile of one-off tweens.
- Zero runtime cost. A showcase that's slow undermines itself. It had to compile to static HTML with no server and nothing heavy shipped to the browser.
Each of those became an architectural decision.
Execution
Content as data: one JSON file, two guarantees
Everything renders from a single resume.json — profile, skills, jobs, projects, education, languages. It's relational in a flat file: a job references skills by id (skillIds), a project references its job by jobId, and endDate: null encodes "Present". That only works if references are guaranteed to resolve, so the build validates in two phases before Next runs.
Phase one — shape: a draft-07 JSON Schema compiled by Ajv. Closed vocabularies (additionalProperties: false, enum'd category/proficiency), format: email/uri, and a reusable dateRange (month 1–12, year 1900–2100). endDate is a oneOf of dateRange-or-null — the "Present" sentinel encoded in the schema itself.
Phase two — referential integrity, which JSON Schema can't express. A pass builds id sets and checks every foreign key:
const skillIds = new Set(data.skills.map((s) => s.id));
for (const job of data.jobs)
for (const sid of job.skillIds)
if (!skillIds.has(sid))
errors.push(`jobs.${job.id}.skillIds: unknown skill "${sid}"`);Both run as a hard gate (validate.ts && next build), so a dangling reference fails the build, not production. Because it's a static export there's no runtime to catch bad data — pushing correctness to build time is the only place left to put it.
Between JSON and components sits a thin accessor layer — one module per domain that confines the untyped cast to a single line and hosts the joins:
export const skills: Skill[] = (resumeData as ResumeData).skills;
export function getSkillsByIds(ids: string[]): Skill[] {
return ids.map((id) => skills.find((s) => s.id === id))
.filter((s): s is Skill => s !== undefined);
}That .filter is deliberate: even if validation were bypassed, an unknown id degrades to "dropped" (a neutral chip in the UI) instead of a crash. Components import { jobs }, never resume.json. An interactive @clack/prompts generator builds the JSON with multiselect for foreign keys, so references are chosen from real ids — broken data is nearly impossible to author.
Theming: one class, twenty tokens
The palette is CSS custom properties, not Tailwind dark: variants. Tailwind v4's @theme maps every utility onto a variable (bg-surface → var(--theme-surface)); the values live in two scopes, dark by default:
:root, .dark { --theme-bg: #0f0b1a; --theme-accent: #7B3FE4; /* … */ }
.light { --theme-bg: #f8f7f4; --theme-accent: #5611C4; /* … */ }Swapping one class on <html> re-themes the whole page — including the ten categorized skill-chip colors — with a global 300 ms cross-fade for free, and new components are themeable just by using bg-surface/text-accent. Because it's a static export with no server to read a cookie, the no-flash trick is an inline <script> that sets the class from localStorage before first paint (defaulting to dark), with suppressHydrationWarning so React tolerates the pre-hydration DOM write.
Motion: presets, a gate, and a FOUC guard
GSAP is centralized so plugins register once (ScrollTrigger, TextPlugin, ScrambleTextPlugin). The goal was reusable intent, not scattered tweens — every motion is a preset keyed by an animation mode (subtle | dopamine; the résumé opts into dopamine):
export const cardPresets: Record<AnimationMode, TweenVars & { stagger: number }> = {
subtle: { opacity: 0, y: 20, duration: 0.4, ease: "power1.out", stagger: 0.1 },
dopamine: { opacity: 0, y: 50, scale: 0.9, duration: 0.8, ease: "back.out(2.5)", stagger: 0.2 },
};Three techniques carry it:
- One choreography gate. An
AnimationProviderflips anisReadyflag ~1.4 s after mount, matched to the loading splash. Every entranceuseGSAPearly-returns until then, so the page enters as one coordinated sequence instead of a race. - A paint-time FOUC guard. Animated wrappers ship
.gsap-animated { opacity: 0 }and GSAP animates them to1— no flash of un-animated, then-jumping content. - Scroll-reveals that survive layout shift. Sections reveal via
ScrollTriggerwith a shared config (start: "top 95%", play-once). Because columns slide in and web fonts load async, the sidebar re-firesScrollTrigger.refresh()on tween-complete and ondocument.fonts.readyso trigger positions are recomputed after layout settles.
The plugins earn their place in the details: headings retype with TextPlugin (duration scales with length), body text resolves out of a ScrambleTextPlugin shuffle (height locked first so it can't reflow), and overflowing skill rows become seamless marquees — duplicated lists wrapped with a modulo modifiers.x, animating only when content actually overflows and pausing on hover. Interactive pieces carry their own accessibility: the project modal is a real focus trap (Tab cycling, focus restore, scroll-lock, Escape), icon links are labeled, and a skip-link jumps to the main content.
The companion CV: one source, three outputs
The animated résumé is built to be experienced. But a recruiter screening two hundred applicants doesn't want a GSAP timeline — they want to scan it in ten seconds, print it, and feed it to an ATS. Those are opposite optimizations, so instead of compromising the interactive version I gave the same data a second, deliberately plain output: /cv, a traditional single-column résumé.
It's the same resume.json and the same accessors — only the presentation differs. The page is print-first: a clean recruiter layout with real @media print rules (@page margins, .no-print controls hidden, backgrounds forced on) so File → Print yields a tidy document, and the theme toggle is suppressed so it always prints light.
The download is a genuine PDF, not a screenshot — and the library that makes it never touches the initial bundle. The export button lazy-loads @react-pdf/renderer (and the PDF document) only on click:
const [{ pdf }, { CVDocument }] = await Promise.all([
import("@react-pdf/renderer"),
import("./CVDocument"),
]);
const blob = await pdf(<CVDocument /* same resume data */ />).toBlob();
// → object URL → trigger downloadCVDocument re-lays the identical data with @react-pdf primitives into a print-grade PDF. So one validated source of truth drives three surfaces — the interactive résumé, a printable HTML CV, and a downloadable PDF — and none of them can drift, because they all read the same file.
Static export: the constraint that shaped everything
output: "export" compiles the site to static HTML/JS with no Node server at runtime, and that one line propagated everywhere: content is imported and bundled at build (so validation must be a build gate), fonts are self-hosted via next/font (no runtime Google request), images are plain <img> (the optimizer needs a server), theming is client-side CSS-only, and everything interactive is a client component hydrated on a static shell. The payoff: it deploys as files on a CDN, loads instantly, and has no backend to break.