Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Nexora — portfolio case study

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.

Run 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 5273

Then open http://localhost:5273. .claude/launch.json starts the same server from the editor.

How the backdrop works

.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.

The choreography, and the one place to retune it

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 next

Pixels, 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.

Two card variants

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 .note and .tech elsewhere: neutral dark surface, --line border, 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:

  1. A full-width gradient wash per scene went murky and swallowed the render.
  2. A card whose gradient dissolved into transparency lost its edge, so it stopped reading as a card at all.
  3. 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.

Anchors land on the settled part of a scene

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.

Where the copy goes

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:

  • #type and #laptop use top and bottom bands.
  • #rig uses a top-left heading and a right column.
  • #brand, #motion and #grid use a top-left heading and a bottom band.
  • #close uses 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 to edit

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.

Deploying to GitHub Pages

.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.

Known rough edge

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.

Layout

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages