# Creating Oneshot Landing Pages

> Designs and builds video-driven parallax hero landing pages with smooth video scrubbing, progress-locked typography, and clean navigation. Powered by Gemini Omni and Nano Banana.

- Skill: `nroadley/creating-oneshot-landing-pages` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add nroadley/creating-oneshot-landing-pages`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nroadley/creating-oneshot-landing-pages/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Design & Media
- Author: nroadley (https://skillmd.com/u/nroadley)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/nroadley/creating-oneshot-landing-pages

---


# Creating Oneshot Hero Landing Pages

An Antigravity skill to design and build video-driven parallax hero landing pages and looping motion showcases.

---

## 1. 4-Step Execution Pipeline

Execute every landing page request in this order:

1. **Step 1: Reference Images**: Generate reference images via `generate_image` (the built-in Antigravity tool powered by Nano Banana / `gemini-3.1-flash-image`) and save them into `./assets/` (e.g. `./assets/hero_ref.jpg`, `./assets/showcase_ref.jpg`).
2. **Step 2: Video Generation**: Execute Python REST calls against `gemini-omni-flash-preview` (Section 3) using the reference images to generate 10s 16:9 `.mp4` video files into `./assets/`.
3. **Step 3: Frontend Implementation**: Implement the **Hero Video Scrub Scaffold** (Section 4) in `index.html` with smooth scroll video scrubbing, progress-locked hero text, and clean navigation.
4. **Step 4: Verification & Local Hosting**: Launch a local preview server (`python3 -m http.server 3000 --directory <dir>`), verify all video assets return `200 OK`, and inspect the visual layout.

---

## 2. Design Guidelines

- **Navigation Guidelines**:
  - **Pick ONE Focused Composition**:
    - `[Logo]` + `[2–4 Editorial Links Max]` (strictly <= 4 links)
    - `[Logo]` + `[Menu / Index Trigger]`
    - `[Logo]` + `[Brand Tagline or Location]` + `[Menu]`
    - `[Logo]` + `[Single Quiet CTA]`
    - `[Split Nav]` with `[2 Links]` Left, `[Logo]` Center, `[2 Links or 1 CTA]` Right
  - **Keep It Simple**: If you have 2–4 inline links, do not stack a heavy CTA button and hamburger menu next to them. If using a drawer, keep the bar quiet.
  - **No Mechanical Numbering or Fake Tickers**: Do not prefix mechanical numbers (`01`, `02`, `03`) onto nav links. Do not add fake GPS coordinates, sensor readings, or status dots (`● LIVE`).
- **No Decorative SVG Icons on Cards or UI**:
  - Do not place decorative SVG icons, Lucide icons (`<i data-lucide=...>`), or icon badge containers inside feature cards, headings, bullet points, or buttons.
  - Cards, pillars, and sections should rely on typography, whitespace, and photography/video. (SVGs are fine for a logo mark or modal close button).
- **No Toy Widgets, Interactive Simulators, or Mini-Games**:
  - Landing pages are editorial showcases.
  - Do not generate character stat calculators, RPG inventory boxes, physics toy widgets, interactive sandbox cards, or `canvas-confetti` particle scripts.
  - For playful brands or games, express personality through **color palettes, typography, and video motion** rather than playable JavaScript mini-games or calculators.
- **No Unrequested Audio Buttons or Sound Synthesizers**:
  - Do not generate audio toggle buttons (`🔊`), background sound generators, or Web Audio API synthesizers (`AudioContext`, oscillators).
- **Clean Typography Over Badges**:
  - Default to unbordered typography, subtle font weights, fine line accents (`—`), or roman numerals (`I`, `II`) instead of encasing words inside rounded pill badges (`[ BADGE ]`).
  - When a tag or table cell is necessary:
    - **1–2 Words Maximum**: Do not put long descriptions or sentences into compact badges (e.g. use `LEO`, not `LEO Station Rendezvous`).
    - **Include `whitespace-nowrap`**: Keep badge text on a single line so it never breaks into awkward stacks.
    - **Single-Row Filter Bars**: Filter buttons should stay on a single unbroken row (`flex items-center gap-2 overflow-x-auto whitespace-nowrap`).
    - **Responsive Tables**: Data tables should use responsive scroll wrappers (`overflow-x-auto min-w-[650px]`).
- **Media & Hero Video Rules**:
  - The hero must use a `<video id="hero-video">` element. Do not substitute video with `<canvas>` image slideshows or static `<img>` tags.
  - Supplemental showcases generated by Omni should be looping `<video autoplay muted loop playsinline>` elements.
  - **No Visible Scrubber HUDs**: Do not render timeline scrub bars, progress percentage meters (`"00%"`), or cockpit dials over media.
- **Progress-Locked Hero Text**:
  - Calculate hero text opacity and translation directly from scroll progress in JavaScript (see Section 4). Scrolling reveals text, pausing keeps it readable, reversing hides it.
  - Do not use time-based CSS transitions (`transition: opacity 0.7s`) on hero scroll text that cause text to lag or scroll past unread. (CSS animations are fine in standard body sections).
- **Layout & Stacking Rules**:
  - Root containers (`html`, `body`, `#root`, wrappers) must have `overflow: visible` (or `overflow-x: clip`). Do not use `overflow-hidden` or `overflow-x-hidden` on parent wrappers, which breaks CSS `position: sticky`.
  - Hero videos should stretch edge-to-edge (`w-full h-full object-cover`), not styled as small rounded UI cards.
