portfolio anim
This commit is contained in:
@@ -1,18 +1,23 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* nameStage:
|
* Cycle timeline (all times relative to stage-1 firing):
|
||||||
* 0 – all hidden, b/vic.com reset to off-screen (transition: none)
|
* +0ms stage 1 — Alex fades in (alexOn=true, nameStage=1)
|
||||||
* 1 – "Alex" fades in, b/vic.com still off-screen
|
* +1300ms stage 2 — b/vic.com start sliding in; A→a crossfade begins
|
||||||
* 2 – b slides in from left, vic.com from right; "A" → "a"
|
* +4300ms shift — slides done (~3 s), heading drifts left over 3 s
|
||||||
* 3 – b/vic.com fade out in-place; "a" → "A"
|
* +7300ms stage 3 — shift done; b/vic.com fade out; a→A; heading drifts back
|
||||||
|
* +9400ms alexOff — b/vic.com nearly gone (2.1 s); Alex starts fading (2.4 s)
|
||||||
|
* +11800ms reset — Alex gone; hard-reset b/vic.com positions (transition:none)
|
||||||
|
* 5 000ms pause
|
||||||
|
* +16800ms — next stage 1 → CYCLE_GAP = 16 800 ms
|
||||||
*
|
*
|
||||||
* Cycle: 0→1 (300ms) →2 (1600ms) →3 (4600ms) →0 (7400ms) → repeat every 7600ms
|
* Content (badge, role, desc, buttons, scroll) fades up once at t=3 s, stays forever.
|
||||||
* Content (subtitle, buttons, etc.) fades in once at 3000ms and stays.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
const [nameStage, setNameStage] = useState(0)
|
const [nameStage, setNameStage] = useState(0)
|
||||||
|
const [alexOn, setAlexOn] = useState(false)
|
||||||
|
const [shiftLeft, setShiftLeft] = useState(false)
|
||||||
const [contentVisible, setContentVisible] = useState(false)
|
const [contentVisible, setContentVisible] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -21,45 +26,48 @@ export function HeroSection() {
|
|||||||
const add = (fn: () => void, ms: number) =>
|
const add = (fn: () => void, ms: number) =>
|
||||||
timers.push(setTimeout(() => { if (alive) fn() }, ms))
|
timers.push(setTimeout(() => { if (alive) fn() }, ms))
|
||||||
|
|
||||||
const CYCLE = 7600
|
const CYCLE_GAP = 16800
|
||||||
|
|
||||||
const scheduleCycle = (offset: number) => {
|
const scheduleCycle = (t1: number) => {
|
||||||
add(() => setNameStage(1), offset + 300)
|
add(() => { setNameStage(1); setAlexOn(true) }, t1)
|
||||||
add(() => setNameStage(2), offset + 1600)
|
add(() => setNameStage(2), t1 + 1300)
|
||||||
add(() => setNameStage(3), offset + 4600)
|
add(() => setShiftLeft(true), t1 + 4300)
|
||||||
add(() => setNameStage(0), offset + 7400)
|
add(() => { setNameStage(3); setShiftLeft(false) }, t1 + 7300)
|
||||||
|
add(() => setAlexOn(false), t1 + 9400)
|
||||||
|
add(() => setNameStage(0), t1 + 11800)
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleCycle(0)
|
scheduleCycle(300)
|
||||||
add(() => setContentVisible(true), 3000)
|
add(() => setContentVisible(true), 3000)
|
||||||
|
|
||||||
for (let i = 1; i <= 40; i++) scheduleCycle(CYCLE * i)
|
for (let i = 1; i <= 20; i++) scheduleCycle(300 + CYCLE_GAP * i)
|
||||||
|
|
||||||
return () => { alive = false; timers.forEach(clearTimeout) }
|
return () => { alive = false; timers.forEach(clearTimeout) }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/** Styles for the sliding "b" and "vic.com" spans */
|
/* ── style helpers ── */
|
||||||
|
|
||||||
|
const noTrans = nameStage === 0
|
||||||
|
|
||||||
|
/** Sliding accent spans ("b" left, "vic.com" right) */
|
||||||
const sideStyle = (side: 'left' | 'right'): React.CSSProperties => {
|
const sideStyle = (side: 'left' | 'right'): React.CSSProperties => {
|
||||||
const offscreen = side === 'left' ? 'translateX(-110vw)' : 'translateX(110vw)'
|
const offscreen = side === 'left' ? 'translateX(-110vw)' : 'translateX(110vw)'
|
||||||
const reset = nameStage === 0
|
const active = nameStage === 2
|
||||||
const visible = nameStage === 2
|
const fadeOut = nameStage === 3
|
||||||
const fadingOut = nameStage === 3
|
|
||||||
return {
|
return {
|
||||||
display: 'inline-block',
|
|
||||||
color: '#22d3ee',
|
color: '#22d3ee',
|
||||||
opacity: visible ? 1 : 0,
|
opacity: active ? 1 : 0,
|
||||||
transform: visible || fadingOut ? 'translateX(0)' : offscreen,
|
transform: active || fadeOut ? 'translateX(0)' : offscreen,
|
||||||
transition: reset
|
transition: noTrans ? 'none' : 'opacity 2.1s ease-out, transform 3s ease-out',
|
||||||
? 'none'
|
whiteSpace: 'nowrap',
|
||||||
: 'opacity 0.7s ease-out, transform 1s ease-out',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generic fade-up for content blocks */
|
/** Content blocks below the heading */
|
||||||
const fadeUp = (active: boolean, delay = '0s'): React.CSSProperties => ({
|
const fadeUp = (active: boolean, delay = '0s'): React.CSSProperties => ({
|
||||||
opacity: active ? 1 : 0,
|
opacity: active ? 1 : 0,
|
||||||
transform: active ? 'translateY(0)' : 'translateY(18px)',
|
transform: active ? 'translateY(0)' : 'translateY(18px)',
|
||||||
transition: `opacity 0.8s ease ${delay}, transform 0.8s ease ${delay}`,
|
transition: `opacity 2.4s ease ${delay}, transform 2.4s ease ${delay}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,10 +98,26 @@ export function HeroSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Animated heading ── */}
|
{/* ── heading shift wrapper ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
transform: shiftLeft ? 'translateX(-15%)' : 'translateX(0)',
|
||||||
|
transition: noTrans ? 'none' : 'transform 3s ease-in-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
* Grid layout keeps "Alex" always horizontally centered:
|
||||||
|
* [minmax(0,1fr)] [auto] [minmax(0,1fr)]
|
||||||
|
* b Alex vic.com
|
||||||
|
* The 1fr columns don't shrink below 0, so Alex stays at center
|
||||||
|
* regardless of whether b/vic.com are visible.
|
||||||
|
*/}
|
||||||
<h1
|
<h1
|
||||||
className="font-heading font-extrabold mb-4 leading-none"
|
className="font-heading font-extrabold mb-4 leading-none"
|
||||||
style={{
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0,1fr) auto minmax(0,1fr)',
|
||||||
|
alignItems: 'baseline',
|
||||||
fontSize: 'clamp(3rem, 9.5vw, 7.5rem)',
|
fontSize: 'clamp(3rem, 9.5vw, 7.5rem)',
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
@@ -101,24 +125,56 @@ export function HeroSection() {
|
|||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* "b" — slides in from the left */}
|
|
||||||
<span style={sideStyle('left')}>b</span>
|
|
||||||
|
|
||||||
{/* "Alex" / "alex" — fades in first */}
|
{/* "b" — right-aligned so it sits flush against "Alex" */}
|
||||||
|
<span style={{ ...sideStyle('left'), justifySelf: 'end' }}>b</span>
|
||||||
|
|
||||||
|
{/* "Alex" / "alex" */}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
opacity: nameStage >= 1 ? 1 : 0,
|
opacity: alexOn ? 1 : 0,
|
||||||
transition: nameStage === 0 ? 'none' : 'opacity 0.8s ease-out',
|
transition: noTrans ? 'none' : 'opacity 2.4s ease-out',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nameStage === 2 ? 'alex' : 'Alex'}
|
{/*
|
||||||
|
* A/a crossfade: "A" lives in normal flow (sets the container width),
|
||||||
|
* "a" is absolutely overlaid and centered over "A".
|
||||||
|
* Both fade over 3.5 s (5× the base 0.7 s) — nameStage===2 means
|
||||||
|
* "b" has arrived, so lowercase "a" should be visible.
|
||||||
|
*/}
|
||||||
|
<span style={{ display: 'inline-block', position: 'relative' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
opacity: nameStage === 2 ? 0 : 1,
|
||||||
|
transition: noTrans ? 'none' : 'opacity 3.5s ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
opacity: nameStage === 2 ? 1 : 0,
|
||||||
|
transition: noTrans ? 'none' : 'opacity 3.5s ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
a
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
lex
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* "vic.com" — slides in from the right */}
|
{/* "vic.com" — left-aligned so it sits flush against "Alex" */}
|
||||||
<span style={sideStyle('right')}>vic.com</span>
|
<span style={{ ...sideStyle('right'), justifySelf: 'start' }}>vic.com</span>
|
||||||
|
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Role */}
|
{/* Role */}
|
||||||
<div style={fadeUp(contentVisible, '0s')}>
|
<div style={fadeUp(contentVisible, '0s')}>
|
||||||
@@ -128,7 +184,7 @@ export function HeroSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div style={fadeUp(contentVisible, '0.1s')}>
|
<div style={fadeUp(contentVisible, '0.3s')}>
|
||||||
<p className="text-zinc-400 text-lg max-w-xl mx-auto mb-10 font-body leading-relaxed">
|
<p className="text-zinc-400 text-lg max-w-xl mx-auto mb-10 font-body leading-relaxed">
|
||||||
Building backend systems and intelligent applications with{' '}
|
Building backend systems and intelligent applications with{' '}
|
||||||
<span className="text-zinc-200 font-mono text-base">Spring Boot</span>,{' '}
|
<span className="text-zinc-200 font-mono text-base">Spring Boot</span>,{' '}
|
||||||
@@ -138,7 +194,7 @@ export function HeroSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CTA buttons */}
|
{/* CTA buttons */}
|
||||||
<div style={fadeUp(contentVisible, '0.2s')}>
|
<div style={fadeUp(contentVisible, '0.6s')}>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||||
<a
|
<a
|
||||||
href="/static/cv.pdf"
|
href="/static/cv.pdf"
|
||||||
@@ -183,7 +239,7 @@ export function HeroSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll hint */}
|
{/* Scroll hint */}
|
||||||
<div style={fadeUp(contentVisible, '0.3s')}>
|
<div style={fadeUp(contentVisible, '0.9s')}>
|
||||||
<div className="mt-16 flex flex-col items-center gap-2 text-zinc-600 text-xs font-mono tracking-widest">
|
<div className="mt-16 flex flex-col items-center gap-2 text-zinc-600 text-xs font-mono tracking-widest">
|
||||||
<span>SCROLL</span>
|
<span>SCROLL</span>
|
||||||
<svg width="14" height="20" viewBox="0 0 14 20" fill="none" className="animate-bounce">
|
<svg width="14" height="20" viewBox="0 0 14 20" fill="none" className="animate-bounce">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function Header() {
|
|||||||
<path d="M16 5 L25 27" stroke="#22d3ee" strokeWidth="2.5" strokeLinecap="round"/>
|
<path d="M16 5 L25 27" stroke="#22d3ee" strokeWidth="2.5" strokeLinecap="round"/>
|
||||||
<line x1="10.5" y1="19" x2="21.5" y2="19" stroke="#22d3ee" strokeWidth="2" strokeLinecap="round"/>
|
<line x1="10.5" y1="19" x2="21.5" y2="19" stroke="#22d3ee" strokeWidth="2" strokeLinecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
Alexander<span className="text-accent">.</span>
|
b<span className="text-accent">A</span>lex
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center gap-8">
|
<nav className="hidden md:flex items-center gap-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user