Grand Line Security Portfolio

Build a One Piece-themed portfolio with GSAP scroll animations.

Introduction

30 Second Summary

Every security engineer has a portfolio site. Most of them look exactly the same. The ones that get remembered use scroll-driven animations to tell a story as you scroll down the page.

In this project, you will build a One Piece-themed developer portfolio that uses advanced scroll animations to showcase your security skills. You will pin sections to the viewport, animate text character by character, and scroll content horizontally while the user scrolls vertically.

What You'll Build

A single-page portfolio site built with Vite, GSAP ScrollTrigger, and Lenis smooth scroll, deployed live to Vercel.

By the end of this project, you'll have:

  • A deployed portfolio site with a wanted poster hero reveal, pinned skills storytelling, a horizontal-scroll project showcase, and a contact section.
  • Working GSAP ScrollTrigger animations including pinning, scrubbing, horizontal scroll, and SplitText character reveals.
  • A production-ready Vite project with Lenis smooth scroll synced to GSAP's animation ticker for buttery page feel.
  • Secret Mission: a "Log Pose" scroll progress indicator and reduced-motion accessibility support using gsap.matchMedia.

Are there any prerequisites?

You should be comfortable with HTML, CSS, and JavaScript (ES modules). Familiarity with animation concepts is helpful but not required.

Set Up the Project

Every great voyage needs a ship. Before you can build scroll-driven animations and deploy your portfolio, you need a local development environment that can handle modern JavaScript modules, animation libraries, and a fast refresh cycle.

In this step, you will scaffold a Vite project, install GSAP and Lenis for animations, replace the default scaffold files with your portfolio content, and deploy the site to Vercel.

In this step, get ready to:
  • Scaffold the project with Vite and install GSAP and Lenis.
  • Replace scaffold files with the portfolio HTML, CSS, and JavaScript.
  • Deploy the project to Vercel via GitHub.
Scaffold the project with Vite and install dependencies

Vite gives you an instant dev server with hot module replacement, so you see animation changes the moment you save. It also produces optimized production builds for deployment.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project by running this command:
npm create vite@latest grand-line-portfolio -- --template vanilla

What does this command do?

This tells npm to run the create-vite scaffolding tool. It creates a folder called grand-line-portfolio with a minimal vanilla JavaScript project structure, including index.html, src/main.js, src/style.css, and a package.json.

The -- separates npm's arguments from the arguments passed to create-vite. --template vanilla tells it to skip the interactive framework selector and use plain JavaScript.

First time running this?

If this is your first time using create-vite, npm may ask: "Need to install the following packages: create-vite@latest. Ok to proceed? (y)". Type y and press Enter to continue.

  • Navigate into the project folder and install the animation libraries by running these commands:
cd grand-line-portfolio
npm install gsap@3.15.0 lenis@1.3.23
npm install

What are these packages?

GSAP 3.15.0 is the animation engine. It includes ScrollTrigger (for scroll-linked animations) and SplitText (for character-level text reveals), all bundled in a single free package.

Lenis 1.3.23 provides momentum-based smooth scrolling. It syncs with GSAP's animation ticker so scroll position and animation progress never fall out of step.

The final npm install installs Vite itself (the dev dependency defined in package.json).

  • Verify the scaffold works by starting the dev server:
npm run dev
  • You should see output showing the Vite dev server running on a localhost URL. Confirm the page loads in your browser.
  • Press Ctrl+C in your terminal to stop the dev server for now.

Dev server won't start?

Make sure you ran all three commands from inside the grand-line-portfolio folder. If you see a "command not found" error for vite, run npm install again to ensure Vite was installed as a dev dependency.

Vite requires Node.js version 20.19+ or 22.12+. Check your version with node --version.

help me troubleshoot why my Vite dev server won't start

Clean up scaffold and set up project structure

The default scaffold includes demo files you do not need. You will delete those and replace the remaining files with your portfolio content.

  • Delete the default demo files by running this command:
rm src/counter.js src/javascript.svg

What are these files?

The scaffold includes counter.js (a demo click counter) and javascript.svg (a logo graphic). These are examples you do not need for your portfolio.

  • Now create the Vite configuration file. In your code editor, create a new file called vite.config.js in the root of your grand-line-portfolio folder and paste in this code:
import { defineConfig } from 'vite'

export default defineConfig({
  server: {
    port: 3000,
    open: true
  },
  build: {
    outDir: 'dist'
  }
})

What does this config do?

This tells Vite to run the dev server on port 3000 and automatically open your browser when you start it. The outDir setting ensures production builds go into a dist folder, which Vercel will serve.

  • Save vite.config.js.
  • Next, replace the contents of index.html (in the project root) with this HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Grand Line Portfolio | Security Engineer</title>
  <link rel="stylesheet" href="/src/style.css" />
</head>
<body>
  <!-- Hero: Wanted Poster -->
  <section class="hero">
    <div class="wanted-poster">
      <h1 class="wanted-header">WANTED</h1>
      <div class="wanted-portrait"></div>
      <h2 class="bounty-name">YOUR NAME</h2>
      <p class="bounty-title">Security Engineer</p>
      <p class="bounty-amount">
        <span class="bounty-label">BOUNTY</span>
        <span class="bounty-value" data-value="500000000">$0</span>
      </p>
      <p class="wanted-footer">DEAD OR ALIVE</p>
    </div>
  </section>

  <!-- Skills: Haki Types -->
  <section class="skills" id="skills">
    <div class="skills-pinned">
      <div class="haki-card haki-observation">
        <h3 class="haki-type">Observation Haki</h3>
        <p class="haki-subtitle">Threat Detection &amp; Monitoring</p>
        <ul class="haki-skills">
          <li>SIEM &amp; Log Analysis</li>
          <li>Threat Intelligence</li>
          <li>Network Traffic Analysis</li>
          <li>Anomaly Detection</li>
        </ul>
      </div>
      <div class="haki-card haki-armament">
        <h3 class="haki-type">Armament Haki</h3>
        <p class="haki-subtitle">Hardening &amp; Defense</p>
        <ul class="haki-skills">
          <li>WAF &amp; Firewall Rules</li>
          <li>Encryption &amp; PKI</li>
          <li>Container Security</li>
          <li>CI/CD Pipeline Security</li>
        </ul>
      </div>
      <div class="haki-card haki-conquerors">
        <h3 class="haki-type">Conqueror's Haki</h3>
        <p class="haki-subtitle">Architecture &amp; Leadership</p>
        <ul class="haki-skills">
          <li>Zero Trust Architecture</li>
          <li>Security Strategy</li>
          <li>Incident Response Planning</li>
          <li>Team Leadership</li>
        </ul>
      </div>
    </div>
  </section>

  <!-- Projects: Bounties Claimed (Horizontal Scroll) -->
  <section class="projects" id="projects">
    <div class="projects-track">
      <div class="project-card">
        <span class="project-threat">Ransomware Incursion</span>
        <h4 class="project-name">Zero-Day Response</h4>
        <p class="project-desc">Detected and contained a ransomware attack within 4 hours using custom YARA rules and automated isolation playbooks.</p>
        <ul class="project-tools">
          <li>YARA</li>
          <li>Splunk</li>
          <li>Ansible</li>
        </ul>
        <span class="project-bounty">$120,000,000</span>
      </div>
      <div class="project-card">
        <span class="project-threat">Supply Chain Breach</span>
        <h4 class="project-name">Pipeline Fortress</h4>
        <p class="project-desc">Rebuilt CI/CD pipelines with signed artifacts, SBOM generation, and policy-as-code gates eliminating supply chain risk.</p>
        <ul class="project-tools">
          <li>Sigstore</li>
          <li>OPA</li>
          <li>GitHub Actions</li>
        </ul>
        <span class="project-bounty">$95,000,000</span>
      </div>
      <div class="project-card">
        <span class="project-threat">Cloud Misconfiguration</span>
        <h4 class="project-name">Cloud Lockdown</h4>
        <p class="project-desc">Implemented infrastructure-as-code scanning and drift detection across 200+ AWS accounts, reducing critical findings by 94%.</p>
        <ul class="project-tools">
          <li>Terraform</li>
          <li>Checkov</li>
          <li>AWS Config</li>
        </ul>
        <span class="project-bounty">$80,000,000</span>
      </div>
      <div class="project-card">
        <span class="project-threat">API Vulnerability</span>
        <h4 class="project-name">Auth Overhaul</h4>
        <p class="project-desc">Redesigned authentication layer with OAuth 2.0 + PKCE flow, rate limiting, and automated penetration testing in CI.</p>
        <ul class="project-tools">
          <li>OAuth 2.0</li>
          <li>OWASP ZAP</li>
          <li>Kong Gateway</li>
        </ul>
        <span class="project-bounty">$110,000,000</span>
      </div>
    </div>
  </section>

  <!-- Contact: Form an Alliance -->
  <section class="contact" id="contact">
    <h2 class="contact-heading">Form an Alliance</h2>
    <p class="contact-cta">Looking for a security engineer to join your crew? Let's talk.</p>
    <div class="contact-links">
      <a href="https://github.com" class="contact-link" target="_blank" rel="noopener">GitHub</a>
      <a href="https://linkedin.com" class="contact-link" target="_blank" rel="noopener">LinkedIn</a>
      <a href="mailto:you@example.com" class="contact-link">Email</a>
    </div>
  </section>

  <script type="module" src="/src/main.js"></script>