- **Typography & Color**:
  - Use modern Google Fonts (e.g. *Italiana*, *Cormorant Garamond*, *Plus Jakarta Sans*, *Space Grotesk*).
  - Use curated color palettes (e.g. terracotta, basalt, obsidian, titanium, alabaster) rather than default generic rainbow colors.

---

## 3. Omni Video Python Generator

Install dependencies:
```bash
pip install google-genai pillow
```

Save generated `.mp4` files directly to `./assets/` using this script:

```python
import base64
import io
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
from PIL import Image

def generate_omni_video(
    api_key: str,
    img_path: str,
    prompt: str,
    output_mp4_path: str
) -> bool:
    ctx = ssl.create_default_context()
    url = "https://generativelanguage.googleapis.com/v1beta/interactions"
    
    try:
        img = Image.open(img_path).convert("RGB")
        buf = io.BytesIO()
        img.save(buf, format="PNG")
        b64_img = base64.b64encode(buf.getvalue()).decode("utf-8")
    except Exception as e:
        print(f"Error opening image '{img_path}': {e}", file=sys.stderr)
        return False

    payload = {
        "model": "models/gemini-omni-flash-preview",
        "generation_config": {"thinking_level": "high"},
        "response_format": {"type": "video", "aspect_ratio": "16:9", "duration": "10s"},
        "input": [
            {"type": "text", "text": prompt + " No text, no titles, no subtitles, no overlays."},
            {"type": "image", "mime_type": "image/png", "data": b64_img}
        ]
    }
    
    req = urllib.request.Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers={
            "Content-Type": "application/json",
            "x-goog-api-key": api_key
        }
    )
    
    try:
        with urllib.request.urlopen(req, context=ctx) as response:
            res = json.loads(response.read().decode("utf-8"))
            for step in res.get("steps", []):
                for item in step.get("content", []):
                    if item.get("type") == "video" or "video" in str(item.get("mime_type")):
                        os.makedirs(os.path.dirname(output_mp4_path) or ".", exist_ok=True)
                        with open(output_mp4_path, "wb") as f:
                            f.write(base64.b64decode(item["data"]))
                        return True
            print("No video data found in response payload.", file=sys.stderr)
            return False
    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8", errors="replace")
        print(f"API HTTP Error {e.code} ({e.reason}): {error_body}", file=sys.stderr)
        return False
    except urllib.error.URLError as e:
        print(f"Network / URL Error: {e.reason}", file=sys.stderr)
        return False
    except Exception as e:
        print(f"Unexpected error during video generation: {e}", file=sys.stderr)
        return False
```

---

## 4. Hero Video Scrub Scaffold (HTML + CSS + JS)

Use this architecture for the sticky 400vh video scrubbing engine:

