A one-page portfolio built around Nexora as the product. A 240-frame flythrough of the Nexora landing page runs behind the whole page, and eight sections take turns scrubbing it.
Any static server works. The page loads JPGs over HTTP, so opening
index.html from the filesystem will not work in Chrome.
python -m http.server 5273Then open http://localhost:5273. .claude/launch.json starts the same server
from the editor.
.backdrop is one position: fixed canvas at z-index: 0, behind everything.
Every real section sits at z-index: 1 on top of it. A section shows the
flythrough by being transparent, and hides it by having an opaque background.
Nothing crossfades or slides between scenes, because the canvas never moves.
| Section | Frames | Subject | Carries |
|---|---|---|---|
#top |
0-47 | the whole page, then the dive | the pitch |
#about |
none | opaque, one viewport | who I am, four numbers |
#type |
48-87 | the display headline | the type ramp |
#laptop |
88-99 | Laptop Air, exploded | Next.js, React, TypeScript |
#rig |
100-123 | headphones on the plate | Tailwind, GSAP, Framer Motion |
#brand |
124-151 | Watch X, the dial reads Nexora | HTML/a11y, JavaScript, Supabase |
#grid |
152-179 | the card row settling into a four-up | the card component |
#motion |
180-207 | the camera flying past the numbers | how things arrive |
#close |
208-239 | pulling back out to the whole page | what it costs to ship |
#components onward |
none | opaque | the component reference, process, contact |
SCENES in assets/js/main.js holds those ranges. On every scroll event the
script walks the list and keeps the last scene the scroll position has reached,
so the frame holds where a scene left off while an opaque section covers the
canvas. Each scene's height sets how many pixels of scroll its frames get, from
44px per frame in the fast chapters to 107px in the laptop scene, where there is
the most to read.
Inside each scene, .scene__pin is position: sticky so the copy holds still
while the frames advance under it.
frames3 is a higher-quality encode: same 1280x720, but about 128KB a frame
against 20KB in the earlier set, so 30MB for all 240. The loader therefore gates
on only 16 frames, about 2MB, and streams the other 224 behind them. Raise
GATE in main.js if you would rather wait longer for a smoother first scrub;
re-encode frames3 if 30MB is too much to serve.
Blocks arrive one at a time and never overlap. Five constants at the top of
main.js describe the whole page's rhythm, in pixels of scroll:
var ARRIVE_PX = 320; // one block fading in
var GAP_PX = 300; // dead scroll after a block lands, before the next
var HOLD_PX = 420; // everything up and readable
var LEAVE_PX = 240; // one block fading out
var LEAVE_GAP_PX = 160; // between one block leaving and the nextPixels, not a fraction of the section, so the pacing does not change when a
section gets taller or a viewport gets shorter. sizeScenes() then derives each
section's height from its own block count:
runway = n*ARRIVE + (n-1)*GAP + HOLD + n*LEAVE + (n-1)*LEAVE_GAP
The height is the choreography, so CSS carries a fallback only. Keeping the number in both places let them drift apart.
Only blocks the current breakpoint actually shows get a slot: a short phone
hides the supporting lede, and counting it anyway left 620px of scroll where
nothing arrived. measureUnits() refilters on resize, so a five-block scene is
5060px on a desktop and 4040px on a short phone.
This makes the page long: about 45,000px, roughly 49 screens. That is the
cost of a real gap between blocks. To tighten it, GAP_PX and HOLD_PX are the
cheap knobs; ARRIVE_PX + LEAVE_PX is charged once per block and is what sets
the floor.
Each block still fades in and out inside the pinned runway, so nothing is on screen while the pin is sliding and no card can be caught mid-air at a section boundary.
The hero is the exception. Its copy rides the veil's curve, both gone by 30% of the hero runway, so the pitch and the gradient behind it leave together. A 3.5px to 0 blur on the canvas runs on the same curve, a focus pull as the headline clears.
Copy blocks are cards in two variants. Both align their left edge to the column and both use the same 26px horizontal padding, so edges and text line up down the whole scene.
- Lead — the section heading. 32px of top padding against the plain card's
18px, a 26px radius, a violet corner glow, a violet border, and a
violet-to-lime spine down its left edge. The heading inside runs
clamp(32px, 4.2vw, 56px). - Plain — supporting copy, matching
.noteand.techelsewhere: neutral dark surface,--lineborder, a dim hairline along the top.
Hierarchy comes from size and from a different treatment, not two shades of the same panel.
Three earlier attempts did not work, each for its own reason:
- A full-width gradient wash per scene went murky and swallowed the render.
- A card whose gradient dissolved into transparency lost its edge, so it stopped reading as a card at all.
- Pulling each card left by its own padding put the text on the grid but left the card edges stepped against the note cards beside them. Aligning edges and sharing one horizontal padding is what fixes it.
One mobile trap worth knowing: the desktop top band uses
align-items: flex-end to sit its two cards on a shared baseline. Stacked, that
same declaration shrink-wraps them and pushes them to the right edge, so the
mobile block resets it to stretch.
A nav link pointing at a scene used to drop you at its progress 0, which is a
bare frame with nothing on it yet. aimAnchors() writes a negative
scroll-margin-top on each scene, computed from the same choreography
constants:
offset = n*ARRIVE_PX + (n-1)*GAP_PX + HOLD_PX*0.4
A negative scroll-margin moves the anchor target down the section, so clicking a
link, going back, and loading /#type cold all land in the same place with no
click handler in the way. start() re-runs scrollIntoView once for a direct
hash load, because the browser does its anchor scroll before the margins exist
and while the body is still overflow: hidden behind the loader.
Checked for all twelve link targets: every scene lands with all of its blocks at opacity 1, and the opaque sections still land 90px down to clear the nav.
The renders put their subject dead centre, so every transparent section keeps the middle empty and puts its cards along the edges it can use:
#typeand#laptopuse top and bottom bands.#riguses a top-left heading and a right column.#brand,#motionand#griduse a top-left heading and a bottom band.#closeuses one wide left column.
Under 900px the columns collapse into one stack against the bottom edge and the render holds the top of the frame.
Under prefers-reduced-motion: reduce the scrub still works, since it is direct
manipulation rather than autoplay. The eased playhead, the blur and the bobbing
scroll arrow all drop out.
| What | Where |
|---|---|
| Which frames a section plays | SCENES in assets/js/main.js |
| How long a section scrubs for | the five choreography constants in assets/js/main.js |
| How far the copy travels in and out | the 26 in overlay(), applied to --slide |
| Colours, spacing, type | the :root token block at the top of the stylesheet |
| Stack percentages, stat numbers | inline in index.html — they are placeholders |
| Contact details | the #contact section — hello@yourdomain.com and the two social handles are placeholders |
The ?v=N on the stylesheet and script links is cache busting. Bump it when you
change either file, or a browser that cached the old copy will keep serving it.
.github/workflows/deploy-pages.yml builds and publishes on every push to
main or master. It copies only index.html, assets/ and frames3/ into
_site, adds .nojekyll, then fails the build if any local href or src in
index.html does not resolve. That check is there because a folder rename once
left six card thumbnails pointing at a deleted directory, which would otherwise
have published a page with broken images.
One setting to flip, once. Repo, Settings, Pages, set Source to GitHub Actions. Without it the workflow runs and the deploy step fails. Everything else is already in the repo.
Nothing needs a base path: every reference is relative, so the site works
whether it is served from user.github.io or user.github.io/repo/.
There is a .nojekyll at the repo root too, so deploying straight from a branch
also works if you would rather not use Actions.
The artifact is about 30MB, effectively all of it frames3. Pages puts it behind
a CDN and the loader gates on 16 frames, but a first visit does pull 30MB in the
background. Re-encoding frames3 at quality 80 would roughly halve that.
The source render has floating text baked into frames around 40 to 60, along the lines of "Delivering your landing page… Contact me". The nav scrim hides most of it. Removing it needs the frames re-exported or the canvas draw cropped past the top of the image.
index.html
assets/css/style.css
assets/js/main.js
frames3/ all 240 frames, ~30MB
first.png the design reference, not served
.github/workflows/deploy-pages.yml the Pages build
.nojekyll serve files as-is, no Jekyll