A single-page, scroll-scrubbed journey through four systems: Camp Burnt Gin, Wyncrest, Pathfinder-X and FutureYou. Each project scene pins for several viewport heights while a multi-plane camera moves through a still image and short text beats cross-fade in sequence.
Every scene also carries an interactive module that demonstrates the system's logic. Those modules are the point of the site, not decoration: a role explorer where no single role holds every power, an append-only ledger that refuses a duplicate at the boundary, a query path that shows its sources and no invented figures, and a deterministic financial projection whose dial also moves the light in the scene behind it.
Live: https://elton-sakyi-portfolio.vercel.app
| Concern | Choice |
|---|---|
| Framework | Next.js 15 (App Router), React 19 |
| Language | TypeScript, strict + noUncheckedIndexedAccess |
| Styling | Hand-written CSS in app/globals.css. No utility framework |
| Motion | One hand-written requestAnimationFrame scrubber. No animation library |
| Images | next/image, five 16:9 stills reused across scenes |
| Fonts | next/font — Bricolage Grotesque, Manrope, IBM Plex Mono |
| Hosting | Vercel (static; every route is prerendered) |
Dark only, by design: the scenes are lit for a black ground and there is no light variant of the artwork. No CMS, no database, no analytics, no chatbot.
pnpm install
pnpm dev # http://localhost:3000
pnpm build # production build
pnpm start # serve the production build
pnpm typecheck # tsc --noEmit
pnpm lint # eslintNo environment variables are required, locally or for deployment.
components/camera/cinematic.tsx is the only place in the app that touches the DOM directly, and
it does so deliberately. Plane transforms and beat opacities change on every scroll frame; routing
those through React state would re-render the page tree sixty times a second to produce styles
React would then have to diff. Instead the tree renders once — mostly as server components — and a
single coalesced rAF loop writes styles onto nodes it finds by data attribute.
The maths is pure and lives apart, in lib/camera.ts: one SHOTS entry per scene mapping scroll
progress to scale, translation and rotation, plus planeTransform for one depth layer. Those
curves are the design's authored values; they are what makes each scene read as a specific camera
move rather than generic parallax.
The markup contract is entirely data attributes — data-scene, data-origin, data-perspective,
data-cut, data-plane, data-beat/data-in/data-out/data-y, data-reveal. Adding a scene
means adding markup with those attributes and a SHOTS entry; nothing else has to know.
Two non-obvious constraints are load-bearing and documented at their call sites:
- The depth planes are not in a shared
preserve-3dcontext. Inside one, the browser sorts children by 3D geometry rather than DOM order, and a few degrees of Y rotation is enough for the blurred atmosphere plane to punch through the sharp subject plane. Each plane carries its ownperspective(...)transform function instead. - The match-cut
clip-pathis removed once the cut finishes, rather than left at its fully-open value. Clipping that group forces the whole set of blurred, transformed planes to be rasterised into one buffer, and holding that for the remaining 90% of a six-viewport scene locks up the compositor.
app/ layout, the single page, and metadata routes (icon, OG image, sitemap)
components/
camera/ the rAF scrubber
scenes/ one file per scene, plus the shared Scene/Stage/Plane/Beat primitives
modules/ the four interactive panels
shell/ fixed header and project navigator
content/ site constants and all module data
lib/ camera maths, tokens, the shared camera store
public/media/ the five scene stills
prefers-reduced-motion: reducezeroes camera strength and depth, holds the planes on a static scale, disables the match cuts, leaves beats to fade only, starts reveals visible, and makes programmatic scrolls jump rather than animate.- A beat that has faded out drops
pointer-eventsand setsaria-hidden, so only the legible beat is reachable by pointer or screen reader. - The reveal animation's hidden state is applied in JavaScript, not CSS, so content stays visible if the script never runs.
- Chamber hover on the work map is wired to focus as well, so keyboard users get the same lighting cue and caption.
All copy lives in the scene components and content/. It is the client's own wording and is
intentionally verbatim, including the curly apostrophes in the hero. There are no em dashes
anywhere in the copy, by request.
The interactive modules are models, not production dumps, and each panel says so in its header. The role matrix and ledger entries are simplified from the real policy layer; the Pathfinder-X panel shows no figures at all, because inventing numbers for a data product would undercut the argument the panel exists to make. If those disclaimers are ever removed, the demos stop being honest.