Persona 5-Inspired Portfolio Website
Build an animated portfolio site with GSAP timelines and P5-style CSS.
Introduction
30 Second Summary
Every developer needs a portfolio. But most portfolios look the same. The ones that land interviews are the ones that prove your skills the moment someone opens the page.
In this project, you will build a personal portfolio website that channels the bold visual language of Persona 5. You will construct true parallelogram-shaped cards with layered wobbling frames, choreograph aggressive scroll-triggered animations, and wire up per-character text reveals.
What You'll Build
A fully animated single-page portfolio powered by GSAP timelines and CSS clip-path polygon shapes, styled with the iconic crimson-and-black Persona 5 aesthetic.
By the end of this project, you'll have:
- A Vite-scaffolded project with five animated sections (Loading, Hero, Projects, Skills, Contact) and a production build.
- A faithful P5-style card component built with layered polygon frames that wobble independently on a 1-second loop.
- A GSAP-powered animation system featuring ScrollTrigger scroll-driven reveals, SplitText per-character effects, and timeline-orchestrated transitions.
- Secret Mission: an "All-Out Attack" screen-wipe transition triggered by a keyboard shortcut, complete with a radial burst and victory splash text.
Are there any prerequisites?
This is an advanced project. You should be comfortable with HTML, CSS, and JavaScript fundamentals before starting.
Familiarity with npm and the command line is also expected.
Scaffold the Project
Every animation-heavy website needs a solid foundation. Before you can build parallelogram-shaped cards and aggressive scroll-driven entrances, you need a build tool that handles TypeScript compilation and hot-reloading without getting in the way.
In this step, you will scaffold a Vite project, install the GSAP animation library, and configure the project structure that will hold your Persona 5-inspired portfolio.
In this step, get ready to:
- Scaffold a Vite project with the vanilla TypeScript template.
- Install GSAP and create the project folder structure.
- Configure Google Fonts and project files.
Scaffold a new Vite project
Vite is a build tool that gives you instant server start and lightning-fast hot module replacement. It compiles your TypeScript on the fly so you see changes in the browser immediately.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your new project by running this command:
npm create vite@latest
What will this wizard ask?
The Vite scaffold wizard will ask you three questions. Here is how to answer each one:
- When prompted for Project name, type persona5-portfolio and press Enter.
- When prompted to Select a framework, choose Vanilla and press Enter.
- When prompted to Select a variant, choose TypeScript and press Enter.
- If the wizard asks whether to install and start now, choose No.
- Move into your new project folder and install its base dependencies by running these commands:
cd persona5-portfolio
npm install
- Verify the scaffold worked by starting the dev server:
npm run dev
- You should see a local URL (like http://localhost:5173) printed in the terminal. Open it in your browser to confirm you see the default Vite page.
- Press Ctrl+C in the terminal to stop the dev server.
Don't see the local URL?
- Make sure you ran npm install before npm run dev. The install step downloads Vite itself.
- Check that your Node.js version is 20.19+ by running node --version. Vite 8 requires this minimum.
Help me troubleshoot why my Vite dev server is not starting.
Install GSAP and create the folder structure
GSAP is the animation engine that will power every timeline, scroll-triggered entrance, and per-character text reveal in your portfolio. You will also reorganize the default scaffold into a clean folder structure for styles, scripts, and assets.
- Install GSAP by running this command:
npm install gsap@3.15.0
What is GSAP?
GSAP (GreenSock Animation Platform) is a professional-grade animation library. It provides timeline orchestration, scroll-driven triggers, and per-character text splitting. All plugins (including ScrollTrigger and SplitText) are free and bundled in the main package.
- Clear out the default scaffold files in src/ and create your project's folder structure by running these commands:
rm -rf src/*
mkdir -p src/styles src/scripts/animations src/assets
- Create empty placeholder files so Vite can find your entry points:
touch src/styles/main.css src/scripts/main.ts
- Confirm the structure exists by running:
ls src/styles src/scripts src/assets
- You should see main.css listed under src/styles, main.ts and animations listed under src/scripts, and an empty src/assets directory.
Seeing 'No such file or directory'?
- Make sure you are inside the persona5-portfolio folder. Run pwd to check your current location.
- On Windows, use PowerShell instead of Command Prompt. The mkdir -p flag works in PowerShell and Git Bash.
Help me fix folder creation issues in my Vite project.
Configure index.html and project files
Your portfolio uses two Google Fonts that approximate Persona 5's bold, condensed typography. You will replace the scaffolded index.html with a clean version that preloads these fonts and points to your new file structure.
- Open the persona5-portfolio folder in VS Code. In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the persona5-portfolio folder, and click Open.
- Open index.html from the Explorer sidebar. Select all its contents and replace them with the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Phantom Portfolio</title>
<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=Big+Shoulders+Display:wght@400;700;900&family=Barlow+Condensed:ital,wght@0,400;0,700;1,700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/styles/main.css" />
</head>
<body>
<script type="module" src="/src/scripts/main.ts"></script>
</body>
</html>
What does this HTML set up?
- The preconnect links tell the browser to start the connection to Google Fonts early, reducing load time.
- Big Shoulders Display is the tall, condensed display font for headings. Barlow Condensed is the slightly rounded body font.
- The stylesheet and script paths point to your new folder structure (src/styles/main.css and src/scripts/main.ts).
- Save index.html.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Phantom Portfolio</title>
<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=Big+Shoulders+Display:wght@400;700;900&family=Barlow+Condensed:ital,wght@0,400;0,700;1,700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/styles/main.css" />
</head>
<body>
<script type="module" src="/src/scripts/main.ts"></script>
</body>
</html>
- Now create the Vite configuration file. In the Explorer sidebar, right-click in the root of your project and select New File.
- Name it vite.config.ts and press Enter.
- Paste the following content into vite.config.ts:
import { defineConfig } from 'vite';
export default defineConfig({
root: '.',
build: {
outDir: 'dist',
},
});
What does this config do?
- The root property tells Vite that index.html lives in the project root (the default, but explicit here for clarity).
- The outDir property tells Vite to output production builds into a dist/ folder.
- Save vite.config.ts.
- Start the dev server again to verify everything is wired up correctly:
npm run dev
- Open the localhost URL from your terminal in your browser. You should see a blank white page with no console errors. This means Vite is serving your empty index.html successfully.
Seeing a 404 or module error?
- Confirm that src/scripts/main.ts and src/styles/main.css exist (even if empty). Vite will error if the paths in index.html point to missing files.
- Check that your index.html is at the project root (not inside src/).
Help me debug why my Vite dev server shows a 404 or module not found error.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before continuing.
ⓧ I'd like to double check the full code
Here are all the project files you should have at this point:
import { defineConfig } from 'vite';
export default defineConfig({
root: '.',
build: {
outDir: 'dist',
},
});
{
"name": "persona5-portfolio",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.8.0",
"vite": "^8.0.0"
},
"dependencies": {
"gsap": "^3.15.0"
}
}
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
Your project skeleton is ready. Next up, you will build the Persona 5 design system with CSS custom properties and the signature layered-frame card component.
Build the Design System
Your project scaffolding is ready and GSAP is installed. Now you need a visual foundation before writing any HTML.
Persona 5's interface is built on a strict set of design rules: crimson red on pure black, angular parallelogram shapes, aggressive motion timing. In this step, you'll translate those rules into a CSS design system with reusable tokens, base styles, and the signature P5 card component.
In this step, get ready to:
- Define design tokens for colors, typography, spacing, and animation timing.
- Build the P5 card component with layered polygon frames and wobble animation.
- Create supporting components (button, dialogue box, section layouts).
Create the design tokens and base styles
Design tokens are CSS custom properties that store your entire visual language in one place. Every color, font size, and timing curve lives in a single file so you can tweak the palette without hunting through dozens of selectors.
- In VS Code, create a new file at src/styles/tokens.css and add the following code:
:root {
/* Colors */
--p5-red: #ED1C24;
--p5-black: #0A0A0A;
--p5-white: #FFFFFF;
--p5-yellow: #FFD700;
--p5-dark-gray: #1A1A1A;
--p5-mid-gray: #2A2A2A;
/* Typography */
--font-display: 'Big Shoulders Display', sans-serif;
--font-body: 'Barlow Condensed', sans-serif;
--fs-hero: clamp(4rem, 10vw, 8rem);
--fs-section-title: clamp(2.5rem, 6vw, 5rem);
--fs-card-title: clamp(1.5rem, 3vw, 2.5rem);
--fs-body: clamp(1rem, 1.5vw, 1.25rem);
/* Spacing */
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 2rem;
--space-lg: 4rem;
--space-xl: 8rem;
/* Animation */
--ease-slam: cubic-bezier(0.68, -0.55, 0.265, 1.55);
--ease-smooth: cubic-bezier(0.25, 0.1, 0.25, 1);
--duration-fast: 0.3s;
--duration-normal: 0.6s;
--duration-slow: 1.2s;
/* Card polygon shapes (P5 parallelogram) */
--card-poly-white: polygon(6% 0, 100% 0, 90% 100%, 0 87%);
--card-poly-black: polygon(6% 0, 100% 0, 95% 100%, 0 87%);
}
What does this code do?
Every visual decision lives inside this single :root block. The clamp() functions make typography fluid between mobile and desktop without separate breakpoints.
The --ease-slam cubic-bezier has a negative value that creates an overshoot effect. Elements fly past their target then snap back. The --card-poly-white and --card-poly-black properties store the clip-path coordinates for the parallelogram card frames.
- Save src/styles/tokens.css.
Now create the global reset and section base styles.
- Create a new file at src/styles/base.css and add the following code:
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: auto;
overflow-x: hidden;
}
body {
font-family: var(--font-body);
font-size: var(--fs-body);
color: var(--p5-white);
background-color: var(--p5-black);
overflow-x: hidden;
line-height: 1.6;
}
.p5-section {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
padding: var(--space-lg) var(--space-md);
overflow: hidden;
}
.section-title {
font-family: var(--font-display);
font-size: var(--fs-section-title);
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--p5-white);
margin-bottom: var(--space-lg);
position: relative;
}
.section-title::after {
content: '';
position: absolute;
bottom: -0.5rem;
left: 0;
width: 3rem;
height: 4px;
background: var(--p5-red);
transform: skewX(-20deg);
}
What does this code do?
The universal selector reset clears browser defaults. The body references your token variables directly. No magic numbers anywhere.
The .p5-section class gives every section full-viewport height with centered flexbox. The .section-title uses the display font at the section title size with a skewed red underline pseudo-element. This is the bold condensed uppercase treatment that Persona 5 applies to all its menu headings.
- Save src/styles/base.css.
- Create a new file at src/styles/main.css with the following import statements:
@import './tokens.css';
@import './base.css';
@import './components.css';
- Save src/styles/main.css.
- Check your browser. The page background should now be solid black (#0A0A0A) instead of the default white.
Still seeing a white background?
Your terminal may show an error because components.css does not exist yet. Create an empty file at src/styles/components.css for now. You will fill it in the next substep.
Also confirm that index.html has <link rel="stylesheet" href="/src/styles/main.css" /> in the <head>.
help me debug my CSS imports not loading
Build the P5 card with layered polygon frames
The signature Persona 5 UI element is a parallelogram-shaped box with two visible frame layers. A white outer frame and a black inner frame each wobble independently on a 1-second loop, creating the "living frame" effect.
How does the layered-frame technique work?
Each frame is an absolutely-positioned div with a clip-path: polygon() that cuts it into a parallelogram shape. The black frame is slightly inset (smaller on all sides) so the white frame peeks out around the edges. Content sits on top at z-index 3, unaffected by the clipping.
CSS @keyframes shift the polygon coordinates by 1-2% on each frame layer independently. Because they move out of sync, the card appears alive.
- Open src/styles/components.css (create it if you made an empty placeholder earlier) and add the core card structure with its wobble keyframes:
/* ===========================
P5 CARD - Layered Polygon Frame
The signature Persona 5 box style:
- White outer parallelogram frame
- Black inner parallelogram frame (slightly inset)
- Both wobble independently on a 1s loop
- Content sits inside readable and unaffected
=========================== */
.p5-card {
position: relative;
width: 100%;
max-width: 320px;
min-height: 200px;
transform: rotate(var(--card-rotate, 0deg));
}
.card-frame-white {
position: absolute;
inset: 0;
background-color: var(--p5-white);
clip-path: var(--card-poly-white);
z-index: 1;
animation: frameWhiteWobble 1s infinite;
}
.card-frame-black {
position: absolute;
top: 5px;
left: 5px;
right: 20px;
bottom: 7px;
background-color: var(--p5-black);
clip-path: var(--card-poly-black);
z-index: 2;
animation: frameBlackWobble 1s infinite;
}
.card-content {
position: relative;
z-index: 3;
padding: 30px 40px 25px 35px;
color: var(--p5-white);
}
/* Wobble keyframes - each frame moves independently by 1-2% */
@keyframes frameWhiteWobble {
0% { clip-path: polygon(6% 0, 100% 0, 90% 100%, 0 87%); }
25% { clip-path: polygon(6% 0, 98% 1%, 90% 100%, 1% 88%); }
50% { clip-path: polygon(6% 2%, 100% 0, 89% 100%, 0 87%); }
75% { clip-path: polygon(6% 0, 100% 0, 90% 99%, 0 87%); }
100% { clip-path: polygon(6% 0, 100% 0, 90% 100%, 0 87%); }
}
@keyframes frameBlackWobble {
0% { clip-path: polygon(6% 0, 100% 0, 95% 100%, 0 87%); }
25% { clip-path: polygon(7% 0, 100% 1%, 94% 100%, 1% 86%); }
50% { clip-path: polygon(6% 1%, 99% 0, 95% 99%, 0 87%); }
75% { clip-path: polygon(6% 0, 100% 0, 95% 100%, 1% 88%); }
100% { clip-path: polygon(6% 0, 100% 0, 95% 100%, 0 87%); }
}
What does this code do?
The .card-frame-white uses inset: 0 to fill the entire card area, then clip-path cuts it into a parallelogram. The .card-frame-black is offset inward (top: 5px, right: 20px) so the white frame is visible around the edges.
Each keyframe shifts polygon coordinates by just 1-2%. The white and black frames use different timing offsets, so they appear to move independently. This creates the characteristic P5 "living" frame feel.
- Save components.css.
- You will not see the card yet because no HTML uses these classes. The next step adds the markup.
Now add the hover intensification and card content typography styles below the wobble keyframes.
- In src/styles/components.css, add this code below the frameBlackWobble keyframe:
/* Hover intensification: wobble shifts increase to 3-4% */
.p5-card:hover .card-frame-white {
animation-name: frameWhiteWobbleIntense;
}
.p5-card:hover .card-frame-black {
animation-name: frameBlackWobbleIntense;
}
@keyframes frameWhiteWobbleIntense {
0% { clip-path: polygon(6% 0, 100% 0, 90% 100%, 0 87%); }
25% { clip-path: polygon(4% 0, 97% 3%, 91% 100%, 2% 89%); }
50% { clip-path: polygon(7% 3%, 100% 0, 88% 99%, 0 85%); }
75% { clip-path: polygon(5% 0, 100% 1%, 91% 98%, 1% 88%); }
100% { clip-path: polygon(6% 0, 100% 0, 90% 100%, 0 87%); }
}
@keyframes frameBlackWobbleIntense {
0% { clip-path: polygon(6% 0, 100% 0, 95% 100%, 0 87%); }
25% { clip-path: polygon(8% 1%, 99% 2%, 93% 99%, 2% 85%); }
50% { clip-path: polygon(5% 2%, 98% 0, 96% 98%, 1% 88%); }
75% { clip-path: polygon(7% 0, 100% 1%, 94% 100%, 0 86%); }
100% { clip-path: polygon(6% 0, 100% 0, 95% 100%, 0 87%); }
}
/* Card content elements */
.card-number {
font-family: var(--font-display);
font-size: 4rem;
font-weight: 900;
color: var(--p5-red);
opacity: 0.4;
position: absolute;
top: 10px;
right: 30px;
z-index: 4;
line-height: 1;
}
.card-title {
font-family: var(--font-display);
font-size: var(--fs-card-title);
font-weight: 900;
text-transform: uppercase;
margin-top: var(--space-md);
}
.card-subtitle {
font-size: 0.9rem;
color: var(--p5-red);
font-style: italic;
font-weight: 700;
margin-top: var(--space-xs);
}
What does this code do?
On hover, the animation-name swaps to the "intense" keyframes where polygon points shift by 3-4% instead of 1-2%. The wobble gets noticeably more aggressive when you mouse over a card.
The .card-number sits at z-index 4 (above content) as a giant faded red number. The .card-title and .card-subtitle use the display font and red italic respectively, matching P5's typography hierarchy.
- Save components.css.
Build supporting component patterns
The card is the hero component, but you need several supporting patterns: a dialogue box variant with a speech-bubble arrow, a skewed button, a projects grid layout, and section-specific utility styles for loading, hero, stats, contact, and transition overlays.
- In src/styles/components.css, add the dialogue box variant and arrow elements below the .card-subtitle rule:
/* Dialogue box variant */
.p5-card--dialogue {
max-width: 600px;
}
.card-arrow-white {
position: absolute;
width: 25px;
height: 25px;
bottom: 20px;
left: -17px;
z-index: 1;
background-color: var(--p5-white);
clip-path: polygon(62% 43%, 100% 17%, 100% 73%, 50% 100%, 44% 56%, 0 71%, 55% 8%);
animation: arrowWhiteWobble 1s infinite;
}
.card-arrow-black {
position: absolute;
width: 28px;
height: 20px;
bottom: 22px;
left: -17px;
z-index: 2;
background-color: var(--p5-black);
clip-path: polygon(62% 43%, 100% 26%, 100% 73%, 53% 80%, 44% 56%, 0 71%, 56% 19%);
animation: arrowBlackWobble 1s infinite;
}
@keyframes arrowWhiteWobble {
0% { clip-path: polygon(62% 43%, 100% 17%, 100% 73%, 50% 100%, 44% 56%, 0 71%, 55% 8%); }
25% { clip-path: polygon(62% 43%, 100% 18%, 99% 73%, 50% 99%, 44% 56%, 0 72%, 55% 8%); }
50% { clip-path: polygon(61% 43%, 98% 17%, 100% 73%, 48% 100%, 43% 56%, 1% 71%, 56% 8%); }
75% { clip-path: polygon(62% 44%, 100% 17%, 100% 72%, 50% 100%, 44% 58%, 0 71%, 55% 9%); }
100% { clip-path: polygon(62% 43%, 100% 17%, 100% 73%, 50% 100%, 44% 56%, 0 71%, 55% 8%); }
}
@keyframes arrowBlackWobble {
0% { clip-path: polygon(62% 43%, 100% 26%, 100% 73%, 53% 80%, 44% 56%, 0 71%, 56% 19%); }
25% { clip-path: polygon(63% 43%, 100% 26%, 98% 73%, 53% 80%, 42% 56%, 1% 71%, 56% 19%); }
50% { clip-path: polygon(62% 44%, 100% 27%, 100% 72%, 53% 81%, 44% 57%, 0 74%, 56% 18%); }
75% { clip-path: polygon(62% 43%, 99% 26%, 100% 73%, 54% 80%, 44% 56%, 0 71%, 57% 19%); }
100% { clip-path: polygon(62% 43%, 100% 26%, 100% 73%, 53% 80%, 44% 56%, 0 71%, 56% 19%); }
}
What does this code do?
The .p5-card--dialogue modifier widens the card to 600px for the contact section. The arrow elements mimic P5's speech-bubble pointer using a complex 7-point polygon.
Just like the card frames, the white and black arrows each get their own wobble keyframes shifting by 1-2%. The white arrow is slightly larger, so the black one sits inside it.
- Save components.css.
- Add the projects grid layout and the P5 button below the arrow keyframes:
/* Projects Grid */
.projects-grid {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
width: 100%;
max-width: 900px;
}
@media (min-width: 768px) {
.projects-grid {
flex-direction: row;
justify-content: center;
}
}
/* P5 Button - Skewed parallelogram with counter-skewed label */
.p5-button {
display: inline-block;
padding: var(--space-xs) var(--space-lg);
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 700;
text-transform: uppercase;
text-decoration: none;
color: var(--p5-white);
background: var(--p5-red);
border: none;
transform: skewX(-5deg);
position: relative;
overflow: hidden;
cursor: pointer;
transition: background var(--duration-fast), transform var(--duration-fast);
}
.p5-button span {
display: inline-block;
transform: skewX(5deg);
}
.p5-button:hover {
background: var(--p5-white);
color: var(--p5-black);
transform: skewX(-5deg) scale(1.05);
}
What does this code do?
The .projects-grid stacks cards vertically on mobile and switches to a horizontal row at 768px.
The .p5-button uses transform: skewX(-5deg) to create a parallelogram shape. The inner span applies the opposite skewX(5deg) so text stays readable while the container appears skewed. On hover, it flips to white-on-black and scales up slightly.
- Save components.css.
- Add the section layout styles below the button hover rule. These prepare the loading screen, hero, stats, contact, transition wipe, and all-out attack overlay for when you build the HTML in upcoming steps:
/* Loading Screen */
.loading-screen {
position: fixed;
inset: 0;
z-index: 9999;
background: var(--p5-black);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.loading-lines {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.loading-line {
stroke: var(--p5-red);
stroke-width: 0.5;
stroke-dasharray: 150;
stroke-dashoffset: 150;
}
.loading-title {
font-family: var(--font-display);
font-size: clamp(3rem, 8vw, 6rem);
font-weight: 900;
text-align: center;
color: var(--p5-white);
letter-spacing: 0.1em;
opacity: 0;
z-index: 1;
}
/* Hero */
.hero-section { background: var(--p5-black); }
.hero-bg-shapes {
position: absolute;
inset: 0;
pointer-events: none;
}
.diagonal-slash {
position: absolute;
width: 200%;
height: 120px;
background: var(--p5-red);
transform-origin: center;
}
.slash-1 { top: 20%; left: -50%; transform: rotate(45deg); opacity: 0.15; }
.slash-2 { top: 50%; left: -50%; transform: rotate(-45deg); opacity: 0.1; }
.slash-3 { top: 75%; left: -50%; transform: rotate(12deg); opacity: 0.08; }
.hero-content { text-align: center; z-index: 1; }
.hero-name {
font-family: var(--font-display);
font-size: var(--fs-hero);
font-weight: 900;
text-transform: uppercase;
line-height: 0.9;
color: var(--p5-white);
}
.hero-tagline {
font-size: clamp(1.2rem, 2.5vw, 2rem);
font-weight: 700;
font-style: italic;
color: var(--p5-red);
margin-top: var(--space-sm);
}
/* Stats */
.stats-container { width: 100%; max-width: 600px; }
.stat-row { margin-bottom: var(--space-md); }
.stat-label {
display: block;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.2rem;
text-transform: uppercase;
margin-bottom: var(--space-xs);
}
.stat-bar {
position: relative;
width: 100%;
height: 28px;
}
.stat-bar-frame-white {
position: absolute;
inset: 0;
background: var(--p5-white);
clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%);
animation: statBarWhiteWobble 1s infinite;
}
.stat-bar-frame-black {
position: absolute;
top: 3px;
left: 4px;
right: 6px;
bottom: 3px;
background: var(--p5-dark-gray);
clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%);
animation: statBarBlackWobble 1s infinite;
}
.stat-bar-fill {
position: absolute;
top: 3px;
left: 4px;
bottom: 3px;
width: 0%;
background: var(--p5-red);
clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%);
z-index: 3;
}
@keyframes statBarWhiteWobble {
0% { clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%); }
50% { clip-path: polygon(2% 1%, 99% 0, 97% 99%, 1% 90%); }
100% { clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%); }
}
@keyframes statBarBlackWobble {
0% { clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%); }
50% { clip-path: polygon(3% 0, 100% 1%, 96% 100%, 0 89%); }
100% { clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%); }
}
.level-up-badge {
font-family: var(--font-display);
font-size: 2rem;
font-weight: 900;
color: var(--p5-yellow);
opacity: 0;
transform: scale(0) rotate(-10deg);
margin-top: var(--space-md);
}
/* Contact text */
.contact-text {
font-size: 1.2rem;
margin-bottom: var(--space-md);
color: var(--p5-white);
}
/* Section Wipe */
.section-wipe {
position: fixed;
inset: 0;
background: var(--p5-red);
z-index: 100;
transform: translateX(-101%);
pointer-events: none;
}
/* All-Out Attack Overlay */
.aoa-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
background: var(--p5-black);
}
.aoa-overlay.is-active { display: flex; }
.aoa-burst { position: absolute; inset: 0; }
.aoa-silhouette {
position: absolute;
width: 300px;
height: 400px;
background: var(--p5-red);
clip-path: polygon(50% 0%, 65% 30%, 100% 35%, 70% 55%, 80% 100%, 50% 70%, 20% 100%, 30% 55%, 0% 35%, 35% 30%);
opacity: 0;
}
.aoa-text {
font-family: var(--font-display);
font-size: clamp(3rem, 8vw, 6rem);
font-weight: 900;
color: var(--p5-yellow);
z-index: 1;
opacity: 0;
text-transform: uppercase;
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.loading-screen { display: none; }
.card-frame-white,
.card-frame-black,
.card-arrow-white,
.card-arrow-black,
.stat-bar-frame-white,
.stat-bar-frame-black {
animation: none !important;
}
}
What does this code do?
These are the layout styles for every section you will build in the next steps. The loading screen uses stroke-dashoffset for SVG line-draw animations. The hero uses diagonal slashes at low opacity for background texture.
The stat bars reuse the same layered polygon technique as the cards but at a narrow aspect ratio. The .section-wipe is a fixed red overlay that GSAP will animate between sections. The .aoa-overlay is hidden by default and only appears when the All-Out Attack shortcut fires.
The prefers-reduced-motion block kills all animation durations and hides the loading screen entirely. Users who prefer reduced motion see the final state immediately with no wobble.
- Save components.css.
- Confirm your dev server has no errors in the terminal. The browser should still show a solid black page with no visible content.
Seeing a CSS parse error in the terminal?
Check for unmatched curly braces. Each @keyframes block has an outer brace pair and inner braces for each percentage step. A missing closing } will break everything below it.
Also verify that main.css imports the files in order: tokens.css first, then base.css, then components.css. Tokens must load before any file that references them.
help me fix CSS parse errors in my components file
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: tokens.css, base.css, components.css, and main.css.
ⓧ I'd like to double check the full code
Here is the complete content for each file.
:root {
/* Colors */
--p5-red: #ED1C24;
--p5-black: #0A0A0A;
--p5-white: #FFFFFF;
--p5-yellow: #FFD700;
--p5-dark-gray: #1A1A1A;
--p5-mid-gray: #2A2A2A;
/* Typography */
--font-display: 'Big Shoulders Display', sans-serif;
--font-body: 'Barlow Condensed', sans-serif;
--fs-hero: clamp(4rem, 10vw, 8rem);
--fs-section-title: clamp(2.5rem, 6vw, 5rem);
--fs-card-title: clamp(1.5rem, 3vw, 2.5rem);
--fs-body: clamp(1rem, 1.5vw, 1.25rem);
/* Spacing */
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 2rem;
--space-lg: 4rem;
--space-xl: 8rem;
/* Animation */
--ease-slam: cubic-bezier(0.68, -0.55, 0.265, 1.55);
--ease-smooth: cubic-bezier(0.25, 0.1, 0.25, 1);
--duration-fast: 0.3s;
--duration-normal: 0.6s;
--duration-slow: 1.2s;
/* Card polygon shapes (P5 parallelogram) */
--card-poly-white: polygon(6% 0, 100% 0, 90% 100%, 0 87%);
--card-poly-black: polygon(6% 0, 100% 0, 95% 100%, 0 87%);
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: auto;
overflow-x: hidden;
}
body {
font-family: var(--font-body);
font-size: var(--fs-body);
color: var(--p5-white);
background-color: var(--p5-black);
overflow-x: hidden;
line-height: 1.6;
}
.p5-section {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
padding: var(--space-lg) var(--space-md);
overflow: hidden;
}
.section-title {
font-family: var(--font-display);
font-size: var(--fs-section-title);
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--p5-white);
margin-bottom: var(--space-lg);
position: relative;
}
.section-title::after {
content: '';
position: absolute;
bottom: -0.5rem;
left: 0;
width: 3rem;
height: 4px;
background: var(--p5-red);
transform: skewX(-20deg);
}
/* ===========================
P5 CARD - Layered Polygon Frame
The signature Persona 5 box style:
- White outer parallelogram frame
- Black inner parallelogram frame (slightly inset)
- Both wobble independently on a 1s loop
- Content sits inside readable and unaffected
=========================== */
.p5-card {
position: relative;
width: 100%;
max-width: 320px;
min-height: 200px;
transform: rotate(var(--card-rotate, 0deg));
}
.card-frame-white {
position: absolute;
inset: 0;
background-color: var(--p5-white);
clip-path: var(--card-poly-white);
z-index: 1;
animation: frameWhiteWobble 1s infinite;
}
.card-frame-black {
position: absolute;
top: 5px;
left: 5px;
right: 20px;
bottom: 7px;
background-color: var(--p5-black);
clip-path: var(--card-poly-black);
z-index: 2;
animation: frameBlackWobble 1s infinite;
}
.card-content {
position: relative;
z-index: 3;
padding: 30px 40px 25px 35px;
color: var(--p5-white);
}
/* Wobble keyframes - each frame moves independently by 1-2% */
@keyframes frameWhiteWobble {
0% { clip-path: polygon(6% 0, 100% 0, 90% 100%, 0 87%); }
25% { clip-path: polygon(6% 0, 98% 1%, 90% 100%, 1% 88%); }
50% { clip-path: polygon(6% 2%, 100% 0, 89% 100%, 0 87%); }
75% { clip-path: polygon(6% 0, 100% 0, 90% 99%, 0 87%); }
100% { clip-path: polygon(6% 0, 100% 0, 90% 100%, 0 87%); }
}
@keyframes frameBlackWobble {
0% { clip-path: polygon(6% 0, 100% 0, 95% 100%, 0 87%); }
25% { clip-path: polygon(7% 0, 100% 1%, 94% 100%, 1% 86%); }
50% { clip-path: polygon(6% 1%, 99% 0, 95% 99%, 0 87%); }
75% { clip-path: polygon(6% 0, 100% 0, 95% 100%, 1% 88%); }
100% { clip-path: polygon(6% 0, 100% 0, 95% 100%, 0 87%); }
}
/* Hover intensification: wobble shifts increase to 3-4% */
.p5-card:hover .card-frame-white {
animation-name: frameWhiteWobbleIntense;
}
.p5-card:hover .card-frame-black {
animation-name: frameBlackWobbleIntense;
}
@keyframes frameWhiteWobbleIntense {
0% { clip-path: polygon(6% 0, 100% 0, 90% 100%, 0 87%); }
25% { clip-path: polygon(4% 0, 97% 3%, 91% 100%, 2% 89%); }
50% { clip-path: polygon(7% 3%, 100% 0, 88% 99%, 0 85%); }
75% { clip-path: polygon(5% 0, 100% 1%, 91% 98%, 1% 88%); }
100% { clip-path: polygon(6% 0, 100% 0, 90% 100%, 0 87%); }
}
@keyframes frameBlackWobbleIntense {
0% { clip-path: polygon(6% 0, 100% 0, 95% 100%, 0 87%); }
25% { clip-path: polygon(8% 1%, 99% 2%, 93% 99%, 2% 85%); }
50% { clip-path: polygon(5% 2%, 98% 0, 96% 98%, 1% 88%); }
75% { clip-path: polygon(7% 0, 100% 1%, 94% 100%, 0 86%); }
100% { clip-path: polygon(6% 0, 100% 0, 95% 100%, 0 87%); }
}
/* Card content elements */
.card-number {
font-family: var(--font-display);
font-size: 4rem;
font-weight: 900;
color: var(--p5-red);
opacity: 0.4;
position: absolute;
top: 10px;
right: 30px;
z-index: 4;
line-height: 1;
}
.card-title {
font-family: var(--font-display);
font-size: var(--fs-card-title);
font-weight: 900;
text-transform: uppercase;
margin-top: var(--space-md);
}
.card-subtitle {
font-size: 0.9rem;
color: var(--p5-red);
font-style: italic;
font-weight: 700;
margin-top: var(--space-xs);
}
/* Dialogue box variant */
.p5-card--dialogue {
max-width: 600px;
}
.card-arrow-white {
position: absolute;
width: 25px;
height: 25px;
bottom: 20px;
left: -17px;
z-index: 1;
background-color: var(--p5-white);
clip-path: polygon(62% 43%, 100% 17%, 100% 73%, 50% 100%, 44% 56%, 0 71%, 55% 8%);
animation: arrowWhiteWobble 1s infinite;
}
.card-arrow-black {
position: absolute;
width: 28px;
height: 20px;
bottom: 22px;
left: -17px;
z-index: 2;
background-color: var(--p5-black);
clip-path: polygon(62% 43%, 100% 26%, 100% 73%, 53% 80%, 44% 56%, 0 71%, 56% 19%);
animation: arrowBlackWobble 1s infinite;
}
@keyframes arrowWhiteWobble {
0% { clip-path: polygon(62% 43%, 100% 17%, 100% 73%, 50% 100%, 44% 56%, 0 71%, 55% 8%); }
25% { clip-path: polygon(62% 43%, 100% 18%, 99% 73%, 50% 99%, 44% 56%, 0 72%, 55% 8%); }
50% { clip-path: polygon(61% 43%, 98% 17%, 100% 73%, 48% 100%, 43% 56%, 1% 71%, 56% 8%); }
75% { clip-path: polygon(62% 44%, 100% 17%, 100% 72%, 50% 100%, 44% 58%, 0 71%, 55% 9%); }
100% { clip-path: polygon(62% 43%, 100% 17%, 100% 73%, 50% 100%, 44% 56%, 0 71%, 55% 8%); }
}
@keyframes arrowBlackWobble {
0% { clip-path: polygon(62% 43%, 100% 26%, 100% 73%, 53% 80%, 44% 56%, 0 71%, 56% 19%); }
25% { clip-path: polygon(63% 43%, 100% 26%, 98% 73%, 53% 80%, 42% 56%, 1% 71%, 56% 19%); }
50% { clip-path: polygon(62% 44%, 100% 27%, 100% 72%, 53% 81%, 44% 57%, 0 74%, 56% 18%); }
75% { clip-path: polygon(62% 43%, 99% 26%, 100% 73%, 54% 80%, 44% 56%, 0 71%, 57% 19%); }
100% { clip-path: polygon(62% 43%, 100% 26%, 100% 73%, 53% 80%, 44% 56%, 0 71%, 56% 19%); }
}
/* Projects Grid */
.projects-grid {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
width: 100%;
max-width: 900px;
}
@media (min-width: 768px) {
.projects-grid {
flex-direction: row;
justify-content: center;
}
}
/* P5 Button - Skewed parallelogram with counter-skewed label */
.p5-button {
display: inline-block;
padding: var(--space-xs) var(--space-lg);
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 700;
text-transform: uppercase;
text-decoration: none;
color: var(--p5-white);
background: var(--p5-red);
border: none;
transform: skewX(-5deg);
position: relative;
overflow: hidden;
cursor: pointer;
transition: background var(--duration-fast), transform var(--duration-fast);
}
.p5-button span {
display: inline-block;
transform: skewX(5deg);
}
.p5-button:hover {
background: var(--p5-white);
color: var(--p5-black);
transform: skewX(-5deg) scale(1.05);
}
/* Loading Screen */
.loading-screen {
position: fixed;
inset: 0;
z-index: 9999;
background: var(--p5-black);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.loading-lines {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.loading-line {
stroke: var(--p5-red);
stroke-width: 0.5;
stroke-dasharray: 150;
stroke-dashoffset: 150;
}
.loading-title {
font-family: var(--font-display);
font-size: clamp(3rem, 8vw, 6rem);
font-weight: 900;
text-align: center;
color: var(--p5-white);
letter-spacing: 0.1em;
opacity: 0;
z-index: 1;
}
/* Hero */
.hero-section { background: var(--p5-black); }
.hero-bg-shapes {
position: absolute;
inset: 0;
pointer-events: none;
}
.diagonal-slash {
position: absolute;
width: 200%;
height: 120px;
background: var(--p5-red);
transform-origin: center;
}
.slash-1 { top: 20%; left: -50%; transform: rotate(45deg); opacity: 0.15; }
.slash-2 { top: 50%; left: -50%; transform: rotate(-45deg); opacity: 0.1; }
.slash-3 { top: 75%; left: -50%; transform: rotate(12deg); opacity: 0.08; }
.hero-content { text-align: center; z-index: 1; }
.hero-name {
font-family: var(--font-display);
font-size: var(--fs-hero);
font-weight: 900;
text-transform: uppercase;
line-height: 0.9;
color: var(--p5-white);
}
.hero-tagline {
font-size: clamp(1.2rem, 2.5vw, 2rem);
font-weight: 700;
font-style: italic;
color: var(--p5-red);
margin-top: var(--space-sm);
}
/* Stats */
.stats-container { width: 100%; max-width: 600px; }
.stat-row { margin-bottom: var(--space-md); }
.stat-label {
display: block;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.2rem;
text-transform: uppercase;
margin-bottom: var(--space-xs);
}
.stat-bar {
position: relative;
width: 100%;
height: 28px;
}
.stat-bar-frame-white {
position: absolute;
inset: 0;
background: var(--p5-white);
clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%);
animation: statBarWhiteWobble 1s infinite;
}
.stat-bar-frame-black {
position: absolute;
top: 3px;
left: 4px;
right: 6px;
bottom: 3px;
background: var(--p5-dark-gray);
clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%);
animation: statBarBlackWobble 1s infinite;
}
.stat-bar-fill {
position: absolute;
top: 3px;
left: 4px;
bottom: 3px;
width: 0%;
background: var(--p5-red);
clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%);
z-index: 3;
}
@keyframes statBarWhiteWobble {
0% { clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%); }
50% { clip-path: polygon(2% 1%, 99% 0, 97% 99%, 1% 90%); }
100% { clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%); }
}
@keyframes statBarBlackWobble {
0% { clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%); }
50% { clip-path: polygon(3% 0, 100% 1%, 96% 100%, 0 89%); }
100% { clip-path: polygon(2% 0, 100% 0, 97% 100%, 0 90%); }
}
.level-up-badge {
font-family: var(--font-display);
font-size: 2rem;
font-weight: 900;
color: var(--p5-yellow);
opacity: 0;
transform: scale(0) rotate(-10deg);
margin-top: var(--space-md);
}
/* Contact text */
.contact-text {
font-size: 1.2rem;
margin-bottom: var(--space-md);
color: var(--p5-white);
}
/* Section Wipe */
.section-wipe {
position: fixed;
inset: 0;
background: var(--p5-red);
z-index: 100;
transform: translateX(-101%);
pointer-events: none;
}
/* All-Out Attack Overlay */
.aoa-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
background: var(--p5-black);
}
.aoa-overlay.is-active { display: flex; }
.aoa-burst { position: absolute; inset: 0; }
.aoa-silhouette {
position: absolute;
width: 300px;
height: 400px;
background: var(--p5-red);
clip-path: polygon(50% 0%, 65% 30%, 100% 35%, 70% 55%, 80% 100%, 50% 70%, 20% 100%, 30% 55%, 0% 35%, 35% 30%);
opacity: 0;
}
.aoa-text {
font-family: var(--font-display);
font-size: clamp(3rem, 8vw, 6rem);
font-weight: 900;
color: var(--p5-yellow);
z-index: 1;
opacity: 0;
text-transform: uppercase;
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.loading-screen { display: none; }
.card-frame-white,
.card-frame-black,
.card-arrow-white,
.card-arrow-black,
.stat-bar-frame-white,
.stat-bar-frame-black {
animation: none !important;
}
}
@import './tokens.css';
@import './base.css';
@import './components.css';
Your entire CSS design system is locked in. Every color, shape, timing curve, and component pattern is ready. Next up, you will build the HTML for the loading sequence and hero section, and watch these styles come alive with GSAP animation.
Build the Loading and Hero Sections
Your design system is ready. Now it's time to build the first things visitors experience: a dramatic loading sequence and a bold hero section.
Persona 5's menus never just appear. They slam, stagger, and wipe into view with choreographed precision. In this step, you'll recreate that energy with a loading screen that draws SVG lines and reveals your name character by character, followed by a hero section with aggressive entrance animations.
In this step, get ready to:
- Build the loading screen and hero section HTML structure.
- Write a GSAP timeline that animates SVG lines, staggers text, and wipes the loading overlay away.
- Animate the hero entrance with per-character reveals, a sliding tagline, and parallax diagonal slashes.
Build the HTML structure for the loading screen and hero section
The loading screen is a full-viewport overlay with three SVG lines that will draw diagonally and a title that reveals character by character. The hero section sits behind it, waiting to appear after the overlay wipes away.
- In index.html, find the <script> tag inside <body>.
- Add the following loading screen and hero section HTML above that <script> tag:
<!-- Loading Screen -->
<div id="loading-screen" class="loading-screen">
<svg class="loading-lines" viewBox="0 0 100 100" preserveAspectRatio="none">
<line class="loading-line" x1="0" y1="0" x2="100" y2="100" />
<line class="loading-line" x1="100" y1="0" x2="0" y2="100" />
<line class="loading-line" x1="50" y1="0" x2="50" y2="100" />
</svg>
<h1 class="loading-title">PHANTOM<br/>PORTFOLIO</h1>
</div>
<!-- Hero Section -->
<section id="hero" class="p5-section hero-section">
<div class="hero-bg-shapes">
<div class="diagonal-slash slash-1"></div>
<div class="diagonal-slash slash-2"></div>
<div class="diagonal-slash slash-3"></div>
</div>
<div class="hero-content">
<h1 class="hero-name">YOUR NAME</h1>
<p class="hero-tagline">Front-End Engineer & UI Architect</p>
</div>
</section>
What does this HTML do?
The loading screen is a position: fixed overlay covering the entire viewport. Its three SVG <line> elements draw diagonally across the screen, and the title text reveals with per-character animation.
The hero section uses three .diagonal-slash divs as background decoration (red diagonal stripes). The .hero-content wrapper holds your name and tagline, which will animate in after the loading screen wipes away.
- Save index.html and check your browser.
- You should see a solid black screen. The loading overlay covers everything, but its SVG lines and title are invisible (opacity 0 and dashed offsets) until GSAP animates them in the next substep.
Seeing a blank white page instead?
Make sure the loading screen HTML is inside <body>, above the <script> tag. If it's outside <body>, the CSS won't apply.
Also confirm your /src/styles/main.css stylesheet link is still in the <head> from the previous step.
Help me debug my loading screen HTML
Write the GSAP loading timeline
The loading sequence is a GSAP timeline that orchestrates four stages: SVG line draw, title reveal, character stagger, and overlay dismissal. Each stage overlaps the previous using position parameters.
- Create a new file at src/scripts/animations/loading.ts and add the following code:
import { gsap } from 'gsap';
import { SplitText } from 'gsap/SplitText';
export function initLoading(): Promise<void> {
return new Promise((resolve) => {
const screen = document.getElementById('loading-screen');
const title = document.querySelector('.loading-title') as HTMLElement;
const lines = document.querySelectorAll('.loading-line');
if (!screen || !title) { resolve(); return; }
// Split the title into individual characters for staggered reveal
const split = new SplitText(title, { type: 'chars' });
const tl = gsap.timeline({
onComplete: () => { screen.style.display = 'none'; resolve(); },
});
// Stage 1: Draw SVG lines diagonally across the screen
tl.to(lines, {
strokeDashoffset: 0, duration: 1, stagger: 0.2, ease: 'power2.inOut',
})
// Stage 2: Make title visible just before characters animate
.to(title, { opacity: 1, duration: 0.01 }, '-=0.5')
// Stage 3: Stagger each character in with rotation and vertical offset
.from(split.chars, {
opacity: 0, y: 40, rotateX: -90,
stagger: 0.04, duration: 0.6, ease: 'back.out(1.7)',
}, '-=0.4')
// Stage 4: Wipe overlay away with a diagonal clip-path
.to(screen, {
clipPath: 'polygon(0 0, 100% 0, 100% 0%, 0 0%)',
duration: 0.8, ease: 'power4.inOut', delay: 0.5,
});
});
}
What does this timeline do?
- initLoading() returns a Promise so other animations can wait until the loading sequence finishes before starting.
- SplitText breaks the title into individual character spans so GSAP can animate each one independently with stagger timing.
- The strokeDashoffset: 0 tween animates the SVG lines from invisible (offset 150) to fully drawn (offset 0).
- Position parameters like '-=0.5' overlap animations by starting the next tween 0.5 seconds before the previous one ends.
- The final clipPath tween collapses the overlay to a zero-height polygon, revealing the hero section beneath.
- Save src/scripts/animations/loading.ts.
- You won't see the animation yet. The next substep creates the hero animation and the main orchestrator that runs this timeline.
Animate the hero section entrance
Once the loading overlay wipes away, the hero section needs its own entrance. Each character of your name will pop in with alternating rotation, the tagline slides up from below, and the diagonal background slashes sweep in from off-screen.
- Create a new file at src/scripts/animations/hero.ts and add the following code:
import { gsap } from 'gsap';
import { SplitText } from 'gsap/SplitText';
export function initHero(): void {
const name = document.querySelector('.hero-name') as HTMLElement;
const tagline = document.querySelector('.hero-tagline') as HTMLElement;
const slashes = document.querySelectorAll('.diagonal-slash');
if (!name || !tagline) return;
// Split hero name into individual characters
const splitName = new SplitText(name, { type: 'chars' });
const tl = gsap.timeline();
// Characters pop in with alternating rotation (-15deg / +15deg)
tl.from(splitName.chars, {
opacity: 0, scale: 0, rotation: gsap.utils.wrap([-15, 15]),
stagger: 0.05, duration: 0.5, ease: 'back.out(2)',
})
// Tagline slides up from below with a slight overlap
.from(tagline, { opacity: 0, y: 30, duration: 0.6, ease: 'power2.out' }, '-=0.2')
// Background diagonal slashes sweep in from the left
.from(slashes, {
x: '-100%', opacity: 0, stagger: 0.15, duration: 1, ease: 'power2.out',
}, '-=0.8');
}
What does this animation do?
- gsap.utils.wrap([-15, 15]) alternates the rotation value between -15 and +15 degrees for each character, creating a dynamic scattered effect.
- back.out(2) is an ease that overshoots the target slightly then settles back, giving each character a "bounce" as it lands.
- The diagonal slashes start at x: '-100%' (fully off-screen left) and slide into their CSS-defined positions with staggered timing.
- Save src/scripts/animations/hero.ts.
Now you need the main orchestrator file that imports both animation modules, registers the GSAP plugins, and runs the loading sequence followed by the hero entrance.
- Open src/scripts/main.ts (this file already exists but is empty) and replace its contents with the following:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
import { initLoading } from './animations/loading';
import { initHero } from './animations/hero';
// Register plugins so GSAP can use them
gsap.registerPlugin(ScrollTrigger, SplitText);
// Check user's motion preference before running any animations
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion) {
// Run loading sequence, then trigger hero entrance
initLoading().then(() => {
initHero();
});
} else {
// Skip all animations and show content immediately
const loader = document.getElementById('loading-screen');
if (loader) loader.style.display = 'none';
}
What does this orchestrator do?
- gsap.registerPlugin(ScrollTrigger, SplitText) activates both plugins globally. ScrollTrigger will be used in later steps for scroll-driven animations.
- The prefersReducedMotion check respects the user's accessibility settings. If they have reduced motion enabled, the loading screen is hidden immediately and all content is shown without animation.
- initLoading().then(() => { initHero(); }) chains the hero entrance to run only after the loading animation completes. This creates the choreographed reveal sequence.
- Save src/scripts/main.ts and refresh your browser.
- You should see the full loading sequence play: three red SVG lines draw diagonally across a black screen, the title "PHANTOM PORTFOLIO" reveals character by character with 3D rotation, then the overlay wipes away to reveal your hero section. Your name staggers in with bouncy alternating rotation, the tagline slides up in red italic text, and red diagonal stripes sweep in from the left.
Animation not playing?
- Check your browser console for import errors. If you see "Failed to resolve module", confirm the file path in each import statement matches your folder structure exactly (src/scripts/animations/loading.ts and src/scripts/animations/hero.ts).
- If the page stays black with no animation, make sure GSAP is installed. Run npm install gsap in your terminal if you see a "Cannot find module 'gsap'" error.
- If you have reduced motion enabled in your OS accessibility settings, the animation will be skipped by design. Temporarily disable it to test.
Help me debug my GSAP loading animation
Why use a Promise for the loading sequence?
The loading animation must finish before the hero entrance begins. Wrapping the timeline in a Promise lets you chain animations with .then() instead of hardcoding delay values. If you later adjust the loading timeline's duration, the hero entrance still waits for the correct moment automatically.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here's the complete code for each file at this point.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Phantom Portfolio</title>
<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=Big+Shoulders+Display:wght@400;700;900&family=Barlow+Condensed:ital,wght@0,400;0,700;1,700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/styles/main.css" />
</head>
<body>
<!-- Loading Screen -->
<div id="loading-screen" class="loading-screen">
<svg class="loading-lines" viewBox="0 0 100 100" preserveAspectRatio="none">
<line class="loading-line" x1="0" y1="0" x2="100" y2="100" />
<line class="loading-line" x1="100" y1="0" x2="0" y2="100" />
<line class="loading-line" x1="50" y1="0" x2="50" y2="100" />
</svg>
<h1 class="loading-title">PHANTOM<br/>PORTFOLIO</h1>
</div>
<!-- Hero Section -->
<section id="hero" class="p5-section hero-section">
<div class="hero-bg-shapes">
<div class="diagonal-slash slash-1"></div>
<div class="diagonal-slash slash-2"></div>
<div class="diagonal-slash slash-3"></div>
</div>
<div class="hero-content">
<h1 class="hero-name">YOUR NAME</h1>
<p class="hero-tagline">Front-End Engineer & UI Architect</p>
</div>
</section>
<script type="module" src="/src/scripts/main.ts"></script>
</body>
</html>
import { gsap } from 'gsap';
import { SplitText } from 'gsap/SplitText';
export function initLoading(): Promise<void> {
return new Promise((resolve) => {
const screen = document.getElementById('loading-screen');
const title = document.querySelector('.loading-title') as HTMLElement;
const lines = document.querySelectorAll('.loading-line');
if (!screen || !title) { resolve(); return; }
const split = new SplitText(title, { type: 'chars' });
const tl = gsap.timeline({
onComplete: () => { screen.style.display = 'none'; resolve(); },
});
tl.to(lines, {
strokeDashoffset: 0, duration: 1, stagger: 0.2, ease: 'power2.inOut',
})
.to(title, { opacity: 1, duration: 0.01 }, '-=0.5')
.from(split.chars, {
opacity: 0, y: 40, rotateX: -90,
stagger: 0.04, duration: 0.6, ease: 'back.out(1.7)',
}, '-=0.4')
.to(screen, {
clipPath: 'polygon(0 0, 100% 0, 100% 0%, 0 0%)',
duration: 0.8, ease: 'power4.inOut', delay: 0.5,
});
});
}
import { gsap } from 'gsap';
import { SplitText } from 'gsap/SplitText';
export function initHero(): void {
const name = document.querySelector('.hero-name') as HTMLElement;
const tagline = document.querySelector('.hero-tagline') as HTMLElement;
const slashes = document.querySelectorAll('.diagonal-slash');
if (!name || !tagline) return;
const splitName = new SplitText(name, { type: 'chars' });
const tl = gsap.timeline();
tl.from(splitName.chars, {
opacity: 0, scale: 0, rotation: gsap.utils.wrap([-15, 15]),
stagger: 0.05, duration: 0.5, ease: 'back.out(2)',
})
.from(tagline, { opacity: 0, y: 30, duration: 0.6, ease: 'power2.out' }, '-=0.2')
.from(slashes, {
x: '-100%', opacity: 0, stagger: 0.15, duration: 1, ease: 'power2.out',
}, '-=0.8');
}
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
import { initLoading } from './animations/loading';
import { initHero } from './animations/hero';
gsap.registerPlugin(ScrollTrigger, SplitText);
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion) {
initLoading().then(() => {
initHero();
});
} else {
const loader = document.getElementById('loading-screen');
if (loader) loader.style.display = 'none';
}
Your loading sequence and hero entrance are alive. Next up, you'll build the Projects section where cards slam into view from alternating sides with aggressive scroll-triggered entrances.
Build the Projects Section
Your hero section slams in with character-level animation and parallax slashes. That's a strong first impression, but a portfolio needs substance behind the flash.
In Persona 5, menu items don't politely fade in. They slam into frame from the edges at aggressive angles, each one staggered a fraction of a second behind the last. You'll recreate that energy for your project cards, using ScrollTrigger to fire the entrance only when the learner scrolls each card into view.
In this step, get ready to:
- Build the Projects section HTML with layered polygon-frame cards.
- Animate each card with a ScrollTrigger-driven slam entrance that alternates direction.
Add the Projects section HTML
Each project card uses the same layered-frame technique you built in the design system step. A white outer parallelogram, a black inner parallelogram, and readable content on top. Each card gets a slightly different rotation offset via a --card-rotate CSS custom property, creating the "scattered menu" look.
- In index.html, find the closing </section> tag of the hero section.
- Add the following Projects section directly below it (before the <script> tag):
<!-- Projects Section -->
<section id="projects" class="p5-section projects-section">
<h2 class="section-title">PROJECTS</h2>
<div class="projects-grid">
<div class="p5-card" style="--card-rotate: -1.5deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">01</span>
<h3 class="card-title">Project Alpha</h3>
<p class="card-subtitle">Full-Stack Application</p>
</div>
</div>
</div>
<div class="p5-card" style="--card-rotate: 1deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">02</span>
<h3 class="card-title">Project Beta</h3>
<p class="card-subtitle">Data Visualization</p>
</div>
</div>
</div>
<div class="p5-card" style="--card-rotate: -0.5deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">03</span>
<h3 class="card-title">Project Gamma</h3>
<p class="card-subtitle">Real-Time Collaboration</p>
</div>
</div>
</div>
</div>
</section>
What does this structure do?
Each .p5-card contains the same three-layer sandwich from the design system: a white outer polygon frame, a black inner polygon frame, and a content layer that sits above both.
The --card-rotate inline custom property gives each card a slightly different angle (-1.5, +1, -0.5 degrees). Your .p5-card CSS reads this variable via transform: rotate(var(--card-rotate, 0deg)) to apply the offset.
- Save index.html.
- Switch back to your browser. You should see three white parallelogram cards stacked vertically, each with a large red number (01, 02, 03), a bold title, and a red italic subtitle. The cards wobble subtly thanks to the keyframe animations from your components CSS.
Cards look like plain rectangles?
Make sure your components.css contains the .card-frame-white and .card-frame-black rules with their clip-path values from the previous step. Without those polygon shapes, the frames render as normal rectangles.
Also check that main.css imports components.css. If the import is missing, none of the card styles apply.
Help me debug my P5 card frames not showing polygon shapes
Animate cards with a ScrollTrigger slam entrance
Right now your cards are visible immediately. In Persona 5, items crash into frame from off-screen at aggressive angles. You'll use ScrollTrigger to fire this entrance only when the learner scrolls each card into the viewport.
- Create a new file at src/scripts/animations/projects.ts and add the following code:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export function initProjects(): void {
const cards = document.querySelectorAll<HTMLElement>('.p5-card');
cards.forEach((card, index) => {
// Alternate direction: even cards from left, odd from right
const direction = index % 2 === 0 ? -1 : 1;
gsap.from(card, {
x: direction * 120 + '%',
opacity: 0,
rotation: direction * -8,
scale: 0.8,
duration: 0.7,
ease: 'power4.out',
scrollTrigger: {
trigger: card,
start: 'top 85%',
end: 'top 50%',
toggleActions: 'play none none reverse',
},
});
});
}
What does this animation do?
- direction alternates based on index parity. Even-indexed cards (0, 2) start at x: -120% (off-screen left). Odd-indexed cards (1) start at x: 120% (off-screen right).
- rotation: direction * -8 tilts each card 8 degrees in the opposite direction of travel, so they feel like they're tumbling into place.
- power4.out is an aggressive deceleration ease. The card rockets in fast and then slows dramatically at the end, mimicking P5's battle menu slam.
- toggleActions: 'play none none reverse' means: play on enter, do nothing on leave, do nothing on enter-back, reverse on leave-back. Cards reset when scrolled past.
Now wire this function into your main orchestrator.
- In src/scripts/main.ts, find the line that says:
import { initHero } from './animations/hero';
- Add this import directly below it:
import { initProjects } from './animations/projects';
- Next, find the initHero(); call inside the .then() block:
initLoading().then(() => {
initHero();
});
- Add initProjects(); on the line after initHero();, so the block now looks like this:
initLoading().then(() => {
initHero();
initProjects();
});
- Save both src/scripts/animations/projects.ts and src/scripts/main.ts.
- Switch back to your browser and scroll down past the hero section. The three project cards should slam into view one at a time as you scroll. Cards 1 and 3 fly in from the left, card 2 from the right. Each card is slightly tilted during the entrance and settles into its final rotated position.
Cards appear instantly without animation?
If your browser window is short enough that the cards are already in view on page load, ScrollTrigger considers them "already entered" and skips the animation. Try making your browser window taller, or scroll up fully and refresh so the cards start below the viewport.
Also confirm that gsap.registerPlugin(ScrollTrigger, SplitText) is present near the top of main.ts. Without registration, ScrollTrigger won't activate.
Help me debug my ScrollTrigger card animations not firing
Why alternate directions?
In Persona 5's battle menus, items don't all slide from the same edge. They alternate or burst from different sides to create visual tension. Alternating left/right entrances prevents the animation from feeling monotonous and adds rhythmic energy to the scroll.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full index.html at this point:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Phantom Portfolio</title>
<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=Big+Shoulders+Display:wght@400;700;900&family=Barlow+Condensed:ital,wght@0,400;0,700;1,700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/styles/main.css" />
</head>
<body>
<!-- Loading Screen -->
<div id="loading-screen" class="loading-screen">
<svg class="loading-lines" viewBox="0 0 100 100" preserveAspectRatio="none">
<line class="loading-line" x1="0" y1="0" x2="100" y2="100" />
<line class="loading-line" x1="100" y1="0" x2="0" y2="100" />
<line class="loading-line" x1="50" y1="0" x2="50" y2="100" />
</svg>
<h1 class="loading-title">PHANTOM<br/>PORTFOLIO</h1>
</div>
<!-- Hero Section -->
<section id="hero" class="p5-section hero-section">
<div class="hero-bg-shapes">
<div class="diagonal-slash slash-1"></div>
<div class="diagonal-slash slash-2"></div>
<div class="diagonal-slash slash-3"></div>
</div>
<div class="hero-content">
<h1 class="hero-name">YOUR NAME</h1>
<p class="hero-tagline">Front-End Engineer & UI Architect</p>
</div>
</section>
<!-- Projects Section -->
<section id="projects" class="p5-section projects-section">
<h2 class="section-title">PROJECTS</h2>
<div class="projects-grid">
<div class="p5-card" style="--card-rotate: -1.5deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">01</span>
<h3 class="card-title">Project Alpha</h3>
<p class="card-subtitle">Full-Stack Application</p>
</div>
</div>
</div>
<div class="p5-card" style="--card-rotate: 1deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">02</span>
<h3 class="card-title">Project Beta</h3>
<p class="card-subtitle">Data Visualization</p>
</div>
</div>
</div>
<div class="p5-card" style="--card-rotate: -0.5deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">03</span>
<h3 class="card-title">Project Gamma</h3>
<p class="card-subtitle">Real-Time Collaboration</p>
</div>
</div>
</div>
</div>
</section>
<script type="module" src="/src/scripts/main.ts"></script>
</body>
</html>
Here is the full src/scripts/animations/projects.ts:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export function initProjects(): void {
const cards = document.querySelectorAll<HTMLElement>('.p5-card');
cards.forEach((card, index) => {
const direction = index % 2 === 0 ? -1 : 1;
gsap.from(card, {
x: direction * 120 + '%',
opacity: 0,
rotation: direction * -8,
scale: 0.8,
duration: 0.7,
ease: 'power4.out',
scrollTrigger: {
trigger: card,
start: 'top 85%',
end: 'top 50%',
toggleActions: 'play none none reverse',
},
});
});
}
Here is the full src/scripts/main.ts:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
import { initLoading } from './animations/loading';
import { initHero } from './animations/hero';
import { initProjects } from './animations/projects';
gsap.registerPlugin(ScrollTrigger, SplitText);
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion) {
initLoading().then(() => {
initHero();
initProjects();
});
} else {
const loader = document.getElementById('loading-screen');
if (loader) loader.style.display = 'none';
}
Your project cards now slam into frame with the aggressive energy of a P5 battle menu. Next up, you'll build the Skills section with animated stat bars that fill on scroll.
Build the Skills and Contact Sections
Your project cards are slamming into view from the edges. Now you need two more sections to complete the portfolio: a skills display and a contact area.
Persona 5 displays character stats as angular progress bars with the same layered polygon frame technique you used for cards. The contact section uses a dialogue box variant of the P5 card. Both sections get their own scroll-triggered GSAP animations.
In this step, get ready to:
- Build the Skills section with P5-style stat bars using layered polygon frames.
- Animate stat bars filling on scroll with a floating LEVEL UP badge.
- Build the Contact section as a P5 dialogue box that slams in from above.
Build the Skills section with stat bars
In Persona 5, character stats display as angular bars with hard-edged polygon ends. You will replicate this by using the same white/black layered frame technique from your cards, stretched into a narrow horizontal shape with a red fill element inside.
- In index.html, find the closing </section> tag of the Projects section.
- Add the following Skills section HTML directly below it (before the <script> tag):
<!-- Skills Section -->
<section id="skills" class="p5-section skills-section">
<h2 class="section-title">STATS</h2>
<div class="stats-container">
<div class="stat-row">
<span class="stat-label">HTML / CSS</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="95"></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">JavaScript / TS</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="90"></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">Animation / GSAP</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="85"></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">React / Vue</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="88"></div>
</div>
</div>
</div>
<div class="level-up-badge" aria-hidden="true">LEVEL UP!</div>
</section>
What does this HTML do?
Each .stat-row contains a label and a .stat-bar wrapper. Inside the bar, three layers stack: a white polygon outer track, a black polygon inner track, and a red fill element.
The data-level attribute on each .stat-bar-fill stores the target percentage. GSAP will read this value and animate the bar's width to match. The .level-up-badge is hidden by default and pops in after all bars finish filling.
- Save index.html.
- Refresh your browser and scroll down past the Projects section. You should see four stat labels (HTML / CSS, JavaScript / TS, Animation / GSAP, React / Vue) with white polygon-shaped bar tracks next to them.
Don't see the stat bars?
Make sure you placed the Skills section after the Projects section's closing </section> tag and before the <script> tag. Check that the CSS classes match exactly. Your components.css from the design system step already styles these elements.
Why aren't my stat bars showing up in the Skills section?
Animate the stat bars with ScrollTrigger
Static bars are not the P5 way. In the game, stats animate with dramatic flair when a character levels up. You will use a ScrollTrigger timeline so the bars fill as the learner scrolls the section into view, then a LEVEL UP badge pops in at the end.
- Create a new file at src/scripts/animations/skills.ts and add the following code:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export function initSkills(): void {
const bars = document.querySelectorAll<HTMLElement>('.stat-bar-fill');
const badge = document.querySelector('.level-up-badge') as HTMLElement;
if (bars.length === 0) return;
// Create a timeline that triggers when the stats container scrolls into view
const tl = gsap.timeline({
scrollTrigger: {
trigger: '.stats-container',
start: 'top 70%',
toggleActions: 'play none none reverse',
},
});
// Animate each bar from 0% width to its data-level percentage
bars.forEach((bar) => {
const level = bar.dataset.level || '0';
tl.to(bar, {
width: `${level}%`, duration: 1, ease: 'power2.out',
}, bars[0] === bar ? undefined : '-=0.85');
});
// Pop in the LEVEL UP badge after all bars complete
if (badge) {
tl.to(badge, {
opacity: 1, scale: 1, rotation: 0, duration: 0.5, ease: 'back.out(3)',
});
}
}
What does this animation do?
The timeline fires when .stats-container hits 70% of the viewport height. Each bar reads its data-level attribute and tweens its width from 0% to that value with a smooth deceleration.
The '-=0.85' position parameter overlaps each bar's start with the previous one by 0.85 seconds. This creates a rapid-fire stagger effect. After all bars finish, the badge scales up from 0 with a bouncy back.out(3) ease.
- Save src/scripts/animations/skills.ts.
You won't see the animation yet because the function is not connected to the main script. You will wire it up after building the Contact section.
Seeing a TypeScript error?
If your editor underlines ScrollTrigger or gsap imports, make sure you installed GSAP in Step 1. The plugin is already registered in main.ts so imports resolve correctly once you save.
Help me fix TypeScript import errors in my GSAP animation file.
Build the Contact section and wire up animations
The final content section is a contact area styled as a P5 dialogue box. It uses the same layered polygon frame at a larger scale, plus a speech-bubble arrow element that wobbles independently.
- In index.html, find the closing </section> tag of the Skills section you just added.
- Add the following Contact section HTML directly below it (before the <script> tag):
<!-- Contact Section -->
<section id="contact" class="p5-section contact-section">
<div class="p5-card p5-card--dialogue">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-arrow-white"></div>
<div class="card-arrow-black"></div>
<div class="card-content">
<h2 class="section-title">GET IN TOUCH</h2>
<p class="contact-text">Ready to start a new confidant? Send a calling card.</p>
<a href="mailto:your@email.com" class="p5-button"><span>Send Calling Card</span></a>
</div>
</div>
</section>
What makes this a dialogue box?
The .p5-card--dialogue modifier class sets a wider max-width (600px) to accommodate the contact content. The .card-arrow-white and .card-arrow-black elements create the speech-bubble pointer using complex clip-path polygons that wobble on their own 1-second loop.
The button uses .p5-button with a skewX(-5deg) transform on the outer element and a counter-skew on the inner <span> so the text stays readable.
- Save index.html.
- Refresh your browser and scroll to the bottom. You should see a large white parallelogram dialogue box with a small wobbling arrow on the left side, a GET IN TOUCH title, and a red skewed button labeled Send Calling Card.
Arrow not visible or misplaced?
The arrow elements use absolute positioning relative to the .p5-card container. Make sure both .card-arrow-white and .card-arrow-black are placed inside the .p5-card.p5-card--dialogue div, not inside .card-content.
Help me fix the speech-bubble arrow positioning on my P5 dialogue box.
Now create the animation that slams the dialogue box into frame from above.
- Create a new file at src/scripts/animations/contact.ts and add the following code:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export function initContact(): void {
const box = document.querySelector('.p5-card--dialogue') as HTMLElement;
if (!box) return;
// Slam the dialogue box in from above with overshoot and slight rotation
gsap.from(box, {
y: -200, opacity: 0, scale: 0.8, rotation: -3,
duration: 0.7, ease: 'back.out(1.7)',
scrollTrigger: {
trigger: box,
start: 'top 80%',
toggleActions: 'play none none reverse',
},
});
}
What does this animation do?
The box starts 200px above its final position, invisible, scaled down, and tilted -3 degrees. When it scrolls into view at 80% of the viewport, GSAP animates it to its natural state with a back.out(1.7) ease that overshoots slightly before settling. This mimics P5's battle menu items dropping into frame with satisfying weight.
- Save src/scripts/animations/contact.ts.
The last piece is connecting both new animations to the main orchestrator.
- Replace the entire contents of src/scripts/main.ts with the following updated code:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
import { initLoading } from './animations/loading';
import { initHero } from './animations/hero';
import { initProjects } from './animations/projects';
import { initSkills } from './animations/skills';
import { initContact } from './animations/contact';
gsap.registerPlugin(ScrollTrigger, SplitText);
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion) {
initLoading().then(() => {
initHero();
initProjects();
initSkills();
initContact();
});
} else {
const loader = document.getElementById('loading-screen');
if (loader) loader.style.display = 'none';
// Show stat bars at their final width for reduced-motion users
document.querySelectorAll<HTMLElement>('.stat-bar-fill').forEach((bar) => {
const level = bar.dataset.level || '0';
bar.style.width = `${level}%`;
});
}
What changed here?
Two new imports bring in initSkills and initContact. Both get called inside the .then() callback after the loading animation finishes.
The else block now includes a fallback for reduced-motion users. It loops through every .stat-bar-fill element and sets its width directly from the data-level attribute so the bars display their final state without animation.
- Save src/scripts/main.ts.
- Refresh your browser and scroll down through the page. When the Skills section enters the viewport, you should see the red stat bars animate from 0% width to their target levels in rapid succession. After all bars finish, a yellow LEVEL UP badge pops in with a bounce.
- Continue scrolling to the Contact section. The dialogue box should slam in from above with a slight overshoot and rotation before settling into place.
Animations not firing on scroll?
ScrollTrigger needs enough page height to trigger. If all sections fit within the viewport without scrolling, the animations will fire immediately on load instead of on scroll. Make sure your browser window is at a normal size (not maximized on a very tall monitor).
Also check that initSkills() and initContact() are called inside the .then() callback, not outside it. If they run before the loading screen is dismissed, ScrollTrigger calculates wrong element positions.
My GSAP ScrollTrigger animations are not firing when I scroll. Help me debug.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: index.html, skills.ts, contact.ts, and main.ts.
ⓧ I'd like to double check the full code
Here is the complete index.html at this point:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Phantom Portfolio</title>
<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=Big+Shoulders+Display:wght@400;700;900&family=Barlow+Condensed:ital,wght@0,400;0,700;1,700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/styles/main.css" />
</head>
<body>
<!-- Loading Screen -->
<div id="loading-screen" class="loading-screen">
<svg class="loading-lines" viewBox="0 0 100 100" preserveAspectRatio="none">
<line class="loading-line" x1="0" y1="0" x2="100" y2="100" />
<line class="loading-line" x1="100" y1="0" x2="0" y2="100" />
<line class="loading-line" x1="50" y1="0" x2="50" y2="100" />
</svg>
<h1 class="loading-title">PHANTOM<br/>PORTFOLIO</h1>
</div>
<!-- Hero Section -->
<section id="hero" class="p5-section hero-section">
<div class="hero-bg-shapes">
<div class="diagonal-slash slash-1"></div>
<div class="diagonal-slash slash-2"></div>
<div class="diagonal-slash slash-3"></div>
</div>
<div class="hero-content">
<h1 class="hero-name">YOUR NAME</h1>
<p class="hero-tagline">Front-End Engineer & UI Architect</p>
</div>
</section>
<!-- Projects Section -->
<section id="projects" class="p5-section projects-section">
<h2 class="section-title">PROJECTS</h2>
<div class="projects-grid">
<div class="p5-card" style="--card-rotate: -1.5deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">01</span>
<h3 class="card-title">Project Alpha</h3>
<p class="card-subtitle">Full-Stack Application</p>
</div>
</div>
</div>
<div class="p5-card" style="--card-rotate: 1deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">02</span>
<h3 class="card-title">Project Beta</h3>
<p class="card-subtitle">Data Visualization</p>
</div>
</div>
</div>
<div class="p5-card" style="--card-rotate: -0.5deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">03</span>
<h3 class="card-title">Project Gamma</h3>
<p class="card-subtitle">Real-Time Collaboration</p>
</div>
</div>
</div>
</div>
</section>
<!-- Skills Section -->
<section id="skills" class="p5-section skills-section">
<h2 class="section-title">STATS</h2>
<div class="stats-container">
<div class="stat-row">
<span class="stat-label">HTML / CSS</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="95"></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">JavaScript / TS</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="90"></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">Animation / GSAP</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="85"></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">React / Vue</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="88"></div>
</div>
</div>
</div>
<div class="level-up-badge" aria-hidden="true">LEVEL UP!</div>
</section>
<!-- Contact Section -->
<section id="contact" class="p5-section contact-section">
<div class="p5-card p5-card--dialogue">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-arrow-white"></div>
<div class="card-arrow-black"></div>
<div class="card-content">
<h2 class="section-title">GET IN TOUCH</h2>
<p class="contact-text">Ready to start a new confidant? Send a calling card.</p>
<a href="mailto:your@email.com" class="p5-button"><span>Send Calling Card</span></a>
</div>
</div>
</section>
<script type="module" src="/src/scripts/main.ts"></script>
</body>
</html>
Here is the complete src/scripts/animations/skills.ts:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export function initSkills(): void {
const bars = document.querySelectorAll<HTMLElement>('.stat-bar-fill');
const badge = document.querySelector('.level-up-badge') as HTMLElement;
if (bars.length === 0) return;
const tl = gsap.timeline({
scrollTrigger: {
trigger: '.stats-container',
start: 'top 70%',
toggleActions: 'play none none reverse',
},
});
bars.forEach((bar) => {
const level = bar.dataset.level || '0';
tl.to(bar, {
width: `${level}%`, duration: 1, ease: 'power2.out',
}, bars[0] === bar ? undefined : '-=0.85');
});
if (badge) {
tl.to(badge, {
opacity: 1, scale: 1, rotation: 0, duration: 0.5, ease: 'back.out(3)',
});
}
}
Here is the complete src/scripts/animations/contact.ts:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export function initContact(): void {
const box = document.querySelector('.p5-card--dialogue') as HTMLElement;
if (!box) return;
gsap.from(box, {
y: -200, opacity: 0, scale: 0.8, rotation: -3,
duration: 0.7, ease: 'back.out(1.7)',
scrollTrigger: {
trigger: box,
start: 'top 80%',
toggleActions: 'play none none reverse',
},
});
}
Here is the complete src/scripts/main.ts:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
import { initLoading } from './animations/loading';
import { initHero } from './animations/hero';
import { initProjects } from './animations/projects';
import { initSkills } from './animations/skills';
import { initContact } from './animations/contact';
gsap.registerPlugin(ScrollTrigger, SplitText);
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion) {
initLoading().then(() => {
initHero();
initProjects();
initSkills();
initContact();
});
} else {
const loader = document.getElementById('loading-screen');
if (loader) loader.style.display = 'none';
document.querySelectorAll<HTMLElement>('.stat-bar-fill').forEach((bar) => {
const level = bar.dataset.level || '0';
bar.style.width = `${level}%`;
});
}
Your portfolio now has all five content sections: Loading, Hero, Projects, Skills, and Contact. Next up, you will add the polish layer with section transitions, responsive breakpoints, and accessibility support.
Polish with Transitions, Responsiveness, and Accessibility
Your five sections are individually animated, but the experience between them still feels disconnected. Persona 5 never lets a single frame go unchoreographed.
In this step, you will add the structural overlays that tie everything together. You will also wire up a hidden easter egg animation and verify that the entire project compiles cleanly for production.
In this step, get ready to:
- Add transition and overlay elements to your HTML.
- Create an All-Out Attack easter egg animation with GSAP timelines.
- Verify accessibility support and run the production build.
Add transition and overlay elements to your HTML
The site needs two utility overlays that sit on top of everything else. One is a full-screen red wipe used for between-section transitions. The other is the All-Out Attack screen, a hidden easter egg that triggers on a keyboard shortcut.
- In index.html, find the closing </section> tag of the Contact section (the last </section> before your <script> tag).
- Add the following two overlay blocks between that closing </section> and the <script> tag:
<!-- All-Out Attack Overlay (Secret Mission) -->
<div id="all-out-attack" class="aoa-overlay" aria-hidden="true">
<div class="aoa-burst"></div>
<div class="aoa-silhouette"></div>
<h2 class="aoa-text">LOOKING COOL!</h2>
</div>
<!-- Section Wipe Transition -->
<div class="section-wipe" aria-hidden="true"></div>
What does this code do?
The #all-out-attack overlay contains three child elements: a .aoa-burst container for radial lines, a .aoa-silhouette for the character slash, and .aoa-text for the victory splash. It stays hidden until triggered by a keyboard shortcut.
The .section-wipe div is a fixed full-screen red panel that slides across the viewport during section changes. Both use aria-hidden="true" because they are purely decorative.
- Save index.html.
- Refresh your browser. You will not see any visible change yet because both overlays are hidden off-screen or set to display: none by default.
Seeing a layout shift or red flash?
Make sure the .section-wipe div is outside any <section> tag. If it is nested inside a section, the CSS position: fixed may not behave as expected.
Also confirm the #all-out-attack div comes before the .section-wipe div, matching the order shown above.
help me debug a layout shift after adding the overlay divs
Create the All-Out Attack easter egg animation
The All-Out Attack is an iconic Persona 5 moment: radial lines explode from the center, a silhouette slashes across, and victory text bursts in letter by letter. You will build this as a self-contained animation module that triggers on a keyboard shortcut.
- Create a new file at src/scripts/animations/all-out-attack.ts and add the following code:
import { gsap } from 'gsap';
import { SplitText } from 'gsap/SplitText';
export function initAllOutAttack(): void {
const overlay = document.getElementById('all-out-attack');
const burst = overlay?.querySelector('.aoa-burst') as HTMLElement;
const silhouette = overlay?.querySelector('.aoa-silhouette') as HTMLElement;
const text = overlay?.querySelector('.aoa-text') as HTMLElement;
if (!overlay || !burst || !silhouette || !text) return;
let isActive = false;
let tl: gsap.core.Timeline | null = null;
function createBurstLines(): HTMLElement[] {
const lines: HTMLElement[] = [];
const lineCount = 24;
for (let i = 0; i < lineCount; i++) {
const line = document.createElement('div');
line.style.cssText = `
position: absolute; top: 50%; left: 50%;
width: 4px; height: 120%; background: #ED1C24;
transform-origin: center top;
transform: translate(-50%, -50%) rotate(${(360 / lineCount) * i}deg) scaleY(0);
`;
burst.appendChild(line);
lines.push(line);
}
return lines;
}
function trigger(): void {
if (isActive) { dismiss(); return; }
isActive = true;
overlay.classList.add('is-active');
const lines = createBurstLines();
const splitText = new SplitText(text, { type: 'chars' });
tl = gsap.timeline({
onComplete: () => { gsap.delayedCall(1.5, dismiss); },
});
tl.to(lines, {
scaleY: 1, stagger: { each: 0.03, from: 'random' },
duration: 0.5, ease: 'power4.out',
}, 'burst')
.to(silhouette, { opacity: 1, scale: 1.2, duration: 0.3, ease: 'power2.out' }, 'burst+=0.3')
.to(silhouette, { x: 200, rotation: 15, duration: 0.4, ease: 'power3.in' }, 'burst+=0.6')
.set(text, { opacity: 1 }, 'burst+=0.8')
.from(splitText.chars, {
opacity: 0, scale: 0, rotation: gsap.utils.wrap([-30, 30]),
stagger: 0.04, duration: 0.4, ease: 'back.out(2)',
}, 'burst+=0.8');
}
function dismiss(): void {
if (!isActive) return;
gsap.to(overlay, {
opacity: 0, duration: 0.4, ease: 'power2.in',
onComplete: () => {
overlay.classList.remove('is-active');
gsap.set(overlay, { opacity: 1 });
burst.innerHTML = '';
gsap.set(silhouette, { opacity: 0, scale: 1, x: 0, rotation: 0 });
gsap.set(text, { opacity: 0 });
isActive = false;
if (tl) { tl.kill(); tl = null; }
},
});
}
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === 'A') { e.preventDefault(); trigger(); }
if (e.key === 'Escape' && isActive) { dismiss(); }
});
}
What does this code do?
- createBurstLines() dynamically generates 24 radial div elements, each rotated by 360/24 = 15 degrees apart. They start with scaleY(0) and GSAP scales them to 1 with a random stagger for an explosive burst.
- trigger() builds a labeled GSAP timeline. The 'burst' label lets subsequent tweens position themselves relative to the burst start using offsets like 'burst+=0.3'.
- The silhouette scales up, then slashes sideways. SplitText splits the victory heading into individual characters that stagger in with rotation.
- dismiss() fades the overlay, resets all animated properties, clears the burst lines from the DOM, and kills the timeline to prevent memory leaks.
- The keyboard listener fires trigger() on Ctrl+Shift+A and dismiss() on Escape. Pressing the shortcut again while active also dismisses.
Now wire this animation into your main entry point.
- In src/scripts/main.ts, find the import block at the top of the file.
- Find this line:
import { initContact } from './animations/contact';
- Add the following import directly below it:
import { initAllOutAttack } from './animations/all-out-attack';
- Next, find the initContact(); call inside the .then() callback.
- Add the following call directly below it:
initAllOutAttack();
- Save both all-out-attack.ts and main.ts.
- In your browser, press Ctrl+Shift+A. You should see a dramatic red burst of radial lines, a silhouette slash, and "LOOKING COOL!" text animating in character by character.
Nothing happens when you press the shortcut?
Make sure the browser window is focused (click inside the page first). Some browser extensions intercept Ctrl+Shift+A, so try with extensions disabled if the shortcut does not respond.
Check your browser console for errors. A missing import statement or a typo in the element ID (all-out-attack in HTML must match getElementById('all-out-attack') in the script) will silently fail.
help me debug why my All-Out Attack animation is not triggering
Verify accessibility and run the production build
A polished animation site must respect users who prefer reduced motion. Your project already handles this in two places: the CSS and the JavaScript entry point.
How does reduced-motion support work here?
In components.css, the @media (prefers-reduced-motion: reduce) block forces all animations to near-zero duration and disables every wobble keyframe. The loading screen is hidden entirely.
In main.ts, the prefersReducedMotion check at the top skips all GSAP initialization. Stat bars are set to their final width immediately so content is still visible.
This two-layer approach ensures that even if JavaScript fails to load, the CSS alone still prevents motion for users who need it.
With all sections, transitions, and the easter egg in place, the project is ready for a production build.
- Run the production build by executing this command in your terminal:
npm run build
- You should see output confirming that TypeScript compiled and Vite bundled your files into the dist/ folder.
What does this command do?
The npm run build script runs tsc && vite build as defined in your package.json. First TypeScript checks your code for type errors. Then Vite bundles and minifies everything into static files in dist/ that you can deploy anywhere.
- Confirm the output folder exists by running:
ls dist
- You should see index.html and an assets/ folder containing your bundled CSS and JavaScript.
Build failing with type errors?
Check the error message for a file name and line number. The most common issue is a missing type assertion. Make sure every querySelector result is cast with as HTMLElement where needed.
If you see an error about an unused variable, check that your imports in main.ts match the function calls below them. An imported function that is never called triggers the noUnusedLocals rule in tsconfig.json.
help me fix TypeScript build errors in my Persona 5 portfolio project
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Here is the complete index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Phantom Portfolio</title>
<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=Big+Shoulders+Display:wght@400;700;900&family=Barlow+Condensed:ital,wght@0,400;0,700;1,700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/styles/main.css" />
</head>
<body>
<!-- Loading Screen -->
<div id="loading-screen" class="loading-screen">
<svg class="loading-lines" viewBox="0 0 100 100" preserveAspectRatio="none">
<line class="loading-line" x1="0" y1="0" x2="100" y2="100" />
<line class="loading-line" x1="100" y1="0" x2="0" y2="100" />
<line class="loading-line" x1="50" y1="0" x2="50" y2="100" />
</svg>
<h1 class="loading-title">PHANTOM<br/>PORTFOLIO</h1>
</div>
<!-- Hero Section -->
<section id="hero" class="p5-section hero-section">
<div class="hero-bg-shapes">
<div class="diagonal-slash slash-1"></div>
<div class="diagonal-slash slash-2"></div>
<div class="diagonal-slash slash-3"></div>
</div>
<div class="hero-content">
<h1 class="hero-name">YOUR NAME</h1>
<p class="hero-tagline">Front-End Engineer & UI Architect</p>
</div>
</section>
<!-- Projects Section -->
<section id="projects" class="p5-section projects-section">
<h2 class="section-title">PROJECTS</h2>
<div class="projects-grid">
<div class="p5-card" style="--card-rotate: -1.5deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">01</span>
<h3 class="card-title">Project Alpha</h3>
<p class="card-subtitle">Full-Stack Application</p>
</div>
</div>
</div>
<div class="p5-card" style="--card-rotate: 1deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">02</span>
<h3 class="card-title">Project Beta</h3>
<p class="card-subtitle">Data Visualization</p>
</div>
</div>
</div>
<div class="p5-card" style="--card-rotate: -0.5deg">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-content">
<div class="card-front">
<span class="card-number">03</span>
<h3 class="card-title">Project Gamma</h3>
<p class="card-subtitle">Real-Time Collaboration</p>
</div>
</div>
</div>
</div>
</section>
<!-- Skills Section -->
<section id="skills" class="p5-section skills-section">
<h2 class="section-title">STATS</h2>
<div class="stats-container">
<div class="stat-row">
<span class="stat-label">HTML / CSS</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="95"></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">JavaScript / TS</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="90"></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">Animation / GSAP</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="85"></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">React / Vue</span>
<div class="stat-bar">
<div class="stat-bar-frame-white"></div>
<div class="stat-bar-frame-black"></div>
<div class="stat-bar-fill" data-level="88"></div>
</div>
</div>
</div>
<div class="level-up-badge" aria-hidden="true">LEVEL UP!</div>
</section>
<!-- Contact Section -->
<section id="contact" class="p5-section contact-section">
<div class="p5-card p5-card--dialogue">
<div class="card-frame-white"></div>
<div class="card-frame-black"></div>
<div class="card-arrow-white"></div>
<div class="card-arrow-black"></div>
<div class="card-content">
<h2 class="section-title">GET IN TOUCH</h2>
<p class="contact-text">Ready to start a new confidant? Send a calling card.</p>
<a href="mailto:your@email.com" class="p5-button"><span>Send Calling Card</span></a>
</div>
</div>
</section>
<!-- All-Out Attack Overlay (Secret Mission) -->
<div id="all-out-attack" class="aoa-overlay" aria-hidden="true">
<div class="aoa-burst"></div>
<div class="aoa-silhouette"></div>
<h2 class="aoa-text">LOOKING COOL!</h2>
</div>
<!-- Section Wipe Transition -->
<div class="section-wipe" aria-hidden="true"></div>
<script type="module" src="/src/scripts/main.ts"></script>
</body>
</html>
Here is the complete src/scripts/animations/all-out-attack.ts:
import { gsap } from 'gsap';
import { SplitText } from 'gsap/SplitText';
export function initAllOutAttack(): void {
const overlay = document.getElementById('all-out-attack');
const burst = overlay?.querySelector('.aoa-burst') as HTMLElement;
const silhouette = overlay?.querySelector('.aoa-silhouette') as HTMLElement;
const text = overlay?.querySelector('.aoa-text') as HTMLElement;
if (!overlay || !burst || !silhouette || !text) return;
let isActive = false;
let tl: gsap.core.Timeline | null = null;
function createBurstLines(): HTMLElement[] {
const lines: HTMLElement[] = [];
const lineCount = 24;
for (let i = 0; i < lineCount; i++) {
const line = document.createElement('div');
line.style.cssText = `
position: absolute; top: 50%; left: 50%;
width: 4px; height: 120%; background: #ED1C24;
transform-origin: center top;
transform: translate(-50%, -50%) rotate(${(360 / lineCount) * i}deg) scaleY(0);
`;
burst.appendChild(line);
lines.push(line);
}
return lines;
}
function trigger(): void {
if (isActive) { dismiss(); return; }
isActive = true;
overlay.classList.add('is-active');
const lines = createBurstLines();
const splitText = new SplitText(text, { type: 'chars' });
tl = gsap.timeline({
onComplete: () => { gsap.delayedCall(1.5, dismiss); },
});
tl.to(lines, {
scaleY: 1, stagger: { each: 0.03, from: 'random' },
duration: 0.5, ease: 'power4.out',
}, 'burst')
.to(silhouette, { opacity: 1, scale: 1.2, duration: 0.3, ease: 'power2.out' }, 'burst+=0.3')
.to(silhouette, { x: 200, rotation: 15, duration: 0.4, ease: 'power3.in' }, 'burst+=0.6')
.set(text, { opacity: 1 }, 'burst+=0.8')
.from(splitText.chars, {
opacity: 0, scale: 0, rotation: gsap.utils.wrap([-30, 30]),
stagger: 0.04, duration: 0.4, ease: 'back.out(2)',
}, 'burst+=0.8');
}
function dismiss(): void {
if (!isActive) return;
gsap.to(overlay, {
opacity: 0, duration: 0.4, ease: 'power2.in',
onComplete: () => {
overlay.classList.remove('is-active');
gsap.set(overlay, { opacity: 1 });
burst.innerHTML = '';
gsap.set(silhouette, { opacity: 0, scale: 1, x: 0, rotation: 0 });
gsap.set(text, { opacity: 0 });
isActive = false;
if (tl) { tl.kill(); tl = null; }
},
});
}
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === 'A') { e.preventDefault(); trigger(); }
if (e.key === 'Escape' && isActive) { dismiss(); }
});
}
Here is the complete src/scripts/main.ts:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
import { initLoading } from './animations/loading';
import { initHero } from './animations/hero';
import { initProjects } from './animations/projects';
import { initSkills } from './animations/skills';
import { initContact } from './animations/contact';
import { initAllOutAttack } from './animations/all-out-attack';
gsap.registerPlugin(ScrollTrigger, SplitText);
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion) {
initLoading().then(() => {
initHero();
initProjects();
initSkills();
initContact();
initAllOutAttack();
});
} else {
const loader = document.getElementById('loading-screen');
if (loader) loader.style.display = 'none';
document.querySelectorAll<HTMLElement>('.stat-bar-fill').forEach((bar) => {
const level = bar.dataset.level || '0';
bar.style.width = `${level}%`;
});
}
Secret mission
All-Out Attack Transition
Your portfolio already has a hidden easter egg wired in. In this mission, you'll explore the GSAP timeline choreography behind the iconic Persona 5 "All-Out Attack" screen-wipe effect. Trigger it, break it apart, and verify every phase works.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future tweaks, or remove them to free up disk space.
Resources you used:
- The persona5-portfolio project folder on your Desktop (includes node_modules, source code, and the dist/ build output).
- The Vite dev server process running in your terminal.
Keep everything running
No action needed. Choose this if you want to keep customizing your portfolio or deploy it to a hosting service.
- Your dev server stays running. Return to it anytime by switching back to the terminal.
- Your dist/ folder contains a production-ready static site you can deploy for free on Netlify, Vercel, or GitHub Pages.
Pause - I'll come back to this later
Stop the dev server to free up your terminal, but keep all project files for later.
- Switch to the terminal where your dev server is running.
- Press Ctrl+C to stop the server.
- When you want to resume, open the persona5-portfolio folder in VS Code and run npm run dev again.
Delete - I don't want to use this again
Remove all project files from your machine. This cannot be undone.
- Switch to the terminal where your dev server is running.
- Press Ctrl+C to stop the server.
- Delete the project folder by running the command for your operating system.
macOS
cd ~/Desktop
rm -rf persona5-portfolio
Windows
cd ~\Desktop
rmdir /s /q persona5-portfolio
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see persona5-portfolio in the output.
Nice Work!
Nice Work!
Nice work! You just built a full portfolio website that channels the aggressive visual language of Persona 5 into production-quality front-end code.
You've learned how to:
- Build true parallelogram-shaped cards using layered clip-path: polygon() frames with independent wobble animations on a 1-second loop.
- Orchestrate complex GSAP timelines with ScrollTrigger scroll-driven animations and SplitText per-character effects that slam content into view.
- Create responsive, accessible animations that gracefully degrade for prefers-reduced-motion users while maintaining full visual impact for everyone else.
- Added an All-Out Attack easter egg with radial burst lines, a silhouette slash, and per-character victory text choreography triggered by a keyboard shortcut.
Ready to quiz yourself?