</body>
</html>

What does this HTML contain?

This is the full page structure for your portfolio. It has four sections, each themed around One Piece:

  • Hero: a wanted poster with your name, title, and bounty value.
  • Skills: three "Haki" cards that will pin and animate during scroll.
  • Projects: a horizontal track of bounty cards that scroll sideways.
  • Contact: a "Form an Alliance" section with social links.
  • Save index.html.
  • Next, replace the contents of src/style.css with the portfolio styles:
:root {
  --paper: #f4e4c1;
  --paper-dark: #d4c4a1;
  --ink: #1a1a1a;
  --marine-blue: #1a3a5c;
  --red-accent: #c0392b;
  --gold: #d4a017;
  --observation-purple: #6c3483;
  --armament-steel: #2c3e50;
  --conquerors-gold: #d4a017;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html {
  font-family: 'Georgia', serif;
  background: var(--ink);
  color: var(--paper);
}

body {
  overflow-x: hidden;
}

/* Hero: Wanted Poster */
.hero {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--marine-blue);
  padding: 2rem;
}

.wanted-poster {
  background: var(--paper);
  color: var(--ink);
  padding: 3rem 2.5rem;
  text-align: center;
  max-width: 480px;
  width: 100%;
  border: 8px solid var(--paper-dark);
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
  position: relative;
}

.wanted-poster::before {
  content: '';
  position: absolute;
  inset: 4px;
  border: 2px solid var(--paper-dark);
  pointer-events: none;
}

.wanted-header {
  font-size: clamp(3rem, 8vw, 5rem);
  font-weight: 900;
  letter-spacing: 0.1em;
  color: var(--red-accent);
  line-height: 1;
  margin-bottom: 1.5rem;
}

.wanted-portrait {
  width: 200px;
  height: 200px;
  margin: 0 auto 1.5rem;
  background: var(--paper-dark);
  border: 3px solid var(--ink);
}

.bounty-name {
  font-size: clamp(1.8rem, 5vw, 2.8rem);
  font-weight: 700;
  margin-bottom: 0.5rem;
  line-height: 1.2;
}

.bounty-title {
  font-size: 1.1rem;
  font-style: italic;
  margin-bottom: 1.5rem;
  opacity: 0.8;
}

.bounty-amount {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.25rem;
}

.bounty-label {
  font-size: 0.9rem;
  letter-spacing: 0.2em;
  opacity: 0.6;
}

.bounty-value {
  font-family: 'Courier New', monospace;
  font-size: clamp(1.5rem, 4vw, 2.2rem);
  font-weight: 700;
}

.wanted-footer {
  margin-top: 1.5rem;
  font-size: 0.85rem;
  letter-spacing: 0.3em;
  opacity: 0.5;
}

/* Skills: Haki Section */
.skills {
  min-height: 300vh;
  position: relative;
}

.skills-pinned {
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
}

.haki-card {
  position: absolute;
  width: min(500px, 90vw);
  padding: 3rem;
  border-radius: 8px;
  opacity: 0;
  transform: translateY(60px);
}

.haki-observation {
  background: var(--observation-purple);
  color: #fff;
}

.haki-armament {
  background: var(--armament-steel);
  color: #fff;
}

.haki-conquerors {
  background: var(--conquerors-gold);
  color: var(--ink);
}

.haki-type {
  font-size: clamp(1.6rem, 4vw, 2.2rem);
  margin-bottom: 0.5rem;
}

.haki-subtitle {
  font-size: 1rem;
  opacity: 0.8;
  margin-bottom: 1.5rem;
  font-style: italic;
}

.haki-skills {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.haki-skills li {
  font-size: 1.1rem;
  padding: 0.5rem 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.2);
  opacity: 0;
  transform: translateX(-20px);
}

.haki-conquerors .haki-skills li {
  border-bottom-color: rgba(0, 0, 0, 0.15);
}

/* Projects: Horizontal Scroll */
.projects {
  overflow: hidden;
  background: var(--ink);
}

.projects-track {
  display: flex;
  gap: 2rem;
  padding: 2rem;
  width: fit-content;
}

.project-card {
  width: 380px;
  min-width: 380px;
  background: var(--paper);
  color: var(--ink);
  padding: 2.5rem 2rem;
  border-radius: 4px;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  border: 3px solid var(--paper-dark);
  opacity: 0.7;
  transform: scale(0.95);
  animation: cardReveal linear both;
  animation-timeline: view(inline);
  animation-range: entry 0% cover 40%;
}

@keyframes cardReveal {
  from {
    opacity: 0.4;
    transform: scale(0.9) translateY(20px);
  }
  to {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
}

.project-threat {
  font-size: 0.75rem;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--red-accent);
  font-weight: 700;
}

.project-name {
  font-size: 1.5rem;
  font-weight: 700;
}

.project-desc {
  font-size: 0.95rem;
  line-height: 1.6;
  opacity: 0.8;
}

.project-tools {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: auto;
}

.project-tools li {
  font-size: 0.8rem;
  padding: 0.25rem 0.75rem;
  background: var(--marine-blue);
  color: #fff;
  border-radius: 3px;
}

.project-bounty {
  font-family: 'Courier New', monospace;
  font-size: 1.3rem;
  font-weight: 700;
  color: var(--red-accent);
  margin-top: 0.5rem;
}

/* Contact Section */
.contact {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 4rem 2rem;
  background: var(--marine-blue);
}

.contact-heading {
  font-size: clamp(2.5rem, 7vw, 4rem);
  margin-bottom: 1rem;
  color: var(--gold);
}

.contact-cta {
  font-size: 1.2rem;
  max-width: 500px;
  margin-bottom: 2.5rem;
  opacity: 0.9;
  line-height: 1.6;
}

.contact-links {
  display: flex;
  gap: 1.5rem;
  flex-wrap: wrap;
  justify-content: center;
}

.contact-link {
  color: var(--paper);
  text-decoration: none;
  font-size: 1.1rem;
  padding: 0.75rem 2rem;
  border: 2px solid var(--paper);
  border-radius: 4px;
  transition: background 0.3s, color 0.3s;
}

.contact-link:hover {
  background: var(--paper);
  color: var(--marine-blue);
}

What does this stylesheet do?

The CSS custom properties at the top define your One Piece color palette: aged paper, ink black, marine blue, and red accents. Every section uses these variables for a consistent theme.

The .haki-card elements start with opacity: 0 and transform: translateY(60px) because GSAP will animate them into view during scroll. The .project-card uses a CSS animation-timeline: view(inline) for a hybrid approach where CSS handles simple card reveals.

  • Save src/style.css.
  • Finally, replace the contents of src/main.js with the animation initialization code:
import 'lenis/dist/lenis.css'
import Lenis from 'lenis'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText } from 'gsap/SplitText'

gsap.registerPlugin(ScrollTrigger, SplitText)

// Initialize Lenis smooth scroll synced to GSAP ticker
const lenis = new Lenis()

lenis.on('scroll', ScrollTrigger.update)

gsap.ticker.add((time) => {
  lenis.raf(time * 1000)
})
gsap.ticker.lagSmoothing(0)

// Hero: Wanted Poster Animation
function initHero() {
  const split = new SplitText('.bounty-name', { type: 'chars' })
  const tl = gsap.timeline({ delay: 0.3 })

  tl.from('.wanted-header', {
    y: -80,
    opacity: 0,
    duration: 0.6,
    ease: 'back.out(1.7)'
  })
  .from(split.chars, {
    opacity: 0,
    y: 20,
    rotateX: -90,
    stagger: 0.04,
    duration: 0.5,
    ease: 'back.out(1.7)'
  }, '-=0.2')
  .from('.bounty-title', {
    opacity: 0,
    y: 20,
    duration: 0.4
  }, '-=0.2')
  .from('.bounty-amount', {
    opacity: 0,
    scale: 0.8,
    duration: 0.4,
    ease: 'back.out(2)'
  }, '-=0.1')
  .add(() => {
    // Animate bounty counter
    const bountyEl = document.querySelector('.bounty-value')
    const target = parseInt(bountyEl.dataset.value)
    gsap.to({ val: 0 }, {
      val: target,
      duration: 1.5,
      ease: 'power2.out',
      onUpdate: function() {
        bountyEl.textContent = '$' + Math.floor(this.targets()[0].val).toLocaleString()
      }
    })
  }, '-=0.2')
  .from('.wanted-footer', {
    opacity: 0,
    duration: 0.4
  }, '-=1')
}

// Placeholder functions - we'll build these in later steps
function initSkills() {}
function initProjects() {}
function initContact() {}

// Initialize all animations
initHero()
initSkills()
initProjects()
initContact()

What does this JavaScript do?

