refactor(hero): refine aesthetics and add GSAP animations

This commit is contained in:
Nicholai Vogel 2026-01-20 09:07:41 -07:00
parent ae1339bdbd
commit 8e5dc3758e
3 changed files with 74 additions and 39 deletions

View File

@ -34,6 +34,7 @@
"astro": "^5.16.4", "astro": "^5.16.4",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"framer-motion": "^12.26.2", "framer-motion": "^12.26.2",
"gsap": "^3.14.2",
"lunr": "^2.3.9", "lunr": "^2.3.9",
"marked": "^17.0.1", "marked": "^17.0.1",
"react": "^19.2.1", "react": "^19.2.1",

8
pnpm-lock.yaml generated
View File

@ -56,6 +56,9 @@ importers:
framer-motion: framer-motion:
specifier: ^12.26.2 specifier: ^12.26.2
version: 12.26.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1) version: 12.26.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
gsap:
specifier: ^3.14.2
version: 3.14.2
lunr: lunr:
specifier: ^2.3.9 specifier: ^2.3.9
version: 2.3.9 version: 2.3.9
@ -1994,6 +1997,9 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
gsap@3.14.2:
resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==}
h3@1.15.4: h3@1.15.4:
resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==} resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==}
@ -5035,6 +5041,8 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
gsap@3.14.2: {}
h3@1.15.4: h3@1.15.4:
dependencies: dependencies:
cookie-es: 1.2.2 cookie-es: 1.2.2

View File