```html
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Brand — Hero Showcase</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <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=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,400&family=Plus+Jakarta+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
  <style>
    html, body {
      margin: 0; padding: 0;
      overflow-x: clip; /* Clean clipping without breaking position:sticky */
      background: #08090d; color: #f7f5f0;
      font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
    }
    .phase-text { will-change: opacity, transform; }
    header.nav-scrolled {
      background: rgba(8, 9, 13, 0.85);
      backdrop-filter: blur(16px);
      border-bottom: 1px solid rgba(255, 255, 255, 0.08);
      padding-top: 1rem; padding-bottom: 1rem;
    }
  </style>
</head>
<body class="relative antialiased">
  <!-- Navigation goes here -->
  
  <!-- 400vh Sticky Scroll Hero Video Engine -->
  <div id="hero-section" class="relative h-[400vh] w-full">
    <div class="sticky top-0 w-full h-screen overflow-hidden flex items-center justify-center">
      <video id="hero-video" class="absolute inset-0 w-full h-full object-cover pointer-events-none" playsinline muted preload="auto"></video>
      <div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/30 pointer-events-none"></div>
      
      <!-- Phase 0 Typography (0% - 25% Scroll) -->
      <div id="phase-0" class="phase-text absolute bottom-20 left-8 md:left-20 max-w-xl pointer-events-none">
        <h1 class="font-serif text-5xl md:text-7xl font-light text-white leading-tight">First Headline Statement.</h1>
      </div>
      <!-- Phase 1 Typography (30% - 60% Scroll) -->
      <div id="phase-1" class="phase-text absolute top-1/3 right-8 md:right-24 max-w-lg pointer-events-none opacity-0">
        <h2 class="font-serif text-4xl md:text-5xl font-light text-white leading-snug">Second Narrative Phase.</h2>
      </div>
      <!-- Phase 2 Typography (65% - 95% Scroll) -->
      <div id="phase-2" class="phase-text absolute bottom-24 left-8 md:left-20 max-w-lg pointer-events-none opacity-0">
        <h2 class="font-serif text-4xl md:text-5xl font-light text-white leading-snug">Third Concluding Statement.</h2>
      </div>
    </div>
  </div>

  <!-- Supplemental Content & Looping Showcases -->
  <section class="py-32 px-8 md:px-20 max-w-7xl mx-auto">
    <!-- Body Content Here -->
  </section>

  <!-- Video Scrubbing Engine & Progress-Locked Text -->
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      const heroVideo = document.getElementById('hero-video');
      const heroSection = document.getElementById('hero-section');
      const nav = document.querySelector('header');
      const phase0 = document.getElementById('phase-0');
      const phase1 = document.getElementById('phase-1');
      const phase2 = document.getElementById('phase-2');

      // 1. In-Memory Blob Preloader with explicit video.load()
      fetch('./assets/hero.mp4')
        .then(res => {
          if (!res.ok) throw new Error('Video network response was not ok');
          return res.blob();
        })
        .then(blob => {
          heroVideo.src = URL.createObjectURL(blob);
          heroVideo.load();
        })
        .catch(() => {
          heroVideo.src = './assets/hero.mp4';
          heroVideo.load();
        });

      // 2. Normalized Scroll Progress
      let targetProgress = 0, currentProgress = 0;
      function updateScroll() {
        const rect = heroSection.getBoundingClientRect();
        const max = rect.height - window.innerHeight;
        if (max > 0) targetProgress = Math.max(0, Math.min(1, -rect.top / max));
        if (nav) {
          if (window.scrollY > 80) nav.classList.add('nav-scrolled');
          else nav.classList.remove('nav-scrolled');
        }
      }
      window.addEventListener('scroll', updateScroll, { passive: true });
      window.addEventListener('resize', updateScroll);

      // 3. Progress-Locked Text Opacity Calculator
      function calcOpacity(progress, enterStart, enterEnd, exitStart, exitEnd) {
        if (progress < enterStart || progress > exitEnd) return 0;
        if (progress < enterEnd) return (progress - enterStart) / (enterEnd - enterStart);
        if (progress > exitStart) return Math.max(0, 1 - (progress - exitStart) / (exitEnd - exitStart));
        return 1.0;
      }

      // 4. Animation Loop
      function scrubLoop() {
        currentProgress += (targetProgress - currentProgress) * 0.15;
        
        // Seek Video
        if (heroVideo.duration && !heroVideo.seeking) {
          const targetTime = currentProgress * heroVideo.duration;
          if (Math.abs(heroVideo.currentTime - targetTime) > 0.015) {
            heroVideo.currentTime = targetTime;
          }
        }

        // Lock Typography directly to scroll progress
        const op0 = calcOpacity(targetProgress, 0.0, 0.05, 0.20, 0.28);
        const op1 = calcOpacity(targetProgress, 0.28, 0.38, 0.55, 0.65);
        const op2 = calcOpacity(targetProgress, 0.65, 0.75, 0.90, 0.98);

        if (phase0) { phase0.style.opacity = op0.toFixed(3); phase0.style.transform = `translateY(${-targetProgress * 40}px)`; }
        if (phase1) { phase1.style.opacity = op1.toFixed(3); phase1.style.transform = `translateY(${(0.45 - targetProgress) * 30}px)`; }
        if (phase2) { phase2.style.opacity = op2.toFixed(3); phase2.style.transform = `translateY(${(0.80 - targetProgress) * 30}px)`; }

        requestAnimationFrame(scrubLoop);
      }
      requestAnimationFrame(scrubLoop);
    });
  </script>
</body>
</html>
```