The top section imports Lenis for smooth scroll and GSAP with its ScrollTrigger and SplitText plugins. It then syncs Lenis to GSAP's ticker so both systems share the same frame loop.

The initHero() function splits your name into individual characters and choreographs a timeline: the "WANTED" header drops in, characters stagger-reveal with rotation, then the bounty counter animates from $0 to $500,000,000.

The stub functions (initSkills, initProjects, initContact) are placeholders. You will fill them in over the next steps.

  • Save src/main.js.
  • Start the dev server again by running:
npm run dev
  • Your browser should open automatically at http://localhost:3000. You should see a parchment-colored "WANTED" poster centered on a dark blue background. The "WANTED" header drops in, your name characters reveal one by one, and the bounty counter ticks up.

Not seeing the animation?

Make sure all three files are saved: index.html, src/style.css, and src/main.js. Vite hot-reloads on save, but only if the file is saved to disk.

If you see a blank page, open your browser's developer console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and look for import errors. A common cause is a typo in the import paths.

help me debug why my GSAP animations aren't showing on my Vite dev server

✔️ Awesome, I've got everything!

Great. Make sure all your files are saved before moving on to Vercel deployment.

ⓧ I'd like to double check the full code

Here is the full code for each file at this point. Compare yours against these:

vite.config.js:

import { defineConfig } from 'vite'

export default defineConfig({
  server: { port: 3000, open: true },
  build: { outDir: 'dist' }
})

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Grand Line Portfolio | Security Engineer</title>
  <link rel="stylesheet" href="/src/style.css" />
</head>
<body>
  <!-- Hero: Wanted Poster -->
  <section class="hero">
    <div class="wanted-poster">
      <h1 class="wanted-header">WANTED</h1>
      <div class="wanted-portrait"></div>
      <h2 class="bounty-name">YOUR NAME</h2>
      <p class="bounty-title">Security Engineer</p>
      <p class="bounty-amount">
        <span class="bounty-label">BOUNTY</span>
        <span class="bounty-value" data-value="500000000">$0</span>
      </p>
      <p class="wanted-footer">DEAD OR ALIVE</p>
    </div>
  </section>

  <!-- Skills: Haki Types -->
  <section class="skills" id="skills">
    <div class="skills-pinned">
      <div class="haki-card haki-observation">
        <h3 class="haki-type">Observation Haki</h3>
        <p class="haki-subtitle">Threat Detection &amp; Monitoring</p>
        <ul class="haki-skills">
          <li>SIEM &amp; Log Analysis</li>
          <li>Threat Intelligence</li>
          <li>Network Traffic Analysis</li>
          <li>Anomaly Detection</li>
        </ul>
      </div>
      <div class="haki-card haki-armament">
        <h3 class="haki-type">Armament Haki</h3>
        <p class="haki-subtitle">Hardening &amp; Defense</p>
        <ul class="haki-skills">
          <li>WAF &amp; Firewall Rules</li>
          <li>Encryption &amp; PKI</li>
          <li>Container Security</li>
          <li>CI/CD Pipeline Security</li>
        </ul>
      </div>
      <div class="haki-card haki-conquerors">
        <h3 class="haki-type">Conqueror's Haki</h3>
        <p class="haki-subtitle">Architecture &amp; Leadership</p>
        <ul class="haki-skills">
          <li>Zero Trust Architecture</li>
          <li>Security Strategy</li>
          <li>Incident Response Planning</li>
          <li>Team Leadership</li>
        </ul>
      </div>
    </div>
  </section>

  <!-- Projects: Bounties Claimed (Horizontal Scroll) -->
  <section class="projects" id="projects">
    <div class="projects-track">
      <div class="project-card">
        <span class="project-threat">Ransomware Incursion</span>
        <h4 class="project-name">Zero-Day Response</h4>
        <p class="project-desc">Detected and contained a ransomware attack within 4 hours using custom YARA rules and automated isolation playbooks.</p>
        <ul class="project-tools"><li>YARA</li><li>Splunk</li><li>Ansible</li></ul>
        <span class="project-bounty">$120,000,000</span>
      </div>
      <div class="project-card">
        <span class="project-threat">Supply Chain Breach</span>
        <h4 class="project-name">Pipeline Fortress</h4>
        <p class="project-desc">Rebuilt CI/CD pipelines with signed artifacts, SBOM generation, and policy-as-code gates eliminating supply chain risk.</p>
        <ul class="project-tools"><li>Sigstore</li><li>OPA</li><li>GitHub Actions</li></ul>
        <span class="project-bounty">$95,000,000</span>
      </div>
      <div class="project-card">
        <span class="project-threat">Cloud Misconfiguration</span>
        <h4 class="project-name">Cloud Lockdown</h4>
        <p class="project-desc">Implemented infrastructure-as-code scanning and drift detection across 200+ AWS accounts, reducing critical findings by 94%.</p>
        <ul class="project-tools"><li>Terraform</li><li>Checkov</li><li>AWS Config</li></ul>
        <span class="project-bounty">$80,000,000</span>
      </div>
      <div class="project-card">
        <span class="project-threat">API Vulnerability</span>
        <h4 class="project-name">Auth Overhaul</h4>
        <p class="project-desc">Redesigned authentication layer with OAuth 2.0 + PKCE flow, rate limiting, and automated penetration testing in CI.</p>
        <ul class="project-tools"><li>OAuth 2.0</li><li>OWASP ZAP</li><li>Kong Gateway</li></ul>
        <span class="project-bounty">$110,000,000</span>
      </div>
    </div>
  </section>

  <!-- Contact: Form an Alliance -->
  <section class="contact" id="contact">
    <h2 class="contact-heading">Form an Alliance</h2>
    <p class="contact-cta">Looking for a security engineer to join your crew? Let's talk.</p>
    <div class="contact-links">
      <a href="https://github.com" class="contact-link" target="_blank" rel="noopener">GitHub</a>
      <a href="https://linkedin.com" class="contact-link" target="_blank" rel="noopener">LinkedIn</a>
      <a href="mailto:you@example.com" class="contact-link">Email</a>
    </div>
  </section>

  <script type="module" src="/src/main.js"></script>
</body>
</html>

src/style.css:

:root {
  --paper: #f4e4c1;
  --paper-dark: #d4c4a1;
  --ink: #1a1a1a;
  --marine-blue: #1a3a5c;
  --red-accent: #c0392b;
  --gold: #d4a017;
  --observation-purple: #6c3483;
  --armament-steel: #2c3e50;
  --conquerors-gold: #d4a017;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

html { font-family: 'Georgia', serif; background: var(--ink); color: var(--paper); }

body { overflow-x: hidden; }

.hero { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--marine-blue); padding: 2rem; }

.wanted-poster { background: var(--paper); color: var(--ink); padding: 3rem 2.5rem; text-align: center; max-width: 480px; width: 100%; border: 8px solid var(--paper-dark); box-shadow: 0 20px 60px rgba(0,0,0,0.5); position: relative; }

.wanted-poster::before { content: ''; position: absolute; inset: 4px; border: 2px solid var(--paper-dark); pointer-events: none; }

.wanted-header { font-size: clamp(3rem, 8vw, 5rem); font-weight: 900; letter-spacing: 0.1em; color: var(--red-accent); line-height: 1; margin-bottom: 1.5rem; }

.wanted-portrait { width: 200px; height: 200px; margin: 0 auto 1.5rem; background: var(--paper-dark); border: 3px solid var(--ink); }

.bounty-name { font-size: clamp(1.8rem, 5vw, 2.8rem); font-weight: 700; margin-bottom: 0.5rem; line-height: 1.2; }

.bounty-title { font-size: 1.1rem; font-style: italic; margin-bottom: 1.5rem; opacity: 0.8; }

.bounty-amount { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }

.bounty-label { font-size: 0.9rem; letter-spacing: 0.2em; opacity: 0.6; }

.bounty-value { font-family: 'Courier New', monospace; font-size: clamp(1.5rem, 4vw, 2.2rem); font-weight: 700; }

.wanted-footer { margin-top: 1.5rem; font-size: 0.85rem; letter-spacing: 0.3em; opacity: 0.5; }

.skills { min-height: 300vh; position: relative; }

.skills-pinned { height: 100vh; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; }

.haki-card { position: absolute; width: min(500px, 90vw); padding: 3rem; border-radius: 8px; opacity: 0; transform: translateY(60px); }

