
Parallax Landing Page
- 42 installs
- 262 repo stars
- Updated July 11, 2026
- hoodini/ai-agents-skills
Turn a short video into a single-hero landing page where the scroll gesture scrubs extracted frames in place while five text scenes crossfade.
About
Extracts every frame of a 5-15s clip and builds a self-contained HTML page where scroll input scrubs frames without the page scrolling, with dramatic text overlays. A developer uses it to turn a short clip into a cinematic scroll-scrub landing.
- Frame extraction plus virtual-scroll canvas frame swap
- Anton/Caveat/Inter type stack with preloader and film-grain overlay
Parallax Landing Page by the numbers
- 42 all-time installs (skills.sh)
- +8 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,360 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hoodini/ai-agents-skills --skill parallax-landing-pageAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 42 |
|---|---|
| repo stars | ★ 262 |
| Last updated | July 11, 2026 |
| Repository | hoodini/ai-agents-skills ↗ |
What it does
Turn a short video into a single-hero landing page where the scroll gesture scrubs extracted frames in place while five text scenes crossfade.
Files
Parallax Landing Page
Turn a short video into a one-screen cinematic landing page where the user's scroll gesture scrubs through the video frame-by-frame while five dramatic text scenes crossfade in and out. The document never actually scrolls — wheel/touch/keyboard input is intercepted in JS and converted into virtual frame progress.
This skill encodes the patterns proven in the bundled reference at `../../examples/parasites/` (the github / lion / hope landings — three working pages built with this exact skill). Treat that folder as the ground truth if anything here is ambiguous.
Where this skill sits in the YUV.AI pyramid
parallax-landing-page is in the middle tier of the YUV.AI skills pyramid, alongside yuv-design-system, yuv-decks, yuv-viral-video, and video-to-landing-page. The top-tier orchestrator yuv-pilot routes here whenever a 5–15s video clip needs to become a single-hero scroll-scrub landing.
This skill brings its own visual language (Anton + Caveat + Inter, gold/amber/accent/cream accent palette) — it does NOT call into yuv-design-system. The two systems coexist: yuv-design-system owns the brand palette across web/app/deck surfaces, parallax-landing-page owns the cinematic scroll-scrub aesthetic specifically. For YUV.AI parallax landings, you can OPTIONALLY add a phoenix watermark + the canonical Linktree URL in the footer area as a brand throughline — see yuv-design-system/assets/ for the watermark file. Do NOT swap the Anton/Caveat type stack for the design system's Neon palette — that would lose the signature look.
What you're building
Each landing is a single locked viewport with:
- A canvas filling the screen, drawing one frame at a time (cover-fit, DPR ≤ 2)
- 5 absolutely-positioned text scenes (kicker + uppercase Anton headline + Caveat handwritten line), each with its own scroll-progress window
- A fixed nav with
PARALLAX // showcaselogo and links to sibling landings - A loader that preloads every frame before letting the user interact
- A vignette + film grain overlay
- A bottom scroll-hint that fades out once the user starts scrolling
- A bottom-right frame counter (e.g.
073 / 145) - A final scene that is the CTA section — no separate
.endsection below
Total weight at q:v 2: roughly 120–220 KB per frame × frame count. A 6-second 24fps clip = 145 frames ≈ 25 MB. A 10-second clip = 241 frames ≈ 53 MB.
Save location
Default: ~/Documents/yuv-projects/landings/<slug>/ — always save standalone landings here so you can find them again. Override only if the user explicitly picks a different location OR if Mode B (integrate into the existing examples/parasites/ showcase) is selected.
mkdir -p ~/Documents/yuv-projects/landings
cd ~/Documents/yuv-projects/landings
# The skill creates <slug>/ here as it extracts frames and builds the HTML.Final path (Mode A — standalone): ~/Documents/yuv-projects/landings/<slug>/. Final path (Mode B — integrate): the existing showcase directory the user chose.
Tell the user the final path at the end of every build.
---
Workflow
There are four phases. Don't skip phases — each one feeds the next.
Phase 1 — Discover
Confirm with the user: 1. Video path — where the source MP4 lives. 2. Slug — short folder/file name (e.g. hope, marcus, desk). One word, lowercase, no spaces. This becomes both the frame folder name (<slug>/) and the HTML filename (<slug>.html). 3. Theme / topic — what is this landing about? A product launch? A personal moment? A wildlife encounter? You need this to write the copy. 4. Project mode — standalone (brand new folder) or integrate into the existing parasites showcase at examples/parasites/ (top of this repo). If the user says "add it to the showcase", they mean Mode B below. 5. Accent color — for the script/CTA highlight: gold (#f5b042), amber (#ff8a3d), accent (pink #ff4d6d), cream (#f5e9d4), or something new (add it to :root in style.css and write a new .script.<name> rule).
If the user hasn't specified some of these, propose defaults and move on — don't stall on questions they can answer once they see the result.
Phase 2 — Extract frames
Run the bundled extraction script. It probes the video with ffprobe and extracts every frame at native resolution with -q:v 2 (near-lossless JPEG), naming them frame-001.jpg through frame-NNN.jpg (3-digit zero-padded).
python <skill>/scripts/extract_frames.py <video_path> <output_folder>Example:
python C:/Users/User/.claude/skills/parallax-landing-page/scripts/extract_frames.py \
C:/Users/User/Documents/parasites/hope/hope.mp4 \
C:/Users/User/Documents/parasites/hopeThe script prints a JSON metadata block to stdout. Capture it — you need frame_count and suggested_scroll_budget for the HTML.
Why -q:v 2: ezgif and similar tools re-compress aggressively (~55 KB/frame). Native ffmpeg at q:v 2 gives ~120–220 KB/frame at the same dimensions — visibly sharper. Do not downscale; never upscale (it inflates files without adding detail).
Why the scroll-budget formula (≈26 px per frame): keeps the tactile "pixels of scroll per frame advanced" constant across clip lengths. 145 frames → ~4200, 241 frames → ~6300, 90 frames → ~2500 (floor). The script computes it for you and clamps to [2500, 8000].
Phase 3 — Author the page
Read the template at <skill>/assets/landing-page-template.html. It is a complete working file with {{PLACEHOLDER}} markers and <!-- TODO: --> comments next to creative bits. Write a new file at <target>/<slug>.html with everything substituted.
Substitution checklist (every placeholder must be replaced):
| Placeholder | What to put | Example |
|---|---|---|
{{PAGE_TITLE}} | HTML <title> | Hope — The Fastest Heart in the Negev |
{{PAGE_DESCRIPTION}} | meta description, one sentence | A moment with Hope, the cheetah of Midbarium. |
{{SLUG}} | folder name (no path, no slash) | hope |
{{FRAME_COUNT}} | integer frame count | 241 |
{{FRAME_COUNTER_TEXT}} | 001 / NNN with matching width | 001 / 241 |
{{SCROLL_BUDGET}} | integer from the extractor JSON | 6300 |
{{PREVIEW_FRAME_MID}} | zero-padded middle frame | 121 |
{{PREVIEW_FRAME_END}} | zero-padded last frame | 241 |
{{LOADER_TITLE}} | one word, uppercase | HOPE |
{{LOADER_SCRIPT}} | handwritten line ending in … | summoning the runner… |
{{NAV_LINKS_HTML}} | full <a> tag block for nav | see below |
{{SCENE_N_*}} | kicker, headline, handwritten line for each of 5 scenes | see copy guide |
{{SCROLL_HINT_TEXT}} | 2–4 word uppercase prompt | SCROLL TO RUN |
{{CTA_BUTTONS_HTML}} | 2–4 <a class="cta"> tags | see below |
{{ACCENT_CLASS}} | accent color suffix for .script.X and .cta.X | amber |
Read references/copy-guide.md for the cinematic copy patterns (kicker phrasing, headline length, handwritten line tone). The copy is where this skill earns its keep — generic copy will sink an otherwise beautiful animation.
Phase 4 — Wire up assets
If the target folder already has style.css and parallax.js (showcase integration), do nothing — they're shared. Otherwise copy both from <skill>/assets/ into the target folder:
cp <skill>/assets/style.css <target>/style.css
cp <skill>/assets/parallax.js <target>/parallax.jsIf integrating into an existing showcase, also do the steps in references/showcase-integration.md — add a card to index.html, add a link to every existing landing's navbar, and add the chain CTA on the previous landing's final scene.
Tell the user how to view:
Open<target>/<slug>.htmlviapython -m http.server 8000from the parent folder (notfile://— Chromium can taint the canvas with local image origins).
File contract
<target>/
├── <slug>/ # frame folder
│ ├── <slug>.mp4 # the source (preserved)
│ ├── frame-001.jpg
│ ├── frame-002.jpg
│ └── … frame-NNN.jpg
├── <slug>.html # the landing page
├── style.css # shared (copy if standalone)
├── parallax.js # shared (copy if standalone)
└── index.html # the hub (only if standalone or integration)The frame folder name and the HTML filename MUST share the same slug — the folder option in the page's new ParallaxPage({folder: '<slug>', ...}) call is the literal folder path.
The ParallaxPage API (already in parallax.js, do not redefine)
new ParallaxPage({
folder: '<slug>', // folder containing frame-001.jpg, etc.
frameCount: 145, // integer
prefix: 'frame-', // always 'frame-' for this skill
scrollBudget: 4200, // pixels of accumulated input for full pass
// optional: padWidth (default 3), ext (default 'jpg')
})The class auto-attaches body.scrub-page (which locks scroll via CSS), preloads all frames, runs an RAF-driven lerp (0.22 factor) toward the scroll-derived target frame, and crossfades scenes whose data-start/data-end ranges include the current progress.
Hard constraints (the non-negotiables)
These are baked into the reference and break the experience if violated:
- 5 scenes, fixed progress windows:
0.00–0.20,0.22–0.40,0.42–0.60,0.62–0.80,0.82–1.00. The 0.02 gap between scenes is intentional — it's a moment of "just the image, no text" between beats. The hero (scene 1) and final (scene 5) usedata-fade="0.30"anddata-fade="0.18"respectively so they hold longer at the edges; middle scenes use the default 0.25. - No `.end` section beneath the scrub. The final scene contains the CTAs. The whole page is one locked viewport.
- Anton + Caveat + Inter — uppercase Anton for everything bold, Caveat for handwritten accents (gold/amber/cream), Inter for any body lead text. Never substitute, never add a fourth font.
- Off-white `#f5f1ea`, never `#fff` — already in CSS as
var(--text). - Frame names are `frame-NNN.jpg` zero-padded to the width of the total (3 digits is the standard since clips < 1000 frames; the JS auto-computes counter width from the total).
- The slug is the folder name AND the HTML filename basename —
hope/,hope.html. No exceptions; the integration steps assume this.
Where to look when you get stuck
- The reference showcase bundled in this repo: `examples/parasites/` — github.html, lion.html, hope.html, style.css, parallax.js, index.html. If your output diverges visibly from those, you've drifted; reread.
references/copy-guide.md— how to write the 5 scenes so they actually feel cinematic.references/showcase-integration.md— exact edits needed when adding to an existing hub.assets/landing-page-template.html— the canonical structure with placeholders.assets/hub-template.html— only used when creating a brand-new showcase (3 cards from scratch).
Two real example invocations
Standalone: user has a single clip desk.mp4 and wants a landing page. 1. Ask for slug + theme. Run extractor on desk.mp4 → ./desk/frame-NNN.jpg. 2. Copy style.css + parallax.js to project root. 3. Author desk.html from template. Nav has just one link: itself. 4. Done. Tell user to serve with python -m http.server.
Showcase add: user adds hope.mp4 to the existing parasites folder. 1. Slug = hope. Extract → hope/frame-NNN.jpg. (Reuse existing style.css + parallax.js.) 2. Author hope.html from template. 3. Run showcase integration: add a third card to index.html, append a Hope link to the navbar in github.html and lion.html, chain the previous landing's final scene with a Next: Hope → CTA. 4. If introducing a new accent color, add it to :root and add .script.X + .cta.X rules in style.css.
<!DOCTYPE html>
<!--
============================================================================
HUB / INDEX TEMPLATE — only for standalone projects with 2+ landings.
If the project is just one landing, you don't need this file — the
landing's own nav can link only to itself.
The grid is 3-up on wide screens, 2-up on medium, 1-up on phones.
Add one <a class="hub-card"> per landing in the project. Each card
needs a unique accent: `.hub-card.<slug> .dot` is styled in style.css
(see the section just below the .hub-card .dot rule). Pick one of the
existing dot styles or add a new one.
============================================================================
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{HUB_TITLE}}</title>
<meta name="description" content="{{HUB_DESCRIPTION}}" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Caveat:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
<!-- Preload each card's hero image so the hub itself feels instant. -->
{{HUB_PRELOAD_HTML}}
<link rel="stylesheet" href="style.css" />
</head>
<body class="hub-wrap">
<div class="scroll-progress"><div class="scroll-progress-bar"></div></div>
<nav class="nav">
<a href="index.html" class="nav-logo">PARALLAX <span class="slash">// showcase</span></a>
<div class="nav-links">
<a href="index.html" class="active">Home</a>
{{HUB_NAV_LINKS_HTML}}
</div>
</nav>
<main class="hub-main">
<div class="hub-eyebrow">{{HUB_EYEBROW}}</div>
<h1 class="hub-title">
{{HUB_HEADLINE_HTML}}
<span class="script">{{HUB_HEADLINE_SCRIPT}}</span>
</h1>
<p class="hub-lead">{{HUB_LEAD}}</p>
<div class="hub-cards">
{{HUB_CARDS_HTML}}
</div>
</main>
<footer class="footer">
<span>© {{FOOTER_LEFT}}</span>
<span class="script">{{FOOTER_MIDDLE}}</span>
<span>{{FOOTER_RIGHT}}</span>
</footer>
</body>
</html>
<!--
============================================================================
CARD SNIPPET — one of these per landing inside the {{HUB_CARDS_HTML}} block.
Replace {{SLUG}}, {{ACCENT_FRAME}} (a padded number like "073"),
{{KICKER}}, {{HEADLINE_HTML}}, {{SCRIPT_LINE}}, and pick a {{DOT_CLASS}}
('' for default pink, 'lion' for gold, 'hope' for amber, or add new).
<a href="{{SLUG}}.html" class="hub-card {{DOT_CLASS}}">
<div class="hub-card-bg" style="background-image: url('{{SLUG}}/frame-{{ACCENT_FRAME}}.jpg');"></div>
<span class="dot"></span>
<span class="hub-card-arrow">Enter →</span>
<div class="hub-card-content">
<span class="kicker">{{KICKER}}</span>
<h2>{{HEADLINE_HTML}}</h2>
<p class="script">{{SCRIPT_LINE}}</p>
</div>
</a>
============================================================================
-->
<!DOCTYPE html>
<!--
============================================================================
PARALLAX LANDING PAGE TEMPLATE
Copy this file to `<target>/<slug>.html` and replace every `{{...}}`
placeholder. Do not change the structure, class names, scene data-start /
data-end ranges, or the parallax.js init call (other than its options).
Those are load-bearing — `parallax.js` queries them by class and by data
attribute.
See: ../SKILL.md (Phase 3) and ../references/copy-guide.md
============================================================================
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESCRIPTION}}" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Caveat:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
<!-- Prime the browser cache with first, middle, last frames so the
loader's progress bar starts moving immediately. -->
<link rel="preload" as="image" href="{{SLUG}}/frame-001.jpg" />
<link rel="preload" as="image" href="{{SLUG}}/frame-{{PREVIEW_FRAME_MID}}.jpg" />
<link rel="preload" as="image" href="{{SLUG}}/frame-{{PREVIEW_FRAME_END}}.jpg" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="scroll-progress"><div class="scroll-progress-bar"></div></div>
<!-- Loader: shows until all frames are preloaded. -->
<div class="loader" aria-live="polite">
<div class="loader-title">{{LOADER_TITLE}}</div>
<div class="loader-script">{{LOADER_SCRIPT}}</div>
<div class="loader-bar"><div class="loader-bar-fill"></div></div>
<div class="loader-progress">0%</div>
</div>
<!-- Fixed nav. Always include Home + one link per landing in the project.
Mark the current page with class="active". -->
<nav class="nav">
<a href="index.html" class="nav-logo">PARALLAX <span class="slash">// showcase</span></a>
<div class="nav-links">
{{NAV_LINKS_HTML}}
</div>
</nav>
<!-- Scrub section: one locked viewport. The page itself never scrolls. -->
<section class="scrub" id="scrub">
<div class="scrub-pin">
<canvas class="bg-canvas" aria-hidden="true"></canvas>
<div class="vignette"></div>
<div class="grain"></div>
<div class="scene-stack">
<!-- ============== SCENE 1 — HERO ==============
Hero stays visible longer (data-fade=0.30) so the user has time
to read it before the next frame nudges it out. Use <h1> here. -->
<div class="scene" data-start="0.00" data-end="0.20" data-fade="0.30">
<div class="scene-inner">
<span class="kicker">{{SCENE_1_KICKER}}</span>
<h1 class="huge">{{SCENE_1_HEADLINE}}</h1>
<p class="script {{ACCENT_CLASS}}">{{SCENE_1_SCRIPT}}</p>
</div>
</div>
<!-- ============== SCENE 2 — left-bottom anchor ============== -->
<div class="scene align-left align-bottom" data-start="0.22" data-end="0.40">
<div class="scene-inner">
<span class="kicker">{{SCENE_2_KICKER}}</span>
<h2 class="huge smaller">{{SCENE_2_HEADLINE}}</h2>
<p class="script cream">{{SCENE_2_SCRIPT}}</p>
</div>
</div>
<!-- ============== SCENE 3 — right-top anchor ============== -->
<div class="scene align-right align-top" data-start="0.42" data-end="0.60">
<div class="scene-inner">
<span class="kicker">{{SCENE_3_KICKER}}</span>
<h2 class="huge smaller">{{SCENE_3_HEADLINE}}</h2>
<p class="script {{ACCENT_CLASS}}">{{SCENE_3_SCRIPT}}</p>
</div>
</div>
<!-- ============== SCENE 4 — left anchor ============== -->
<div class="scene align-left" data-start="0.62" data-end="0.80">
<div class="scene-inner">
<span class="kicker">{{SCENE_4_KICKER}}</span>
<h2 class="huge smaller">{{SCENE_4_HEADLINE}}</h2>
<p class="script cream">{{SCENE_4_SCRIPT}}</p>
</div>
</div>
<!-- ============== SCENE 5 — FINAL (with CTAs) ==============
The CTAs live inside this scene. There is NO separate .end
section beneath. Final scene also uses a tighter fade
(data-fade=0.18) so it sits and holds. -->
<div class="scene" data-start="0.82" data-end="1.00" data-fade="0.18">
<div class="scene-inner">
<span class="kicker">{{SCENE_5_KICKER}}</span>
<h2 class="huge smaller">{{SCENE_5_HEADLINE}}</h2>
<p class="script {{ACCENT_CLASS}}">{{SCENE_5_SCRIPT}}</p>
<div class="end-actions">
{{CTA_BUTTONS_HTML}}
</div>
</div>
</div>
</div>
<div class="scroll-hint">{{SCROLL_HINT_TEXT}}</div>
<div class="frame-counter">{{FRAME_COUNTER_TEXT}}</div>
</div>
</section>
<script src="parallax.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const page = new ParallaxPage({
folder: '{{SLUG}}',
frameCount: {{FRAME_COUNT}},
prefix: 'frame-',
scrollBudget: {{SCROLL_BUDGET}},
});
page.start();
});
</script>
</body>
</html>
/* ============================================================
parallax.js — virtual-scroll frame scrubber
The document does NOT scroll. We intercept wheel / touch /
keyboard input and convert it into a 0..1 virtualProgress
that drives the frame index and the scene fades.
============================================================ */
(function () {
'use strict';
// ---------- utils ----------
const pad = (n, w) => String(n).padStart(w, '0');
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
// trapezoidal fade for a scene over [start..end]
function trapFade(progress, start, end, fadeRatio = 0.25) {
if (progress <= start || progress >= end) return 0;
const t = (progress - start) / (end - start);
const fIn = Math.min(1, t / fadeRatio);
const fOut = Math.min(1, (1 - t) / fadeRatio);
return Math.max(0, Math.min(fIn, fOut));
}
// ---------- FrameScrubber ----------
class FrameScrubber {
constructor({ canvas, folder, frameCount, padWidth = 3, prefix = 'frame-', ext = 'jpg' }) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.folder = folder;
this.frameCount = frameCount;
this.padWidth = padWidth;
this.prefix = prefix;
this.ext = ext;
this.frames = new Array(frameCount);
this.loaded = 0;
this.target = 0;
this.current = 0;
this.lastDrawn = -1;
this.dpr = Math.min(2, window.devicePixelRatio || 1);
}
framePath(i) {
return `${this.folder}/${this.prefix}${pad(i + 1, this.padWidth)}.${this.ext}`;
}
preload(onProgress) {
const tasks = [];
for (let i = 0; i < this.frameCount; i++) {
tasks.push(new Promise((resolve) => {
const img = new Image();
img.decoding = 'async';
const done = () => {
this.loaded += 1;
onProgress && onProgress(this.loaded, this.frameCount);
resolve();
};
img.onload = done;
img.onerror = done;
img.src = this.framePath(i);
this.frames[i] = img;
}));
}
return Promise.all(tasks);
}
resize() {
const w = window.innerWidth;
const h = window.innerHeight;
this.canvas.width = Math.round(w * this.dpr);
this.canvas.height = Math.round(h * this.dpr);
this.canvas.style.width = w + 'px';
this.canvas.style.height = h + 'px';
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
this.lastDrawn = -1;
this.draw(this.current);
}
draw(frameIdx) {
const i = clamp(Math.round(frameIdx), 0, this.frameCount - 1);
if (i === this.lastDrawn) return;
const img = this.frames[i];
if (!img || !img.complete || !img.naturalWidth) return;
const cw = this.canvas.width / this.dpr;
const ch = this.canvas.height / this.dpr;
const iw = img.naturalWidth;
const ih = img.naturalHeight;
const scale = Math.max(cw / iw, ch / ih); // cover
const dw = iw * scale;
const dh = ih * scale;
const dx = (cw - dw) * 0.5;
const dy = (ch - dh) * 0.5;
this.ctx.clearRect(0, 0, cw, ch);
this.ctx.drawImage(img, dx, dy, dw, dh);
this.lastDrawn = i;
}
setTarget(progress01) {
this.target = clamp(progress01, 0, 1) * (this.frameCount - 1);
}
snapTo(progress01) {
const v = clamp(progress01, 0, 1) * (this.frameCount - 1);
this.target = v;
this.current = v;
this.draw(v);
}
tick() {
const diff = this.target - this.current;
if (Math.abs(diff) < 0.005) {
if (this.current !== this.target) {
this.current = this.target;
this.draw(this.current);
}
return;
}
this.current += diff * 0.22;
this.draw(this.current);
}
}
// ---------- ParallaxPage (virtual scroll) ----------
class ParallaxPage {
constructor({ folder, frameCount, scrollBudget = 2800, prefix, padWidth, ext }) {
this.canvas = document.querySelector('.bg-canvas');
this.scenes = Array.from(document.querySelectorAll('.scene'));
this.loaderEl = document.querySelector('.loader');
this.progressTextEl = document.querySelector('.loader-progress');
this.progressBarEl = document.querySelector('.loader-bar-fill');
this.scrollBarEl = document.querySelector('.scroll-progress-bar');
this.scrollHintEl = document.querySelector('.scroll-hint');
this.frameCounterEl = document.querySelector('.frame-counter');
// Strip undefined so FrameScrubber's defaults still apply.
const scrubberOpts = { canvas: this.canvas, folder, frameCount };
if (prefix !== undefined) scrubberOpts.prefix = prefix;
if (padWidth !== undefined) scrubberOpts.padWidth = padWidth;
if (ext !== undefined) scrubberOpts.ext = ext;
this.scrubber = new FrameScrubber(scrubberOpts);
// Virtual scroll: 0..1 progress driven by wheel / touch / keys.
// scrollBudget is the number of accumulated input pixels for a
// full 0→1 traversal. ~2800px ≈ 3-4 trackpad swipes / ~28 mouse
// wheel notches — feels deliberate but not exhausting.
this.virtualProgress = 0;
this.scrollBudget = scrollBudget;
this.interactive = false;
this._raf = this._raf.bind(this);
}
async start() {
document.documentElement.classList.add('scrub-page');
document.body.classList.add('scrub-page');
requestAnimationFrame(this._raf);
await this.scrubber.preload((loaded, total) => {
const pct = Math.round((loaded / total) * 100);
if (this.progressTextEl) this.progressTextEl.textContent = pct + '%';
if (this.progressBarEl) this.progressBarEl.style.right = (100 - pct) + '%';
});
this.scrubber.resize();
this.scrubber.snapTo(0);
await new Promise(r => setTimeout(r, 220));
if (this.loaderEl) this.loaderEl.classList.add('hidden');
if (this.scrollHintEl) this.scrollHintEl.classList.add('show');
this._update();
this._attachInputs();
this.interactive = true;
}
_attachInputs() {
// ---- wheel ----
window.addEventListener('wheel', (e) => {
if (!this.interactive) return;
e.preventDefault();
let dy = e.deltaY;
if (e.deltaMode === 1) dy *= 16; // lines
else if (e.deltaMode === 2) dy *= window.innerHeight; // pages
this.virtualProgress = clamp(
this.virtualProgress + dy / this.scrollBudget, 0, 1
);
this._update();
}, { passive: false });
// ---- touch ----
let touchY = null;
window.addEventListener('touchstart', (e) => {
touchY = e.touches[0].clientY;
}, { passive: true });
window.addEventListener('touchmove', (e) => {
if (touchY == null || !this.interactive) return;
// don't hijack scrolls that start on links/buttons
if (e.target.closest('a, button')) return;
e.preventDefault();
const y = e.touches[0].clientY;
const dy = touchY - y; // finger up ⇒ positive (advance)
touchY = y;
this.virtualProgress = clamp(
this.virtualProgress + (dy * 2.4) / this.scrollBudget, 0, 1
);
this._update();
}, { passive: false });
window.addEventListener('touchend', () => { touchY = null; });
window.addEventListener('touchcancel', () => { touchY = null; });
// ---- keyboard ----
window.addEventListener('keydown', (e) => {
if (!this.interactive) return;
// ignore when typing in a form field (none here, but safe)
if (/^(input|textarea|select)$/i.test(e.target.tagName)) return;
const SMALL = 0.035;
const BIG = 0.10;
let d = null;
switch (e.key) {
case 'ArrowDown':
case 'j': d = SMALL; break;
case 'ArrowUp':
case 'k': d = -SMALL; break;
case 'PageDown':
case ' ': d = BIG; break;
case 'PageUp': d = -BIG; break;
case 'Home':
e.preventDefault();
this.virtualProgress = 0; this._update(); return;
case 'End':
e.preventDefault();
this.virtualProgress = 1; this._update(); return;
default: return;
}
e.preventDefault();
this.virtualProgress = clamp(this.virtualProgress + d, 0, 1);
this._update();
});
// ---- resize ----
window.addEventListener('resize', () => {
this.scrubber.resize();
this._update();
});
window.addEventListener('orientationchange', () => {
this.scrubber.resize();
this._update();
});
}
_update() {
const p = this.virtualProgress;
this.scrubber.setTarget(p);
// scenes — fade + slight upward translate as they appear
for (const scene of this.scenes) {
const start = parseFloat(scene.dataset.start);
const end = parseFloat(scene.dataset.end);
const ratio = scene.dataset.fade ? parseFloat(scene.dataset.fade) : 0.25;
const op = trapFade(p, start, end, ratio);
scene.style.opacity = op.toFixed(3);
const ty = (1 - op) * 26;
scene.style.transform = `translate3d(0, ${ty}px, 0)`;
scene.style.visibility = op > 0.001 ? 'visible' : 'hidden';
scene.style.pointerEvents = op > 0.5 ? 'auto' : 'none';
}
// top scroll-progress bar reflects virtual progress
if (this.scrollBarEl) this.scrollBarEl.style.transform = `scaleX(${p})`;
// scroll hint
if (this.scrollHintEl) {
if (p > 0.02) this.scrollHintEl.classList.remove('show');
else this.scrollHintEl.classList.add('show');
}
// frame counter (1-based) — width matches total
if (this.frameCounterEl) {
const total = this.scrubber.frameCount;
const idx = clamp(Math.round(p * (total - 1)), 0, total - 1) + 1;
const w = String(total).length;
this.frameCounterEl.textContent = `${pad(idx, w)} / ${pad(total, w)}`;
}
}
_raf() {
this.scrubber.tick();
requestAnimationFrame(this._raf);
}
}
window.ParallaxPage = ParallaxPage;
window.FrameScrubber = FrameScrubber;
})();
/* ============================================================
Parallax Showcase — shared styles
Typography: Anton (uppercase dramatic) + Caveat (handwriting)
============================================================ */
:root {
--bg: #07070a;
--bg-2: #0f0f14;
--text: #f5f1ea; /* off-white, never #fff */
--muted: rgba(245, 241, 234, 0.62);
--accent: #ff4d6d;
--gold: #f5b042;
--amber: #ff8a3d; /* Hope — cheetah orange */
--cream: #f5e9d4;
--line: rgba(245, 241, 234, 0.18);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
background: var(--bg);
color: var(--text);
overflow-x: hidden;
scroll-behavior: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body { font-family: 'Inter', system-ui, sans-serif; }
/* Landing pages: lock scroll entirely. Scroll gesture is intercepted by JS
and converted into virtual frame progress — the document never moves. */
html.scrub-page,
body.scrub-page {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
overscroll-behavior: none;
touch-action: none;
margin: 0;
}
a { color: inherit; text-decoration: none; }
img { display: block; max-width: 100%; }
/* ---------- Nav ---------- */
.nav {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px clamp(20px, 4vw, 48px);
background: linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 100%);
pointer-events: none;
mix-blend-mode: normal;
}
.nav > * { pointer-events: auto; }
.nav-logo {
font-family: 'Anton', sans-serif;
font-size: 22px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text);
}
.nav-logo .slash {
font-family: 'Caveat', cursive;
font-size: 20px;
font-weight: 600;
opacity: 0.75;
margin-left: 10px;
color: var(--gold);
text-transform: none;
letter-spacing: 0;
}
.nav-links { display: flex; gap: clamp(16px, 2.5vw, 32px); align-items: center; }
.nav-links a {
font-family: 'Anton', sans-serif;
font-size: 13px;
letter-spacing: 0.22em;
text-transform: uppercase;
padding: 6px 2px;
position: relative;
color: var(--text);
opacity: 0.85;
transition: opacity 0.3s;
}
.nav-links a:hover { opacity: 1; }
.nav-links a::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: 0;
height: 1px;
background: var(--text);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.4s cubic-bezier(.6,.04,.27,1.06);
}
.nav-links a:hover::after,
.nav-links a.active::after { transform: scaleX(1); }
.nav-links a.active { opacity: 1; }
/* ---------- Scroll progress ---------- */
.scroll-progress {
position: fixed;
top: 0; left: 0; right: 0;
height: 2px;
z-index: 200;
background: rgba(255,255,255,0.06);
}
.scroll-progress-bar {
height: 100%;
width: 100%;
background: var(--text);
transform-origin: left center;
transform: scaleX(0);
will-change: transform;
}
/* ---------- Loader ---------- */
.loader {
position: fixed;
inset: 0;
z-index: 9999;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
transition: opacity 0.7s ease, visibility 0.7s;
}
.loader.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.loader-title {
font-family: 'Anton', sans-serif;
font-size: clamp(46px, 8vw, 96px);
letter-spacing: 0.04em;
text-transform: uppercase;
text-align: center;
line-height: 0.95;
}
.loader-script {
font-family: 'Caveat', cursive;
font-size: clamp(20px, 2.5vw, 30px);
color: var(--gold);
margin-top: -10px;
}
.loader-bar {
width: min(420px, 70vw);
height: 2px;
background: rgba(255,255,255,0.12);
overflow: hidden;
position: relative;
}
.loader-bar-fill {
position: absolute;
inset: 0 100% 0 0;
background: var(--text);
transition: right 0.15s linear;
}
.loader-progress {
font-family: 'Anton', sans-serif;
font-size: 13px;
letter-spacing: 0.42em;
opacity: 0.7;
}
/* ---------- Scrub section ---------- */
/* Single locked hero: 100vh exactly. The "scroll" is virtual, handled in JS. */
.scrub {
position: relative;
height: 100vh;
width: 100%;
overflow: hidden;
background: #000;
}
.scrub-pin {
position: absolute;
inset: 0;
overflow: hidden;
background: #000;
}
.bg-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.vignette {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(ellipse at 50% 45%, rgba(0,0,0,0) 38%, rgba(0,0,0,0.55) 100%),
linear-gradient(180deg, rgba(0,0,0,0.45) 0%, rgba(0,0,0,0.05) 28%, rgba(0,0,0,0.08) 65%, rgba(0,0,0,0.6) 100%);
}
.grain {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.07;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='1'/></svg>");
background-size: 180px 180px;
}
.scene-stack {
position: absolute;
inset: 0;
pointer-events: none;
}
.scene { pointer-events: none; } /* JS flips to 'auto' on the visible scene */
.scene a, .scene button { pointer-events: auto; }
/* CTA cluster inside a scene (final scene) */
.scene .end-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: clamp(28px, 4vh, 44px);
justify-content: center;
}
.scene.align-left .end-actions { justify-content: flex-start; }
.scene.align-right .end-actions { justify-content: flex-end; }
.scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 80px 6vw;
opacity: 0;
will-change: opacity, transform;
}
.scene.align-left { justify-content: flex-start; text-align: left; padding-left: clamp(28px, 8vw, 120px); }
.scene.align-right { justify-content: flex-end; text-align: right; padding-right: clamp(28px, 8vw, 120px); }
.scene.align-bottom { align-items: flex-end; padding-bottom: clamp(60px, 12vh, 140px); }
.scene.align-top { align-items: flex-start; padding-top: clamp(120px, 16vh, 180px); }
.scene-inner { max-width: 1100px; }
.kicker {
display: inline-block;
font-family: 'Anton', sans-serif;
text-transform: uppercase;
letter-spacing: 0.42em;
font-size: 12px;
color: var(--text);
padding: 8px 16px 7px;
border: 1px solid rgba(245,241,234,0.32);
margin-bottom: 28px;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
background: rgba(0,0,0,0.12);
}
.huge {
font-family: 'Anton', sans-serif;
font-size: clamp(58px, 12.5vw, 200px);
line-height: 0.9;
text-transform: uppercase;
letter-spacing: -0.005em;
text-shadow: 0 8px 36px rgba(0,0,0,0.6);
}
.huge.smaller { font-size: clamp(48px, 9vw, 140px); }
.script {
font-family: 'Caveat', cursive;
font-size: clamp(26px, 4.2vw, 56px);
line-height: 1.15;
margin-top: 22px;
color: var(--gold);
text-shadow: 0 4px 22px rgba(0,0,0,0.65);
}
.script.cream { color: var(--cream); }
.script.accent { color: var(--accent); }
.script.amber { color: var(--amber); }
.script.small { font-size: clamp(22px, 2.8vw, 36px); }
/* per-scene specifics */
.scene .lead {
font-family: 'Inter', sans-serif;
font-weight: 300;
font-size: clamp(15px, 1.2vw, 19px);
line-height: 1.55;
margin-top: 26px;
max-width: 600px;
margin-inline: auto;
opacity: 0.85;
text-shadow: 0 2px 12px rgba(0,0,0,0.7);
}
.scene.align-left .lead,
.scene.align-right .lead { margin-inline: 0; }
/* scroll hint */
.scroll-hint {
position: absolute;
bottom: 26px;
left: 50%;
transform: translateX(-50%);
font-family: 'Anton', sans-serif;
font-size: 11px;
letter-spacing: 0.55em;
opacity: 0;
pointer-events: none;
text-transform: uppercase;
animation: bob 2.2s ease-in-out infinite;
transition: opacity 0.4s;
}
.scroll-hint.show { opacity: 0.7; }
.scroll-hint::after {
content: '';
display: block;
width: 1px;
height: 28px;
margin: 10px auto 0;
background: var(--text);
opacity: 0.6;
}
@keyframes bob {
0%, 100% { transform: translate(-50%, 0); }
50% { transform: translate(-50%, 8px); }
}
/* frame counter (subtle, bottom corner) */
.frame-counter {
position: absolute;
bottom: 22px;
right: 26px;
font-family: 'Anton', sans-serif;
font-size: 11px;
letter-spacing: 0.4em;
opacity: 0.55;
text-transform: uppercase;
pointer-events: none;
}
/* ---------- End / CTA section ---------- */
.end {
position: relative;
min-height: 90vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 120px 6vw 100px;
background: var(--bg);
overflow: hidden;
}
.end::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 50% 0%, rgba(245,176,66,0.08), transparent 60%);
pointer-events: none;
}
.end-inner { max-width: 980px; position: relative; }
.end h2 {
font-family: 'Anton', sans-serif;
font-size: clamp(44px, 7vw, 110px);
text-transform: uppercase;
letter-spacing: -0.005em;
line-height: 0.92;
margin-bottom: 18px;
}
.end .script { margin-bottom: 44px; }
.end-actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
justify-content: center;
}
.cta {
display: inline-flex;
align-items: center;
gap: 10px;
font-family: 'Anton', sans-serif;
text-transform: uppercase;
letter-spacing: 0.28em;
font-size: 13px;
padding: 18px 30px;
border: 1.5px solid var(--text);
color: var(--text);
background: transparent;
transition: background 0.35s, color 0.35s, transform 0.2s;
cursor: pointer;
}
.cta:hover { background: var(--text); color: var(--bg); transform: translateY(-2px); }
.cta.solid { background: var(--text); color: var(--bg); }
.cta.solid:hover { background: transparent; color: var(--text); }
.cta.gold { border-color: var(--gold); color: var(--gold); }
.cta.gold:hover { background: var(--gold); color: var(--bg); }
.cta.amber { border-color: var(--amber); color: var(--amber); }
.cta.amber:hover { background: var(--amber); color: var(--bg); }
/* footer */
.footer {
padding: 36px 6vw 28px;
border-top: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
letter-spacing: 0.2em;
text-transform: uppercase;
font-family: 'Anton', sans-serif;
opacity: 0.75;
}
.footer .script {
font-family: 'Caveat', cursive;
text-transform: none;
letter-spacing: 0;
opacity: 0.85;
color: var(--gold);
margin: 0;
font-size: 22px;
text-shadow: none;
}
/* ---------- Hub (index) ---------- */
.hub-wrap {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.hub-main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 130px 5vw 60px;
text-align: center;
position: relative;
}
.hub-main::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 20% 20%, rgba(255,77,109,0.10), transparent 50%),
radial-gradient(ellipse at 80% 80%, rgba(245,176,66,0.10), transparent 55%);
pointer-events: none;
}
.hub-eyebrow {
font-family: 'Anton', sans-serif;
letter-spacing: 0.55em;
font-size: 12px;
text-transform: uppercase;
opacity: 0.75;
margin-bottom: 28px;
position: relative;
}
.hub-title {
font-family: 'Anton', sans-serif;
font-size: clamp(56px, 11vw, 180px);
line-height: 0.9;
text-transform: uppercase;
margin-bottom: 24px;
position: relative;
}
.hub-title .script {
display: block;
font-family: 'Caveat', cursive;
font-size: clamp(28px, 5vw, 64px);
text-transform: none;
color: var(--gold);
margin-top: 14px;
letter-spacing: 0;
text-shadow: none;
}
.hub-lead {
font-family: 'Inter', sans-serif;
font-weight: 300;
font-size: clamp(15px, 1.2vw, 18px);
line-height: 1.6;
max-width: 620px;
opacity: 0.78;
margin: 0 auto 60px;
position: relative;
}
.hub-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 22px;
width: min(1600px, 100%);
position: relative;
}
@media (max-width: 1100px) {
.hub-cards { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 720px) {
.hub-cards { grid-template-columns: 1fr; }
}
.hub-card {
position: relative;
overflow: hidden;
display: block;
height: 64vh;
min-height: 380px;
isolation: isolate;
}
.hub-card-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
transition: transform 1s cubic-bezier(.2,.6,.2,1);
z-index: 1;
}
.hub-card:hover .hub-card-bg { transform: scale(1.08); }
.hub-card::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.4) 50%, rgba(0,0,0,0.82) 100%);
z-index: 2;
}
.hub-card-content {
position: absolute;
inset: auto 0 0 0;
z-index: 3;
padding: clamp(24px, 4vw, 44px);
text-align: left;
}
.hub-card-content .kicker {
margin-bottom: 14px;
border: 0;
padding: 0;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.hub-card-content h2 {
font-family: 'Anton', sans-serif;
font-size: clamp(44px, 6.6vw, 92px);
line-height: 0.9;
margin-bottom: 10px;
text-transform: uppercase;
}
.hub-card-content .script {
margin: 0;
color: var(--gold);
font-size: clamp(22px, 2.6vw, 36px);
text-shadow: 0 2px 14px rgba(0,0,0,0.6);
}
.hub-card-arrow {
position: absolute;
top: clamp(24px, 4vw, 44px);
right: clamp(24px, 4vw, 44px);
z-index: 3;
font-family: 'Anton', sans-serif;
font-size: 14px;
letter-spacing: 0.3em;
text-transform: uppercase;
padding: 10px 14px;
border: 1px solid rgba(255,255,255,0.4);
transition: background 0.35s, color 0.35s;
}
.hub-card:hover .hub-card-arrow { background: var(--text); color: var(--bg); border-color: var(--text); }
/* color accent dot on cards */
.hub-card .dot {
position: absolute;
top: clamp(24px, 4vw, 44px);
left: clamp(24px, 4vw, 44px);
z-index: 3;
width: 10px; height: 10px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px rgba(255,77,109,0.18);
}
.hub-card.lion .dot { background: var(--gold); box-shadow: 0 0 0 4px rgba(245,176,66,0.18); }
.hub-card.hope .dot { background: var(--amber); box-shadow: 0 0 0 4px rgba(255,138,61,0.18); }
/* reduce motion */
@media (prefers-reduced-motion: reduce) {
.scroll-hint { animation: none; }
.hub-card-bg { transition: none; }
}
/* Small screens */
@media (max-width: 760px) {
.nav-logo .slash { display: none; }
.nav { padding: 16px 18px; }
.nav-links { gap: 18px; }
}
Copy Guide — writing the five scenes
The animation is half the work. The copy is the other half. Generic copy will sink an otherwise beautiful landing. Read this before you write a single word of headline.
The structural rule
Each of the 5 scenes is exactly three lines:
[KICKER] ← 1–3 words, all caps, Anton, letter-spaced
[HEADLINE] ← 2–4 words, broken across 2 lines with <br>
[HANDWRITTEN LINE] ← 4–8 words, one full sentence, ends with a soft punctuationThe kicker frames the moment ("Chapter 02", "— A True Story —", "01 — Workflow"). The headline is the punch. The handwritten line is the emotional follow-through — the line that earns the trust the headline asked for.
Length matters because the typography is huge. A 6-word headline at 200px breaks the layout. Keep it tight.
The five-beat arc
This structure is borrowed from short documentary editing — you have ~25 seconds of attention; spend each beat well.
| Scene | Beat | Job |
|---|---|---|
| 1 | Hook | Introduce the subject. Name it. State its essence in one phrase. |
| 2 | Origin | Where does it come from? What is its world? |
| 3 | Stakes | Why does it matter? Where does the story take place? |
| 4 | Moment | The personal turn — the speaker's act, the decision, the proximity. |
| 5 | Resolution + CTA | What happened. Then the call to action. |
You're not writing about a product. You're writing about a moment that includes a product.
Voice rules
- Anton headlines are commands or noun-phrases, never sentences. "Born to Run." "Built for Flow." "Meet Marcus." Not "He was born to run." The handwritten line is where sentences live.
- Handwritten lines are colloquial, almost spoken. "the white lion of the Negev." "an AI that finally ships with you." "and knelt with food in my hands." Lowercase first word; trail off with a period (or
…only for the loader script). - Avoid adjectives in the headline. The image already shows the adjective. "Beautiful Cheetah" is wasted ink. "Born to Run" lets the image do the work.
- Specifics beat abstractions. "Be'er Sheva" beats "the desert". "macOS · Windows · Linux" beats "every platform". "70 mph" beats "fast".
Three real examples — read these carefully
GitHub Desktop (product)
SCENE 1
— A NEW CHAPTER —
GitHub
Desktop
the new build. native. fast. yours.
SCENE 2
01 — WORKFLOW
Where
Code Lives.
branches, PRs, reviews — at your fingertips.
SCENE 3
02 — PERFORMANCE
Built
For Flow.
native speed. zero friction. all platforms.
SCENE 4
03 — INTELLIGENCE
Copilot
Inside.
an AI that finally ships with you.
SCENE 5 (CTA)
— NOW AVAILABLE —
Ready?
macOS · Windows · Linux.
[ Download for macOS ] [ Windows ] [ Linux ] [ Next: Marcus → ]Marcus the white lion (personal moment, wildlife)
SCENE 1
— A TRUE STORY —
Meet
Marcus.
the white lion of the Negev.
SCENE 2
CHAPTER 01
Born
of the Desert.
a coat like snow over a land that burns.
SCENE 3
CHAPTER 02
Midbarium.
Be'er Sheva.
where wilderness meets wonder.
SCENE 4
CHAPTER 03
I Crossed
the Fence.
and knelt with food in my hands.
SCENE 5
— THE LAST FRAME —
And the
King Ate.
a moment frozen in time.
[ Visit Midbarium ] [ Open in Maps ] [ Next: Hope → ] [ ← Back ]Hope the cheetah (speed, motion, intimacy)
SCENE 1
— A TRUE STORY —
Meet
Hope.
the fastest heart in the Negev.
SCENE 2
CHAPTER 01
Born
to Run.
seventy miles an hour of wild grace.
SCENE 3
CHAPTER 02
Midbarium.
Be'er Sheva.
where the desert opens — she answers.
SCENE 4
CHAPTER 03
I Came
Close.
she looked at me — and let me stay.
SCENE 5
— THE LAST FRAME —
A Second
of Stillness.
with the fastest cat alive.
[ Visit Midbarium ] [ Open in Maps ] [ ← Back to showcase ]The accent color picks a tone
gold(#f5b042) — warmth, reverence, mythic ("Marcus")amber(#ff8a3d) — heat, motion, vitality ("Hope")accent(pink #ff4d6d) — product/tech, modern, slightly playful ("GitHub")cream(#f5e9d4) — neutral, used inside scenes 2 and 4 for variety so every line isn't the same color
The convention in the references: scenes 1, 3, 5 use the page's signature accent; scenes 2 and 4 use cream for visual rhythm. Follow that unless you have a reason not to.
Loader script tone
The loader sits on screen for about 1–3 seconds while frames preload. The handwritten line beneath the giant title should be a tiny hook — present-tense, ending in ….
Good examples (from the reference):
loading the new build…summoning the king of the Negev…summoning the runner…
Bad: Please wait while frames load. (boring, breaks tone)
Scroll hint tone
Short. Uppercase. 2–4 words. Often a verb tied to the subject:
SCROLL TO PLAY(GitHub — software metaphor)SCROLL TO ENTER(Marcus — entering a kingdom)SCROLL TO RUN(Hope — running with the cheetah)
If you can't find a verb that suits the subject, fall back to SCROLL TO BEGIN.
CTAs in the final scene
2–4 CTAs, the first one solid or accent-colored, the rest outlined. Keep them short — 2–4 words each. End with a chain link (Next: X →) if this is part of a showcase, and a back-to-hub link (← Back to showcase).
Available CTA classes:
cta— outlined off-white (default)cta solid— filled off-white on bgcta gold— outlined gold (good for Midbarium / nature subjects)cta amber— outlined amber (Hope-style)
If introducing a new accent, add .cta.<name> rules to style.css in the same pattern as .cta.gold.
Showcase Integration — adding a new landing to an existing project
Use this when the target folder already has an index.html (hub) and at least one other landing (e.g. github.html + lion.html). You're not creating from scratch — you're growing what's there.
There are four edits, all small. Do them in this order so the user can preview after each step if they want.
1. Add a card to index.html
Inside <div class="hub-cards">, add a new <a class="hub-card {{slug}}"> block alongside the existing cards. The grid is already repeat(3, 1fr) and collapses gracefully — you don't need to touch it.
Pattern (copy and adapt):
<a href="{{slug}}.html" class="hub-card {{slug}}">
<div class="hub-card-bg" style="background-image: url('{{slug}}/frame-{{mid}}.jpg');"></div>
<span class="dot"></span>
<span class="hub-card-arrow">Enter →</span>
<div class="hub-card-content">
<span class="kicker">{{NN}} — {{CATEGORY}}</span>
<h2>{{HEADLINE_LINE_1}}<br />{{HEADLINE_LINE_2}}</h2>
<p class="script" style="color: var(--{{accent}});">{{HANDWRITTEN_LINE}}</p>
</div>
</a>Notes:
{{mid}}is the middle frame number (e.g.121for a 241-frame clip) — gives the card a more visually arresting still than frame-001.{{NN}}is the position in the showcase:01,02,03, etc. Match the order they appear.{{CATEGORY}}is a one-word taxonomy (Product, Wild, Speed, Story, Field, etc.).- The
style="color: var(--{{accent}});"on the handwritten line ties the card to its landing page's accent color.
Also bump the hub copy to reflect the new count:
<div class="hub-eyebrow">{{N}} Stories · One Scroll</div><span class="script">{{n}} short films, one page each.</span>Spell N as a word ("Three", "Four", "Five") to match the existing typography rhythm.
Add the preload link in <head>:
<link rel="preload" as="image" href="{{slug}}/frame-{{mid}}.jpg" />2. Add a dot accent style to style.css
If the new landing uses an existing accent color (gold, amber), reuse the dot rule. If it's a new color, add both the variable and the dot rule.
Add the color to :root (next to the existing --accent, --gold, --amber):
--{{newcolor}}: #HEXVAL;Add the dot rule right after the existing .hub-card.lion .dot / .hub-card.hope .dot lines:
.hub-card.{{slug}} .dot {
background: var(--{{newcolor}});
box-shadow: 0 0 0 4px rgba(R, G, B, 0.18);
}The RGB triplet should match the hex. (E.g., #ff8a3d → 255, 138, 61.)
And if you want the handwritten lines on that landing to use this color, also add:
.script.{{newcolor}} { color: var(--{{newcolor}}); }
.cta.{{newcolor}} { border-color: var(--{{newcolor}}); color: var(--{{newcolor}}); }
.cta.{{newcolor}}:hover { background: var(--{{newcolor}}); color: var(--bg); }3. Add the new landing to every existing nav
In index.html, github.html, lion.html, and any other landing, find the .nav-links block and add:
<a href="{{slug}}.html">{{Title}}</a>On the new landing's own page, the link gets class="active". On every other page, no class.
Maintain a stable order across all pages — easiest is alphabetical or "by show-order" (i.e. matching the hub card order). Either is fine; just be consistent so the nav doesn't jitter as the user clicks around.
4. Chain the previous landing's final scene
In the immediately previous landing (the one with the highest existing number), update the end-actions block inside its scene 5 to add a chain link to the new landing.
Before:
<div class="end-actions">
<a class="cta gold" href="...">Visit Midbarium</a>
<a class="cta" href="...">Open in Maps</a>
<a class="cta" href="index.html">← Back to showcase</a>
</div>After:
<div class="end-actions">
<a class="cta gold" href="...">Visit Midbarium</a>
<a class="cta" href="...">Open in Maps</a>
<a class="cta" href="{{slug}}.html">Next: {{Title}} →</a>
<a class="cta" href="index.html">← Back to showcase</a>
</div>On the new landing's own scene 5 CTA block, end with ← Back to showcase and (if appropriate) a chain forward to whatever you'd want next.
Verification checklist
After all four edits, the user should be able to:
1. Open index.html → see N cards in the grid, all with the right backgrounds and accent dots. 2. Click each card → land on the right page, with the loader title matching. 3. Use the nav from any page → reach any other page; the active page is underlined. 4. Scroll to the end of any non-final landing → see a Next → CTA pointing to a real page. 5. Scroll to the end of the new landing → see the back-to-showcase CTA.
If any of those breaks, you missed an edit. Re-check nav-links parity across files.
Common slips to avoid
- Forgetting to add the slug to the preload list in index.html. Cards without a preload still load, just a beat later.
- Mixing accent colors on a single card. The card BG accent (dot + script color) should match the landing's interior accent — otherwise the hub feels disconnected from the page.
- Reusing a frame number that's the wrong padding. Always pad to 3 digits (
frame-073.jpg, notframe-73.jpg). - Forgetting to set `class="active"` on the current page's nav link. Subtle but breaks the "where am I" affordance.
- Adding a new accent variable but forgetting the `.script.<name>` and `.cta.<name>` rules. The page renders but the accent never appears anywhere except the dot.
#!/usr/bin/env python3
"""
extract_frames.py — Probe a video, extract every frame at near-lossless
JPEG quality into <output_folder>/frame-NNN.jpg, and print a JSON
metadata block to stdout for the page generator to consume.
Usage:
python extract_frames.py <video_path> <output_folder>
[--target-px-per-frame N] # default 28; used to derive scrollBudget
[--padding N] # default 3; zero-pad width for frame index
Requires `ffmpeg` and `ffprobe` on PATH.
The output JSON looks like:
{
"slug": "hope",
"video_path": "C:/.../hope.mp4",
"output_folder": "C:/.../hope",
"frame_count": 241,
"width": 1280,
"height": 720,
"fps": 24.0,
"duration_seconds": 10.04,
"suggested_scroll_budget": 6300,
"padding": 3,
"frame_naming": "frame-001.jpg .. frame-241.jpg",
"preview_frames": {
"first": "frame-001.jpg",
"mid": "frame-121.jpg",
"last": "frame-241.jpg"
},
"total_size_bytes": 53842117
}
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
def die(msg: str, code: int = 1) -> None:
print(json.dumps({"error": msg}), file=sys.stderr)
sys.exit(code)
def require(tool: str) -> str:
path = shutil.which(tool)
if not path:
die(f"{tool!r} not found on PATH. Install ffmpeg (which ships with ffprobe) and retry.")
return path
def probe(video_path: Path) -> dict:
"""ffprobe → dict of width, height, fps, duration, nb_frames."""
cmd = [
"ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height,r_frame_rate,nb_frames,duration",
"-of", "json",
str(video_path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
die(f"ffprobe failed: {result.stderr.strip()}")
data = json.loads(result.stdout)
streams = data.get("streams") or []
if not streams:
die(f"No video stream found in {video_path}")
s = streams[0]
fps_num, _, fps_den = s.get("r_frame_rate", "0/1").partition("/")
try:
fps = float(fps_num) / float(fps_den) if fps_den else float(fps_num)
except (ValueError, ZeroDivisionError):
fps = 0.0
try:
duration = float(s.get("duration", 0.0))
except (TypeError, ValueError):
duration = 0.0
nb_frames_raw = s.get("nb_frames")
nb_frames = int(nb_frames_raw) if nb_frames_raw and nb_frames_raw.isdigit() else None
return {
"width": int(s.get("width", 0)),
"height": int(s.get("height", 0)),
"fps": fps,
"duration": duration,
"nb_frames_hint": nb_frames, # may be None — confirm by counting after extract
}
def extract(video_path: Path, output_folder: Path, padding: int) -> int:
"""Run ffmpeg, return the count of extracted frame-*.jpg files."""
output_folder.mkdir(parents=True, exist_ok=True)
# Clear any prior frame-*.jpg so a re-run is deterministic.
for old in output_folder.glob("frame-*.jpg"):
try:
old.unlink()
except OSError:
pass
pattern = output_folder / f"frame-%0{padding}d.jpg"
cmd = [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", str(video_path),
"-q:v", "2", # near-lossless JPEG (1=best, 31=worst)
"-start_number", "1",
str(pattern),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
die(f"ffmpeg failed: {result.stderr.strip()}")
frames = sorted(output_folder.glob("frame-*.jpg"))
if not frames:
die("ffmpeg produced no frames — was the video readable?")
return len(frames)
def scroll_budget(frame_count: int, px_per_frame: int) -> int:
"""Clamp to [2500, 8000] to keep both very-short and very-long clips usable."""
raw = frame_count * px_per_frame
return max(2500, min(8000, raw))
def total_size(folder: Path) -> int:
return sum(f.stat().st_size for f in folder.glob("frame-*.jpg"))
def main() -> None:
ap = argparse.ArgumentParser(description="Extract HD frames from a video for a parallax landing page.")
ap.add_argument("video", type=Path, help="Path to source video (mp4, mov, mkv, etc.)")
ap.add_argument("output", type=Path, help="Folder to write frame-NNN.jpg into (created if missing)")
ap.add_argument("--target-px-per-frame", type=int, default=28,
help="Pixels of scroll per frame; used to derive scrollBudget. Default 28.")
ap.add_argument("--padding", type=int, default=3,
help="Zero-padding width for frame index. Default 3.")
args = ap.parse_args()
require("ffmpeg")
require("ffprobe")
video_path: Path = args.video.resolve()
if not video_path.is_file():
die(f"Video not found: {video_path}")
output_folder: Path = args.output.resolve()
info = probe(video_path)
frame_count = extract(video_path, output_folder, args.padding)
sb = scroll_budget(frame_count, args.target_px_per_frame)
pad = args.padding
last = frame_count
mid = max(1, (frame_count + 1) // 2)
out = {
"slug": output_folder.name,
"video_path": str(video_path).replace("\\", "/"),
"output_folder": str(output_folder).replace("\\", "/"),
"frame_count": frame_count,
"width": info["width"],
"height": info["height"],
"fps": round(info["fps"], 3),
"duration_seconds": round(info["duration"], 3),
"suggested_scroll_budget": sb,
"padding": pad,
"frame_naming": f"frame-{str(1).zfill(pad)}.jpg .. frame-{str(last).zfill(pad)}.jpg",
"preview_frames": {
"first": f"frame-{str(1).zfill(pad)}.jpg",
"mid": f"frame-{str(mid).zfill(pad)}.jpg",
"last": f"frame-{str(last).zfill(pad)}.jpg",
},
"total_size_bytes": total_size(output_folder),
"notes": (
"JPEGs at q:v 2 (near-lossless). Source dimensions preserved — no up/downscale. "
"If the result is heavy and you don't need maximum sharpness, re-run a "
"downstream `mogrify -quality 85 frame-*.jpg` or re-extract with -q:v 3."
),
}
print(json.dumps(out, indent=2))
if __name__ == "__main__":
main()