@ -15,7 +15,7 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
--- ---
<section id="hero" class="relative w-full h-[100dvh] overflow-hidden bg-[var(--theme-bg-primary)]"> <section id="hero" class="relative w-full h-[100dvh] overflow-hidden bg-[var(--theme-bg-primary)]">
<!-- Industrial Scanlines --> <!-- Industrial Scanlines -->
<div class="absolute inset-0 z-1 pointer-events-none opacity-[0.03] bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,112,0.06))] bg-[length:100%_2px,3px_100%]"></div> <div class="absolute inset-0 z-1 pointer-events-none opacity-[0.02] bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,112,0.06))] bg-[length:100%_2px,3px_100%]"></div>
<!-- Background Image (Portrait) - Optimized with AVIF/WebP --> <!-- Background Image (Portrait) - Optimized with AVIF/WebP -->
<div class="absolute top-0 right-0 w-full md:w-1/2 h-full z-0"> <div class="absolute top-0 right-0 w-full md:w-1/2 h-full z-0">
@ -26,7 +26,7 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
widths={[640, 1024, 1600]} widths={[640, 1024, 1600]}
sizes="(max-width: 768px) 100vw, 50vw" sizes="(max-width: 768px) 100vw, 50vw"
alt="Nicholai Vogel portrait" alt="Nicholai Vogel portrait"
class="w-full h-full object-cover object-center opacity-0 mix-blend-luminosity transition-opacity duration-[2500ms] ease-out delay-700 intro-element" class="w-full h-full object-cover object-center opacity-0 mix-blend-luminosity intro-portrait"
id="hero-portrait" id="hero-portrait"
loading="eager" loading="eager"
decoding="sync" decoding="sync"
@ -35,7 +35,7 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
<div class="absolute inset-0 bg-gradient-to-t from-[var(--theme-bg-primary)] via-transparent to-transparent transition-colors duration-500"></div> <div class="absolute inset-0 bg-gradient-to-t from-[var(--theme-bg-primary)] via-transparent to-transparent transition-colors duration-500"></div>
<!-- Technical Overlay Elements --> <!-- Technical Overlay Elements -->
<div class="absolute bottom-12 right-12 hidden lg:flex flex-col items-end gap-1 font-mono text-[9px] text-brand-accent/40 uppercase tracking-[0.3em] intro-element opacity-0 delay-1000"> <div class="absolute bottom-12 right-12 hidden lg:flex flex-col items-end gap-1 font-mono text-[9px] text-brand-accent/40 uppercase tracking-[0.3em] intro-tech opacity-0">
<span>COORD: 38.8339° N, 104.8214° W</span> <span>COORD: 38.8339° N, 104.8214° W</span>
<span>ELV: 1,839M</span> <span>ELV: 1,839M</span>
<div class="flex gap-2 mt-2"> <div class="flex gap-2 mt-2">
@ -55,14 +55,14 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
<!-- The Content --> <!-- The Content -->
<!-- Mobile: pt-24 clears mobile top bar. Desktop: pt-16 since nav is on left side --> <!-- Mobile: pt-24 clears mobile top bar. Desktop: pt-16 since nav is on left side -->
<div class="absolute inset-0 z-20 flex flex-col justify-between p-6 md:p-12 lg:p-16 pt-24 lg:pt-16 pointer-events-auto"> <div class="absolute inset-0 z-20 flex flex-col justify-between p-8 md:p-16 lg:p-24 pt-28 lg:pt-24 pointer-events-auto">
<!-- Top Metadata --> <!-- Top Metadata -->
<div class="flex justify-between items-start w-full intro-element opacity-0 translate-y-4 transition-all duration-1000 ease-out delay-300"> <div class="flex justify-between items-start w-full intro-top opacity-0">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-1.5 h-1.5 bg-brand-accent animate-pulse"></div> <div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
<div class="font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--theme-text-muted)]"> <div class="font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">
<span class="text-brand-accent mr-1">SYS.PRTF</span> / {portfolioYear} <span class="text-brand-accent mr-1">SYS.PRTF</span> <span class="text-brand-accent font-bold mx-1">///</span> {portfolioYear}
</div> </div>
</div> </div>
@ -77,23 +77,23 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
</div> </div>
<!-- Main Heading & Description --> <!-- Main Heading & Description -->
<div class="max-w-5xl"> <div class="max-w-6xl">
<h1 class="text-6xl md:text-8xl lg:text-9xl tracking-tighter leading-[0.85] font-bold text-[var(--theme-text-primary)] mb-4 perspective-text"> <h1 class="text-5xl md:text-7xl lg:text-[9rem] tracking-tight leading-[0.85] font-bold uppercase text-[var(--theme-text-primary)] mb-8 perspective-text">
<span class="block intro-element opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-100">{headlineLine1}</span> <span class="block intro-head opacity-0">{headlineLine1}</span>
<span class="block text-brand-accent opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-200 intro-element">{headlineLine2}</span> <span class="block text-brand-accent intro-head opacity-0">{headlineLine2}</span>
</h1> </h1>
<div class="font-mono text-xs text-[var(--theme-text-muted)] mb-6 intro-element opacity-0 translate-y-4 transition-all duration-1000 ease-out delay-300"> <div class="font-mono text-sm text-[var(--theme-text-muted)] mb-8 intro-sub opacity-0">
<span class="text-brand-accent">$</span> <span class="text-[var(--theme-text-subtle)]">curl nicholai.work</span> <span class="text-brand-accent">$</span> <span class="text-[var(--theme-text-subtle)]">curl nicholai.work</span>
</div> </div>
<p class="font-mono text-sm md:text-base max-w-lg text-[var(--theme-text-secondary)] font-light leading-relaxed intro-element opacity-0 translate-y-6 transition-all duration-1000 ease-out delay-500"> <p class="font-mono text-base md:text-lg max-w-xl text-[var(--theme-text-secondary)] font-light leading-relaxed intro-bio opacity-0">
{bio} {bio}
</p> </p>
</div> </div>
<!-- Bottom Navigation --> <!-- Bottom Navigation -->
<div class="flex justify-between items-end w-full intro-element opacity-0 transition-all duration-1000 ease-out delay-700"> <div class="flex justify-between items-end w-full intro-bottom opacity-0">
<a href="#experience" class="group flex items-center gap-6 py-2"> <a href="#experience" class="group flex items-center gap-6 py-2">
<div class="relative w-12 h-12 flex items-center justify-center border border-[var(--theme-border-primary)] text-brand-accent hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300"> <div class="relative w-12 h-12 flex items-center justify-center border border-[var(--theme-border-primary)] text-brand-accent hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" class="group-hover:translate-y-1 transition-transform duration-300"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" class="group-hover:translate-y-1 transition-transform duration-300">
@ -128,25 +128,11 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
/* Snappier fade-out */ /* Snappier fade-out */
transition: opacity 0.6s ease-out, background-color 0.6s ease-out; transition: opacity 0.6s ease-out, background-color 0.6s ease-out;
} }
/* Initial Loaded State Classes */
.intro-visible {
opacity: 1 !important;
transform: translateY(0) !important;
}
/* Portrait Loaded State */
.portrait-visible {
opacity: 0.4 !important; /* Mobile default */
}
@media (min-width: 768px) {
.portrait-visible {
opacity: 0.6 !important; /* Desktop default */
}
}
</style> </style>
<script> <script>
import gsap from 'gsap';
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false; const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false;
const finePointer = window.matchMedia?.('(pointer: fine) and (hover: hover)')?.matches ?? false; const finePointer = window.matchMedia?.('(pointer: fine) and (hover: hover)')?.matches ?? false;
@ -159,12 +145,59 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
if (pulseInterval) window.clearInterval(pulseInterval); if (pulseInterval) window.clearInterval(pulseInterval);
clockTimer = undefined; clockTimer = undefined;
pulseInterval = undefined; pulseInterval = undefined;
// Kill any GSAP animations on this component to prevent memory leaks/conflicts
gsap.killTweensOf('.intro-portrait, .intro-top, .intro-head, .intro-sub, .intro-bio, .intro-bottom, .intro-tech');
} }
function initHero() { function initHero() {
// Clean up previous instances first // Clean up previous instances first
cleanup(); cleanup();
// ===== GSAP INTRO ANIMATIONS =====
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
// Portrait opacity based on screen size
const portraitOpacity = window.innerWidth >= 768 ? 0.7 : 0.4;
if (!reduceMotion) {
tl.to('.intro-portrait', {
opacity: portraitOpacity,
duration: 2.5,
ease: 'power2.out'
}, 0.5)
.fromTo('.intro-top',
{ y: 20, opacity: 0 },
{ y: 0, opacity: 1, duration: 1 },
0.8
)
.fromTo('.intro-head',
{ y: 50, opacity: 0 },
{ y: 0, opacity: 1, duration: 1.2, stagger: 0.15 },
0.9
)
.fromTo('.intro-sub',
{ y: 20, opacity: 0 },
{ y: 0, opacity: 1, duration: 1 },
1.3
)
.fromTo('.intro-bio',
{ y: 30, opacity: 0 },
{ y: 0, opacity: 1, duration: 1 },
1.5
)
.to('.intro-bottom', { opacity: 1, duration: 1 }, 1.8)
.to('.intro-tech', { opacity: 1, duration: 1 }, 2.2);
} else {
// Reduced motion: simple fades
gsap.to('.intro-portrait', { opacity: portraitOpacity, duration: 1.5 });
gsap.to('.intro-top, .intro-head, .intro-sub, .intro-bio, .intro-bottom, .intro-tech', {
opacity: 1,
duration: 1,
stagger: 0.2,
delay: 0.5
});
}
// ===== CLOCK ===== // ===== CLOCK =====
const clock = document.getElementById('clock'); const clock = document.getElementById('clock');
if (clock) { if (clock) {
@ -196,17 +229,10 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
} }
// ===== INTRO ANIMATIONS ===== // ===== INTRO ANIMATIONS =====
// Trigger Intro Elements // (Handled by GSAP above)
const introElements = document.querySelectorAll('.intro-element');
introElements.forEach(el => {
el.classList.add('intro-visible');
});
// Trigger Portrait // Trigger Portrait
const portrait = document.getElementById('hero-portrait'); // (Handled by GSAP above)
if (portrait) {
portrait.classList.add('portrait-visible');
}
const section = document.getElementById('hero'); const section = document.getElementById('hero');
const cells = document.querySelectorAll('.grid-cell'); const cells = document.querySelectorAll('.grid-cell');