.haki-observation { background: var(--observation-purple); color: #fff; }
.haki-armament { background: var(--armament-steel); color: #fff; }
.haki-conquerors { background: var(--conquerors-gold); color: var(--ink); }

.haki-type { font-size: clamp(1.6rem, 4vw, 2.2rem); margin-bottom: 0.5rem; }

.haki-subtitle { font-size: 1rem; opacity: 0.8; margin-bottom: 1.5rem; font-style: italic; }

.haki-skills { list-style: none; display: flex; flex-direction: column; gap: 0.75rem; }

.haki-skills li { font-size: 1.1rem; padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.2); opacity: 0; transform: translateX(-20px); }

.haki-conquerors .haki-skills li { border-bottom-color: rgba(0,0,0,0.15); }

.projects { overflow: hidden; background: var(--ink); }

.projects-track { display: flex; gap: 2rem; padding: 2rem; width: fit-content; }

.project-card { width: 380px; min-width: 380px; background: var(--paper); color: var(--ink); padding: 2.5rem 2rem; border-radius: 4px; display: flex; flex-direction: column; gap: 0.75rem; border: 3px solid var(--paper-dark); opacity: 0.7; transform: scale(0.95); animation: cardReveal linear both; animation-timeline: view(inline); animation-range: entry 0% cover 40%; }

@keyframes cardReveal { from { opacity: 0.4; transform: scale(0.9) translateY(20px); } to { opacity: 1; transform: scale(1) translateY(0); } }

.project-threat { font-size: 0.75rem; letter-spacing: 0.2em; text-transform: uppercase; color: var(--red-accent); font-weight: 700; }

.project-name { font-size: 1.5rem; font-weight: 700; }

.project-desc { font-size: 0.95rem; line-height: 1.6; opacity: 0.8; }

.project-tools { list-style: none; display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: auto; }

.project-tools li { font-size: 0.8rem; padding: 0.25rem 0.75rem; background: var(--marine-blue); color: #fff; border-radius: 3px; }

.project-bounty { font-family: 'Courier New', monospace; font-size: 1.3rem; font-weight: 700; color: var(--red-accent); margin-top: 0.5rem; }

.contact { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 4rem 2rem; background: var(--marine-blue); }

.contact-heading { font-size: clamp(2.5rem, 7vw, 4rem); margin-bottom: 1rem; color: var(--gold); }

.contact-cta { font-size: 1.2rem; max-width: 500px; margin-bottom: 2.5rem; opacity: 0.9; line-height: 1.6; }

.contact-links { display: flex; gap: 1.5rem; flex-wrap: wrap; justify-content: center; }

.contact-link { color: var(--paper); text-decoration: none; font-size: 1.1rem; padding: 0.75rem 2rem; border: 2px solid var(--paper); border-radius: 4px; transition: background 0.3s, color 0.3s; }

.contact-link:hover { background: var(--paper); color: var(--marine-blue); }

src/main.js:

import 'lenis/dist/lenis.css'
import Lenis from 'lenis'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText } from 'gsap/SplitText'

gsap.registerPlugin(ScrollTrigger, SplitText)

const lenis = new Lenis()

lenis.on('scroll', ScrollTrigger.update)

gsap.ticker.add((time) => {
  lenis.raf(time * 1000)
})
gsap.ticker.lagSmoothing(0)

function initHero() {
  const split = new SplitText('.bounty-name', { type: 'chars' })
  const tl = gsap.timeline({ delay: 0.3 })
  tl.from('.wanted-header', { y: -80, opacity: 0, duration: 0.6, ease: 'back.out(1.7)' })
  .from(split.chars, { opacity: 0, y: 20, rotateX: -90, stagger: 0.04, duration: 0.5, ease: 'back.out(1.7)' }, '-=0.2')
  .from('.bounty-title', { opacity: 0, y: 20, duration: 0.4 }, '-=0.2')
  .from('.bounty-amount', { opacity: 0, scale: 0.8, duration: 0.4, ease: 'back.out(2)' }, '-=0.1')
  .add(() => {
    const bountyEl = document.querySelector('.bounty-value')
    const target = parseInt(bountyEl.dataset.value)
    gsap.to({ val: 0 }, { val: target, duration: 1.5, ease: 'power2.out', onUpdate: function() { bountyEl.textContent = '$' + Math.floor(this.targets()[0].val).toLocaleString() } })
  }, '-=0.2')
  .from('.wanted-footer', { opacity: 0, duration: 0.4 }, '-=1')
}

function initSkills() {}
function initProjects() {}
function initContact() {}

initHero()
initSkills()
initProjects()
initContact()
Connect Vercel for deployment

Your site works locally. Now you will push it to GitHub and connect it to Vercel so every future push automatically deploys a live version.

  • Stop the dev server with Ctrl+C.
  • Initialize a Git repository and make your first commit by running these commands:
git init
git add .
git commit -m "Initial portfolio scaffold"
  • Go to github.com and create a new repository called grand-line-portfolio. Leave it empty (no README, no .gitignore).
  • Push your code to GitHub by running these commands (replace the URL with your own repository URL):
git remote add origin [[REPO_URL="https://github.com/YOUR_USERNAME/grand-line-portfolio.git"]]
git branch -M main
git push -u origin main
  • Now set up Vercel. Go to vercel.com in your browser.
  • Click Sign Up and choose Continue with GitHub.
  • Authorize Vercel to access your GitHub account.
  • From the Vercel dashboard, click Add New... then Project.
  • Find grand-line-portfolio in the list of repositories and click Import.
  • Vercel auto-detects Vite. Leave all settings at their defaults and click Deploy.
  • Wait for the build to complete. This takes about 30-60 seconds.
  • Click the generated URL to view your live site. You should see the same wanted poster animation you saw locally.

Why Vercel?

Vercel's free Hobby plan gives you automatic deploys on every Git push, a global CDN, and automatic HTTPS. It detects Vite projects and configures builds with zero configuration. Your portfolio stays live at no cost.

Deploy failed?

Check that your package.json includes the build script ("build": "vite build"). Vercel runs this command during deployment.

If Vercel cannot find your repository, make sure you pushed to the main branch and that Vercel has permission to access the repo.

help me troubleshoot my Vercel deployment

Your portfolio is live. Next up, you will build the Skills section with pinned scroll animations that reveal your security expertise as the user scrolls.

Build the Hero Section

Your project is scaffolded, deployed, and ready for action. Now it's time to focus on the first thing visitors see when they land on your portfolio.

Every great pirate has a wanted poster. In this step, you'll understand how your hero section creates that iconic poster reveal using GSAP SplitText character animations and a sequenced timeline. The animation plays on page load as the opening scene of your portfolio.

In this step, get ready to:
  • Examine the wanted poster HTML structure and customize it with your name.
  • Understand the aged-paper CSS aesthetic built from custom properties.
  • Explore the GSAP timeline that sequences the poster reveal animation.
Examine the wanted poster HTML structure

The hero section is already in your index.html file from setup. Let's walk through how it's structured for GSAP to target each element individually.

  • In VS Code, open index.html and find the .hero section near the top of <body>.
  • Look at the structure inside .wanted-poster:
<section class="hero">
  <div class="wanted-poster">
    <h1 class="wanted-header">WANTED</h1>
    <div class="wanted-portrait"></div>
    <h2 class="bounty-name">YOUR NAME</h2>
    <p class="bounty-title">Security Engineer</p>
    <p class="bounty-amount">
      <span class="bounty-label">BOUNTY</span>
      <span class="bounty-value" data-value="500000000">$0</span>
    </p>
    <p class="wanted-footer">DEAD OR ALIVE</p>
  </div>
</section>

What does this structure do?

Each element has a unique class name so GSAP can target it independently in the animation timeline. The .bounty-name is what SplitText will break into individual characters for the stagger-reveal effect.

The data-value="500000000" attribute on .bounty-value stores the final bounty number. GSAP reads this to animate a counter from $0 up to $500,000,000.

Now personalize your poster. This is your portfolio, after all.

  • Find the line that says YOUR NAME inside the .bounty-name element.
  • Replace YOUR NAME with your actual name or alias.
  • Save index.html.

Keep it short

The SplitText animation reveals each character one by one. A name longer than 15 characters will slow the reveal. First name only works well.

Understand the parchment CSS design

The wanted poster's aged-paper look comes from CSS custom properties and layered border styling. Open src/style.css and look at the top of the file.

  • Find the :root block at the very top of src/style.css:
:root {
  --paper: #f4e4c1;
  --paper-dark: #d4c4a1;
  --ink: #1a1a1a;
  --marine-blue: #1a3a5c;
  --red-accent: #c0392b;
  --gold: #d4a017;
  --observation-purple: #6c3483;
  --armament-steel: #2c3e50;
  --conquerors-gold: #d4a017;
}

What is this color palette?

These CSS custom properties define the One Piece color theme used across every section. --paper and --paper-dark create the aged parchment look. --red-accent gives the "WANTED" header its bold color.

The remaining variables (--observation-purple, --armament-steel, --conquerors-gold) are used in later sections for the Haki skill cards.

  • Scroll down to find the .wanted-poster rule:
.wanted-poster {
  background: var(--paper);
  color: var(--ink);
  padding: 3rem 2.5rem;
  text-align: center;
  max-width: 480px;
  width: 100%;
  border: 8px solid var(--paper-dark);
  box-shadow: 0 20px 60px rgba(0,0,0,0.5);
  position: relative;
}

.wanted-poster::before {
  content: '';
  position: absolute;
  inset: 4px;
  border: 2px solid var(--paper-dark);
  pointer-events: none;
}

Why the double border?

The 8px outer border gives the poster its thick frame. The ::before pseudo-element adds a thinner inset border 4px inside, creating that old document double-border look you see on real wanted posters.

The deep box-shadow lifts the poster off the dark marine-blue background, giving it a physical floating presence.

  • Save src/style.css and check your browser.
  • You should see a warm parchment-colored poster centered on a dark navy background with bold red "WANTED" text at the top.

Poster looks unstyled or broken?

Make sure you saved src/style.css. Vite's hot reload picks up saved changes automatically.

Check that the <link> tag in index.html points to /src/style.css (with the src/ prefix).

Still stuck? Why isn't my CSS loading in Vite?

Explore the SplitText animation timeline

The hero animation is the first impression visitors get. It plays immediately on page load, not on scroll. Let's examine how the GSAP timeline choreographs six elements in sequence.

  • Open src/main.js and find the initHero() function:
function initHero() {
  // Split the bounty name into individual characters
  const split = new SplitText('.bounty-name', { type: 'chars' })
  // Create a timeline with a short delay so the page settles first
  const tl = gsap.timeline({ delay: 0.3 })

  // 1. WANTED header drops in from above
  tl.from('.wanted-header', {
    y: -80,
    opacity: 0,
    duration: 0.6,
    ease: 'back.out(1.7)'
  })
  // 2. Name characters stagger-reveal with rotation
  .from(split.chars, {
    opacity: 0,
    y: 20,
    rotateX: -90,
    stagger: 0.04,
    duration: 0.5,
    ease: 'back.out(1.7)'
  }, '-=0.2')
  // 3. Title fades up
  .from('.bounty-title', {
    opacity: 0,
    y: 20,
    duration: 0.4
  }, '-=0.2')
  // 4. Bounty amount scales in with a bounce
  .from('.bounty-amount', {
    opacity: 0,
    scale: 0.8,
    duration: 0.4,
    ease: 'back.out(2)'
  }, '-=0.1')
  // 5. Counter animates from $0 to $500,000,000
  .add(() => {
    const bountyEl = document.querySelector('.bounty-value')
    const target = parseInt(bountyEl.dataset.value)
    gsap.to({ val: 0 }, {
      val: target,
      duration: 1.5,
      ease: 'power2.out',
      onUpdate: function() {
        bountyEl.textContent = '$' + Math.floor(this.targets()[0].val).toLocaleString()
      }
    })
  }, '-=0.2')
  // 6. Footer fades in during the counter
  .from('.wanted-footer', {
    opacity: 0,
    duration: 0.4
  }, '-=1')
}

How does this timeline work?

  • SplitText wraps each character of your name in its own <span>, giving GSAP individual control over each letter.
  • The '-=0.2' position parameter overlaps each animation by 0.2 seconds with the previous one. This creates a fluid cascade instead of waiting for one to finish before the next starts.
  • The back.out(1.7) ease makes elements overshoot their target slightly, then settle back. It mimics the "slam" of a wanted poster being slapped onto a wall.
  • The bounty counter uses gsap.to({ val: 0 }, ...) to tween a plain object. The onUpdate callback reads the current value on every frame and writes it to the DOM as formatted currency.

What about the Lenis setup above initHero?

At the top of main.js, Lenis smooth scroll is initialized and synced to GSAP's internal ticker. This ensures scroll position and animation progress stay perfectly in sync. You won't notice Lenis on the hero (it plays on load, not scroll), but it gives the entire page buttery momentum-based scrolling for later sections.

  • Return to your browser at localhost:3000.
  • Hard-refresh the page (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows) to replay the animation from scratch.
  • You should see: "WANTED" drops in from above, your name reveals character by character with a 3D rotation, the title fades up, and the bounty counter climbs from $0 to $500,000,000.

Animation not playing?

Check your browser's developer console (F12 or Cmd+Option+I) for errors. A common issue is a typo in the class selector strings inside initHero().

If you see "SplitText is not defined", confirm that gsap.registerPlugin(ScrollTrigger, SplitText) appears near the top of main.js.

Still not working? Help me debug my GSAP hero animation

✔️ Awesome, I've got everything!

Great. Make sure you've saved all files and your hero animation is playing on refresh.

ⓧ I'd like to double check the full code

Here's what your index.html should look like:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Grand Line Portfolio | Security Engineer</title>
  <link rel="stylesheet" href="/src/style.css" />
</head>
<body>
  <section class="hero">
    <div class="wanted-poster">
      <h1 class="wanted-header">WANTED</h1>
      <div class="wanted-portrait"></div>
      <h2 class="bounty-name">YOUR NAME</h2>
      <p class="bounty-title">Security Engineer</p>
      <p class="bounty-amount">
        <span class="bounty-label">BOUNTY</span>
        <span class="bounty-value" data-value="500000000">$0</span>
      </p>
      <p class="wanted-footer">DEAD OR ALIVE</p>
    </div>
  </section>
  <section class="skills" id="skills">
    <div class="skills-pinned">
      <div class="haki-card haki-observation"><h3 class="haki-type">Observation Haki</h3><p class="haki-subtitle">Threat Detection &amp; Monitoring</p><ul class="haki-skills"><li>SIEM &amp; Log Analysis</li><li>Threat Intelligence</li><li>Network Traffic Analysis</li><li>Anomaly Detection</li></ul></div>
      <div class="haki-card haki-armament"><h3 class="haki-type">Armament Haki</h3><p class="haki-subtitle">Hardening &amp; Defense</p><ul class="haki-skills"><li>WAF &amp; Firewall Rules</li><li>Encryption &amp; PKI</li><li>Container Security</li><li>CI/CD Pipeline Security</li></ul></div>
      <div class="haki-card haki-conquerors"><h3 class="haki-type">Conqueror's Haki</h3><p class="haki-subtitle">Architecture &amp; Leadership</p><ul class="haki-skills"><li>Zero Trust Architecture</li><li>Security Strategy</li><li>Incident Response Planning</li><li>Team Leadership</li></ul></div>
    </div>
  </section>
  <section class="projects" id="projects">
    <div class="projects-track">
      <div class="project-card"><span class="project-threat">Ransomware Incursion</span><h4 class="project-name">Zero-Day Response</h4><p class="project-desc">Detected and contained a ransomware attack within 4 hours using custom YARA rules and automated isolation playbooks.</p><ul class="project-tools"><li>YARA</li><li>Splunk</li><li>Ansible</li></ul><span class="project-bounty">$120,000,000</span></div>
      <div class="project-card"><span class="project-threat">Supply Chain Breach</span><h4 class="project-name">Pipeline Fortress</h4><p class="project-desc">Rebuilt CI/CD pipelines with signed artifacts, SBOM generation, and policy-as-code gates eliminating supply chain risk.</p><ul class="project-tools"><li>Sigstore</li><li>OPA</li><li>GitHub Actions</li></ul><span class="project-bounty">$95,000,000</span></div>
      <div class="project-card"><span class="project-threat">Cloud Misconfiguration</span><h4 class="project-name">Cloud Lockdown</h4><p class="project-desc">Implemented infrastructure-as-code scanning and drift detection across 200+ AWS accounts, reducing critical findings by 94%.</p><ul class="project-tools"><li>Terraform</li><li>Checkov</li><li>AWS Config</li></ul><span class="project-bounty">$80,000,000</span></div>
      <div class="project-card"><span class="project-threat">API Vulnerability</span><h4 class="project-name">Auth Overhaul</h4><p class="project-desc">Redesigned authentication layer with OAuth 2.0 + PKCE flow, rate limiting, and automated penetration testing in CI.</p><ul class="project-tools"><li>OAuth 2.0</li><li>OWASP ZAP</li><li>Kong Gateway</li></ul><span class="project-bounty">$110,000,000</span></div>
    </div>
  </section>
  <section class="contact" id="contact">
    <h2 class="contact-heading">Form an Alliance</h2>
    <p class="contact-cta">Looking for a security engineer to join your crew? Let's talk.</p>
    <div class="contact-links">
      <a href="https://github.com" class="contact-link" target="_blank" rel="noopener">GitHub</a>
      <a href="https://linkedin.com" class="contact-link" target="_blank" rel="noopener">LinkedIn</a>
      <a href="mailto:you@example.com" class="contact-link">Email</a>
    </div>
  </section>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

Here's what your src/main.js should look like:

import 'lenis/dist/lenis.css'
import Lenis from 'lenis'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText } from 'gsap/SplitText'

gsap.registerPlugin(ScrollTrigger, SplitText)

const lenis = new Lenis()
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => { lenis.raf(time * 1000) })
gsap.ticker.lagSmoothing(0)

function initHero() {
  const split = new SplitText('.bounty-name', { type: 'chars' })
  const tl = gsap.timeline({ delay: 0.3 })
  tl.from('.wanted-header', { y: -80, opacity: 0, duration: 0.6, ease: 'back.out(1.7)' })
  .from(split.chars, { opacity: 0, y: 20, rotateX: -90, stagger: 0.04, duration: 0.5, ease: 'back.out(1.7)' }, '-=0.2')
  .from('.bounty-title', { opacity: 0, y: 20, duration: 0.4 }, '-=0.2')
  .from('.bounty-amount', { opacity: 0, scale: 0.8, duration: 0.4, ease: 'back.out(2)' }, '-=0.1')
  .add(() => {
    const bountyEl = document.querySelector('.bounty-value')
    const target = parseInt(bountyEl.dataset.value)
    gsap.to({ val: 0 }, { val: target, duration: 1.5, ease: 'power2.out', onUpdate: function() { bountyEl.textContent = '$' + Math.floor(this.targets()[0].val).toLocaleString() } })
  }, '-=0.2')
  .from('.wanted-footer', { opacity: 0, duration: 0.4 }, '-=1')
}

function initSkills() {}
function initProjects() {}
function initContact() {}

initHero()
initSkills()
initProjects()
initContact()

Your src/style.css and vite.config.js remain unchanged from the previous step.

Your wanted poster is alive with motion. Next up, you'll build the skills section where three Haki cards pin to the viewport and reveal one by one as the user scrolls. This is where ScrollTrigger pinning and scrubbing come into play.

Build the Skills Section

Your wanted poster drops in with a punchy entrance animation. Now you need a way to tell visitors what you actually do.

The skills section uses a scroll-driven storytelling pattern where the page pins in place while content swaps beneath the user's scroll. Each "Haki type" card represents a security skill domain, and they reveal one at a time as the user scrolls through a tall section. This is the signature ScrollTrigger pinning pattern that separates polished portfolio sites from basic ones.

In this step, get ready to:
  • Understand the pinned skills panel HTML and CSS structure.
  • Create a GSAP timeline with ScrollTrigger pinning and scrubbing.
  • Animate individual skill items with staggered reveals inside each card.
Review the pinned skills panel structure

The HTML and CSS for the skills section are already in your project from the previous step. Before writing the animation, take a moment to understand why the structure is built the way it is.

  • Switch back to index.html in VS Code and find the <section class="skills"> element.
  • Notice the nested structure: a .skills-pinned container wraps three .haki-card elements (Observation, Armament, Conqueror's).

Why is the section 300vh tall?

The .skills section has min-height: 300vh in CSS. This creates 3x the viewport height of scroll distance for the animation to play through.

The inner .skills-pinned container is only 100vh tall. GSAP pins this container to the viewport while the user scrolls through the remaining 200vh. The three cards are stacked on top of each other using position: absolute and start invisible (opacity: 0).

Create the pinned ScrollTrigger timeline

ScrollTrigger's pin option locks an element in place while scrub ties the animation progress directly to the scroll position. Together, they let you play a full timeline as the user scrolls.

  • In src/main.js, find the empty initSkills() function and replace it with this code:
// Skills: Pinned Haki Timeline
function initSkills() {
  const cards = gsap.utils.toArray('.haki-card')
  const tl = gsap.timeline({
    scrollTrigger: {
      trigger: '.skills',
      start: 'top top',
      end: '+=200%',
      scrub: 1,
      pin: '.skills-pinned'
    }
  })

  cards.forEach((card, i) => {
    const skills = card.querySelectorAll('.haki-skills li')

    // Card enters
    tl.to(card, {
      opacity: 1,
      y: 0,
      duration: 1,
      ease: 'power2.out'
    })
    // Skills stagger in
    .to(skills, {
      opacity: 1,
      x: 0,
      stagger: 0.15,
      duration: 0.5
    }, '-=0.5')
    // Hold
    .to(card, { duration: 0.5 })

    // Card exits (except last)
    if (i < cards.length - 1) {
      tl.to(card, {
        opacity: 0,
        y: -40,
        duration: 0.5
      })
    }
  })
}

What does this code do?

This function orchestrates the entire skills section animation through a single scrubbed timeline.

  • gsap.utils.toArray('.haki-card') collects all three Haki cards into a regular array that GSAP can iterate over.
  • The scrollTrigger config pins .skills-pinned to the viewport and maps the timeline progress to 200% of the viewport height of scrolling.
  • scrub: 1 adds a 1-second smoothing delay so the animation eases into position rather than jumping frame-to-frame with the scroll.
  • The forEach loop builds the timeline sequentially: each card fades in, reveals its skill items, holds briefly, then exits (except the last card, which stays visible).
  • Save src/main.js.
  • Return to your browser at localhost:3000 and scroll past the hero section. The page should lock in place while a purple card (Observation Haki) fades in with its skill list.

Skills section not pinning?

  • Make sure you replaced the empty function initSkills() {} stub completely. If any braces are mismatched, the function won't run.
  • Check the browser console for errors. A typo in a class name like .skills-pinned will cause the pin to fail silently.
  • If the cards flash on screen instantly instead of animating, check that scrub: 1 is inside the scrollTrigger object, not at the timeline level.
  • Help me debug my ScrollTrigger pinning animation.
Verify the detail reveals within each card

The skill list items inside each card have their own staggered entrance. This is the detail that makes the animation feel choreographed rather than mechanical.

  • Scroll slowly through the skills section and watch the individual <li> items slide in from the left one by one as each card appears.
  • Scroll backwards to confirm the animation reverses. The cards should fade back out and the skill items should slide back to the left.

How does the stagger work inside the timeline?

The .to(skills, { opacity: 1, x: 0, stagger: 0.15 }, '-=0.5') call does two things. The stagger: 0.15 delays each list item by 0.15 timeline-seconds after the previous one, creating a cascading reveal.

The '-=0.5' position parameter overlaps this tween with the previous one (the card entrance) by 0.5 seconds. This means the skills start sliding in while the card is still finishing its fade, making the transition feel seamless rather than step-by-step.

Why does the last card skip the exit animation?

The if (i < cards.length - 1) check prevents the final card (Conqueror's Haki) from fading out. When the pinned section unpins, the last card stays visible as the natural content the user sees when they continue scrolling into the projects section.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/main.js and all three cards animate correctly when scrolling.

ⓧ I'd like to double check the full code

Here is the full src/main.js as it should look at this point:

import 'lenis/dist/lenis.css'
import Lenis from 'lenis'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText } from 'gsap/SplitText'

gsap.registerPlugin(ScrollTrigger, SplitText)

const lenis = new Lenis()
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => { lenis.raf(time * 1000) })
gsap.ticker.lagSmoothing(0)

function initHero() {
  const split = new SplitText('.bounty-name', { type: 'chars' })
  const tl = gsap.timeline({ delay: 0.3 })
  tl.from('.wanted-header', { y: -80, opacity: 0, duration: 0.6, ease: 'back.out(1.7)' })
  .from(split.chars, { opacity: 0, y: 20, rotateX: -90, stagger: 0.04, duration: 0.5, ease: 'back.out(1.7)' }, '-=0.2')
  .from('.bounty-title', { opacity: 0, y: 20, duration: 0.4 }, '-=0.2')
  .from('.bounty-amount', { opacity: 0, scale: 0.8, duration: 0.4, ease: 'back.out(2)' }, '-=0.1')
  .add(() => {
    const bountyEl = document.querySelector('.bounty-value')
    const target = parseInt(bountyEl.dataset.value)
    gsap.to({ val: 0 }, { val: target, duration: 1.5, ease: 'power2.out', onUpdate: function() { bountyEl.textContent = '$' + Math.floor(this.targets()[0].val).toLocaleString() } })
  }, '-=0.2')
  .from('.wanted-footer', { opacity: 0, duration: 0.4 }, '-=1')
}

function initSkills() {
  const cards = gsap.utils.toArray('.haki-card')
  const tl = gsap.timeline({
    scrollTrigger: { trigger: '.skills', start: 'top top', end: '+=200%', scrub: 1, pin: '.skills-pinned' }
  })
  cards.forEach((card, i) => {
    const skills = card.querySelectorAll('.haki-skills li')
    tl.to(card, { opacity: 1, y: 0, duration: 1, ease: 'power2.out' })
    .to(skills, { opacity: 1, x: 0, stagger: 0.15, duration: 0.5 }, '-=0.5')
    .to(card, { duration: 0.5 })
    if (i < cards.length - 1) { tl.to(card, { opacity: 0, y: -40, duration: 0.5 }) }
  })
}

function initProjects() {}
function initContact() {}

initHero()
initSkills()
initProjects()
initContact()

Your skills section now pins, scrubs, and reveals each Haki card with a polished stagger. Next up, you'll build the horizontal-scrolling projects section where your bounty cards slide across the viewport.

Build the Projects Section

Your skills section pins and scrubs beautifully. Now you need a way to show off the actual work you've done.

In this step, you'll build a horizontal scroll section where your project "bounty cards" slide across the screen as the user scrolls vertically. This is a signature advanced ScrollTrigger pattern that immediately signals frontend skill to anyone reviewing your portfolio.

In this step, get ready to:
  • Understand the horizontal scroll container structure already in your HTML.
  • Wire up GSAP ScrollTrigger to drive horizontal movement from vertical scrolling.
  • See how CSS scroll-driven animations handle per-card reveals without JavaScript.
Understand the horizontal scroll container

Horizontal scroll with GSAP requires a specific HTML structure. Your index.html already has the pieces in place from the setup step.

  • Switch back to index.html in VS Code and find the .projects section.
<section class="projects" id="projects">
  <div class="projects-track">
    <div class="project-card">...</div>
    <div class="project-card">...</div>
    <div class="project-card">...</div>
    <div class="project-card">...</div>
  </div>
</section>

Why this structure enables horizontal scroll

The outer .projects section has overflow: hidden so nothing spills out visually. ScrollTrigger pins this section in place while we animate.

The inner .projects-track uses display: flex and width: fit-content so it grows as wide as its children demand. With four 380px cards plus gaps, it ends up roughly 4x the viewport width.

Wire up the horizontal ScrollTrigger

The trick to horizontal scroll is simple: pin the outer section, then animate the track's x position leftward as the user scrolls vertically. GSAP's scrub ties animation progress to scroll position so the movement feels natural.

  • Switch to src/main.js in VS Code.
  • Find the empty initProjects() function:
function initProjects() {}
  • Replace it with the following implementation:
function initProjects() {
  // Grab the inner track and calculate how far it needs to travel
  const track = document.querySelector('.projects-track')
  const totalScroll = track.scrollWidth - window.innerWidth

  // Pin the section and slide the track leftward as user scrolls
  gsap.to(track, {
    x: -totalScroll,
    ease: 'none',
    scrollTrigger: {
      trigger: '.projects',
      start: 'top top',
      end: '+=' + totalScroll,
      scrub: 1,
      pin: true
    }
  })
}

What does this code do?

  • track.scrollWidth - window.innerWidth calculates the exact pixel distance the track needs to travel so the last card reaches the right edge of the viewport.
  • x: -totalScroll moves the track leftward by that distance over the course of the scroll.
  • ease: 'none' keeps the movement perfectly linear. Any easing would make the scroll feel unnatural since scrub already follows the user's scroll speed.
  • end: '+=' + totalScroll sets the scroll distance to match the track width, giving pixel-perfect horizontal travel.
  • pin: true locks the .projects section in place while the horizontal animation plays out.
  • Save src/main.js.
  • Return to your browser at localhost:3000. Scroll past the skills section. You should see the projects section pin in place and the bounty cards slide horizontally as you scroll down.

Cards not scrolling horizontally?

  • Make sure you replaced the entire function initProjects() {} line, not just inserted code inside the old braces. The function should have const track as its first line.
  • Check that initProjects() is still being called at the bottom of the file. It should appear after initSkills().
  • Open your browser console. If you see a Cannot read properties of null error, double check that your HTML has a .projects-track element inside the .projects section.

Help me debug my horizontal scroll section.

Understand the CSS scroll-driven card reveals

You might have noticed the cards don't just sit there at full opacity. Each card subtly scales up and fades in as it enters the horizontal viewport. This is handled entirely by CSS with zero JavaScript overhead.

  • Switch to src/style.css and find the .project-card rule. Look at these three properties near the end of the selector:
/* These three lines drive the per-card reveal animation */
animation: cardReveal linear both;
animation-timeline: view(inline);
animation-range: entry 0% cover 40%;

What is the hybrid approach?

GSAP handles the complex orchestration: pinning the section and translating the track. CSS handles the simple per-card reveal using animation-timeline: view(inline), which ties an animation to the element's visibility on the inline (horizontal) axis.

The animation-range: entry 0% cover 40% means the card animates from the moment it enters the viewport to when it covers 40% of the viewport width. After that, it sits at full size and opacity.

This is how professional scroll-heavy sites are built. You give the complex problems to GSAP and let the browser handle simple reveals natively.

What about Firefox?

CSS scroll-driven animations have zero support in Firefox as of mid-2026. On Firefox, cards simply appear at their default opacity (0.7) without the reveal animation. This graceful degradation is acceptable for a portfolio site.

  • Return to your browser and scroll through the projects section slowly. In Chrome or Edge, you should see each card scale up and brighten as it enters from the right side.

✔️ Awesome, I've got everything!

Great. Make sure you've saved src/main.js before moving on.

ⓧ I'd like to double check the full code

Here is the full src/main.js as it should look right now:

import 'lenis/dist/lenis.css'
import Lenis from 'lenis'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText } from 'gsap/SplitText'

gsap.registerPlugin(ScrollTrigger, SplitText)

const lenis = new Lenis()
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => { lenis.raf(time * 1000) })
gsap.ticker.lagSmoothing(0)

function initHero() {
  const split = new SplitText('.bounty-name', { type: 'chars' })
  const tl = gsap.timeline({ delay: 0.3 })
  tl.from('.wanted-header', { y: -80, opacity: 0, duration: 0.6, ease: 'back.out(1.7)' })
  .from(split.chars, { opacity: 0, y: 20, rotateX: -90, stagger: 0.04, duration: 0.5, ease: 'back.out(1.7)' }, '-=0.2')
  .from('.bounty-title', { opacity: 0, y: 20, duration: 0.4 }, '-=0.2')
  .from('.bounty-amount', { opacity: 0, scale: 0.8, duration: 0.4, ease: 'back.out(2)' }, '-=0.1')
  .add(() => {
    const bountyEl = document.querySelector('.bounty-value')
    const target = parseInt(bountyEl.dataset.value)
    gsap.to({ val: 0 }, { val: target, duration: 1.5, ease: 'power2.out', onUpdate: function() { bountyEl.textContent = '$' + Math.floor(this.targets()[0].val).toLocaleString() } })
  }, '-=0.2')
  .from('.wanted-footer', { opacity: 0, duration: 0.4 }, '-=1')
}

function initSkills() {
  const cards = gsap.utils.toArray('.haki-card')
  const tl = gsap.timeline({
    scrollTrigger: { trigger: '.skills', start: 'top top', end: '+=200%', scrub: 1, pin: '.skills-pinned' }
  })
  cards.forEach((card, i) => {
    const skills = card.querySelectorAll('.haki-skills li')
    tl.to(card, { opacity: 1, y: 0, duration: 1, ease: 'power2.out' })
    .to(skills, { opacity: 1, x: 0, stagger: 0.15, duration: 0.5 }, '-=0.5')
    .to(card, { duration: 0.5 })
    if (i < cards.length - 1) { tl.to(card, { opacity: 0, y: -40, duration: 0.5 }) }
  })
}

function initProjects() {
  const track = document.querySelector('.projects-track')
  const totalScroll = track.scrollWidth - window.innerWidth
  gsap.to(track, {
    x: -totalScroll,
    ease: 'none',
    scrollTrigger: { trigger: '.projects', start: 'top top', end: '+=' + totalScroll, scrub: 1, pin: true }
  })
}

function initContact() {}

initHero()
initSkills()
initProjects()
initContact()

Your bounty board slides across the screen exactly like the showcase sites on Awwwards. Next up, you'll bring the contact section to life and deploy the whole thing.

Build the Contact Section and Deploy

Your horizontal scroll projects section is working. Visitors can scroll vertically and watch bounty cards slide across the screen.

Now it is time to bring the final section to life. The contact section needs a scroll-triggered entrance animation that reveals your "Form an Alliance" heading character by character. Once that is working, you will build the production bundle and deploy the full site to Vercel.

In this step, get ready to:
  • Animate the contact section with SplitText and a scroll-triggered timeline.
  • Build the production bundle and deploy to Vercel.
  • Verify all four animated sections work on your live URL.
Review the contact section structure

The contact HTML and CSS are already in place from Step 1. You have a section with the class .contact containing three elements that the animation will target.

  • Switch back to index.html in VS Code and scroll to the bottom.
  • Confirm you see the .contact section with a heading, paragraph, and three links.

What are the animation targets?

Your animation will target three selectors in sequence:

  • .contact-heading ("Form an Alliance") will split into individual characters and reveal one by one.
  • .contact-cta (the call-to-action paragraph) will fade up.
  • .contact-link (the three link buttons) will stagger in from below.
Add the scroll-triggered entrance animation

Unlike the skills section which pins and scrubs, the contact animation fires once when the section scrolls into view. This creates a satisfying reveal as the visitor reaches the bottom of the page.

  • In src/main.js, find the empty initContact() function:
function initContact() {}
  • Replace it with the full contact animation by pasting this code in its place:
// Contact: Scroll-triggered reveal
function initContact() {
  const split = new SplitText('.contact-heading', { type: 'chars' })
  const tl = gsap.timeline({
    scrollTrigger: {
      trigger: '.contact',
      start: 'top 80%'
    }
  })

  tl.from(split.chars, {
    opacity: 0,
    y: 30,
    stagger: 0.03,
    duration: 0.4,
    ease: 'back.out(1.7)'
  })
  .from('.contact-cta', {
    opacity: 0,
    y: 20,
    duration: 0.5
  }, '-=0.2')
  .from('.contact-link', {
    opacity: 0,
    y: 30,
    stagger: 0.1,
    duration: 0.4,
    ease: 'back.out(1.7)'
  }, '-=0.2')
}

What does this code do?

  • SplitText('.contact-heading', { type: 'chars' }) breaks the heading text into individual character spans that GSAP can animate independently.
  • The ScrollTrigger uses start: 'top 80%' so the animation fires when the section's top edge crosses 80% down the viewport. No pin, no scrub.
  • stagger: 0.03 on the characters creates a rapid wave effect. The '-=0.2' offsets overlap each tween so elements don't wait for the previous one to finish completely.
  • back.out(1.7) gives each element a slight overshoot that settles back, adding a playful bounce.
  • Save src/main.js.
  • Return to your browser and scroll all the way to the bottom of the page. You should see "Form an Alliance" reveal character by character, followed by the call-to-action and the three link buttons staggering in from below.

Contact section not animating?

  • Make sure you replaced the entire empty function initContact() {} line, not just the contents inside the braces.
  • Check the browser console for errors. A typo in .contact-heading will cause SplitText to fail silently.
  • Scroll far enough down. The animation only fires when the section's top crosses 80% of the viewport height.

Why isn't my contact section animation triggering on scroll?

Why does Lenis make this feel smoother?

The Lenis smooth scroll you set up in Step 1 is already synced to GSAP's ticker. This means scroll position updates and animation frames are perfectly coordinated. Without Lenis, native scroll events can fire out of sync with the animation loop, causing subtle jitter in scroll-triggered animations.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on to deployment.

ⓧ I'd like to double check the full code

Here is the complete src/main.js with all four animation functions:

import 'lenis/dist/lenis.css'
import Lenis from 'lenis'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText } from 'gsap/SplitText'

gsap.registerPlugin(ScrollTrigger, SplitText)

const lenis = new Lenis()
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => { lenis.raf(time * 1000) })
gsap.ticker.lagSmoothing(0)

function initHero() {
  const split = new SplitText('.bounty-name', { type: 'chars' })
  const tl = gsap.timeline({ delay: 0.3 })
  tl.from('.wanted-header', { y: -80, opacity: 0, duration: 0.6, ease: 'back.out(1.7)' })
  .from(split.chars, { opacity: 0, y: 20, rotateX: -90, stagger: 0.04, duration: 0.5, ease: 'back.out(1.7)' }, '-=0.2')
  .from('.bounty-title', { opacity: 0, y: 20, duration: 0.4 }, '-=0.2')
  .from('.bounty-amount', { opacity: 0, scale: 0.8, duration: 0.4, ease: 'back.out(2)' }, '-=0.1')
  .add(() => {
    const bountyEl = document.querySelector('.bounty-value')
    const target = parseInt(bountyEl.dataset.value)
    gsap.to({ val: 0 }, { val: target, duration: 1.5, ease: 'power2.out', onUpdate: function() { bountyEl.textContent = '$' + Math.floor(this.targets()[0].val).toLocaleString() } })
  }, '-=0.2')
  .from('.wanted-footer', { opacity: 0, duration: 0.4 }, '-=1')
}

function initSkills() {
  const cards = gsap.utils.toArray('.haki-card')
  const tl = gsap.timeline({
    scrollTrigger: { trigger: '.skills', start: 'top top', end: '+=200%', scrub: 1, pin: '.skills-pinned' }
  })
  cards.forEach((card, i) => {
    const skills = card.querySelectorAll('.haki-skills li')
    tl.to(card, { opacity: 1, y: 0, duration: 1, ease: 'power2.out' })
    .to(skills, { opacity: 1, x: 0, stagger: 0.15, duration: 0.5 }, '-=0.5')
    .to(card, { duration: 0.5 })
    if (i < cards.length - 1) { tl.to(card, { opacity: 0, y: -40, duration: 0.5 }) }
  })
}

function initProjects() {
  const track = document.querySelector('.projects-track')
  const totalScroll = track.scrollWidth - window.innerWidth
  gsap.to(track, {
    x: -totalScroll,
    ease: 'none',
    scrollTrigger: { trigger: '.projects', start: 'top top', end: '+=' + totalScroll, scrub: 1, pin: true }
  })
}

function initContact() {
  const split = new SplitText('.contact-heading', { type: 'chars' })
  const tl = gsap.timeline({ scrollTrigger: { trigger: '.contact', start: 'top 80%' } })
  tl.from(split.chars, { opacity: 0, y: 30, stagger: 0.03, duration: 0.4, ease: 'back.out(1.7)' })
  .from('.contact-cta', { opacity: 0, y: 20, duration: 0.5 }, '-=0.2')
  .from('.contact-link', { opacity: 0, y: 30, stagger: 0.1, duration: 0.4, ease: 'back.out(1.7)' }, '-=0.2')
}

initHero()
initSkills()
initProjects()
initContact()
Build and deploy to Vercel

All four sections are now animated. Time to create a production build and push to GitHub so Vercel deploys automatically.

  • Create the production bundle by running this command in your terminal:
npm run build

What does this command do?

Vite uses Rollup under the hood to bundle your JavaScript, CSS, and HTML into optimized static files in the dist/ folder. It tree-shakes unused code, minifies everything, and hashes filenames for cache-busting.

  • You should see output listing the generated files in dist/ with their file sizes.
  • Push your changes to GitHub by running these commands:
git add .
git commit -m "Add contact animation and production build"
git push

Vercel detects the push and automatically deploys your updated site. This typically takes 30-60 seconds.

  • Open your Vercel dashboard and wait for the deployment to show a green Ready status.
  • Click your live URL to open the deployed site.

Build failing?

  • Check the terminal output for syntax errors. A missing comma or unclosed brace in main.js will cause the build to fail.
  • Make sure your dev server is not blocking the terminal. Stop it with Ctrl+C before running the build command if needed.

Why is my Vite production build failing?

Verify all animations on the live site
  • On your live Vercel URL, verify each section works:
  • The hero loads immediately with the "WANTED" header dropping in, name characters staggering, and the bounty counting up.
  • Scrolling into the skills section pins the container and cycles through the three Haki cards with staggering skill items.
  • The projects section pins and scrolls horizontally as you scroll vertically, with cards revealing as they enter view.
  • The contact section triggers "Form an Alliance" character-by-character, followed by the CTA and link buttons.

What about Firefox?

The CSS animation-timeline: view(inline) on project cards is not supported in Firefox. This is expected. On Firefox, cards will appear at their default opacity without the scale reveal. All GSAP-powered animations (hero, skills, horizontal scroll, contact) work in every modern browser.

Your Grand Line portfolio is live. You have a production-quality scroll-driven site with pinning, scrubbing, horizontal scroll, and character-level text reveals. Next up, the Secret Mission adds a scroll progress indicator and accessibility support.

Secret mission

The Log Pose and Accessibility

Your portfolio looks impressive, but professional GSAP sites ship with two things yours is missing: a scroll progress indicator so visitors know where they are, and reduced-motion support so everyone can experience your content. Add both to level up from demo to production-ready.

Clean Up Your Resources

Clean Up Your Resources

This project runs on Vercel's free Hobby plan, so there are no ongoing costs while your site stays under 100 GB bandwidth per month. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Vercel deployment (free Hobby plan, no cost while under 100 GB bandwidth/month).
  • GitHub repository.
  • Local grand-line-portfolio folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want the site to stay live as a portfolio piece.

  • Your Vercel Hobby plan has no expiry and incurs $0 cost for static sites under 100 GB bandwidth per month.
  • Your GitHub repository stays available at no cost.
  • Your local project folder remains on your Desktop for future edits.

Pause - I'll come back to this later

Static sites on Vercel incur no compute costs when idle, so there is nothing to pause on the hosting side. To free up local resources:

  • Stop the Vite dev server if it is still running by pressing Ctrl+C in the terminal.
  • Your deployed site stays live at $0 cost. You can resume development later by opening the grand-line-portfolio folder and running npm run dev again.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Delete the Vercel project:
  • Open your Vercel dashboard and select the grand-line-portfolio project.
  • Click Settings in the sidebar.
  • Scroll to the bottom of the General page and find the Delete Project section.
  • Click Delete, enter the project name and confirmation prompt in the dialog, then click Continue.
  • Delete the GitHub repository:
  • Navigate to your repository on GitHub and click Settings.
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Confirm by typing the repository name, then click Delete this repository.
  • Delete the local project folder:

macOS

Run this command in your terminal:

rm -rf ~/Desktop/grand-line-portfolio

Windows

Run this command in PowerShell:

Remove-Item -Recurse -Force ~\Desktop\grand-line-portfolio
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see grand-line-portfolio in the output.

Nice Work!

Nice Work!

You just built and deployed a production-quality, scroll-driven portfolio site that proves you can create the kind of animation-heavy experiences seen on award-winning sites.

You've learned how to:

  • Build GSAP ScrollTrigger animations with pinning, scrubbing, and timeline choreography, including a horizontal scroll section driven by vertical scrolling.
  • Use SplitText for character-level text reveals and integrate Lenis smooth scroll synced to GSAP's ticker for polished, momentum-based scrolling.
  • Deploy a live Vite project to Vercel with a hybrid approach using CSS scroll-driven animations for simple reveals and GSAP for complex orchestration.
  • Secret Mission: Added a Log Pose scroll progress indicator and reduced-motion accessibility support using gsap.matchMedia().

Ready to quiz yourself?