From 0aa03f16f70c79178a6bdbeac0ea9f39eca8ee4c Mon Sep 17 00:00:00 2001 From: Vo Nguyen Dang Khoa Date: Tue, 28 Apr 2026 11:51:27 +0700 Subject: [PATCH] feat: Add scroll-based VNDK logo animation, interactive flying letters --- README.md | 31 ++++++------- src/App.jsx | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 92b609e..47e7b55 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # KHOA.VO Portfolio -Personal portfolio website featuring dual personas (Creative & IT), inspired by Simmonds Ltd design aesthetics. +Personal portfolio website featuring dual personas (Creative & IT), with animated scroll-based branding experience. ## Live Site @@ -10,28 +10,24 @@ Personal portfolio website featuring dual personas (Creative & IT), inspired by ## Features ### Creative Side +- **Animated VNDK Logo**: Flying letters that deconstruct/reconstruct during scroll +- **Scroll-triggered animation**: Letters appear while scrolling, disappear when stopped +- **2.5D floating effect**: Letters wave and spread during scroll +- **Reconstructed footer**: Full logo appears centered at bottom - **Three viewing modes**: Grid, List, Minimal - **Image effects**: Grayscale + pixelated + blur → Full color on hover - **Enhanced project modal**: Keyboard navigation (ESC, Arrow keys) -- **Professional Journey**: Longer, more detailed for HR/readability ### IT Side - **Retro desktop UI**: Draggable windows - **CRT screen effects**: Scanlines, vignette -- **Idle screensaver**: 5s timeout with animated logo +- **Idle screensaver**: 10s timeout with animated logo ### Design -- **Simmonds Ltd inspired**: Dark/light theme, grid patterns, phosphor green accents -- **Typography**: Syne (display) + IBM Plex Mono -- **Default theme**: Light - -### Print CV (Downloadable PDF) -- Professional B&W print-friendly A4 format -- Two-column layout: Sidebar (contact, skills, education) + Main content -- Includes all 8 job experiences from Graphic Artist to AI Creative Lead -- Strategic IT Projects section showcasing Full-Stack development skills -- Awards & Recognition section -- Clean, minimal design optimized for HR/recruiters +- **Dual persona**: Creative Portfolio & IT Developer modes +- **Dark/light theme**: Toggle between modes +- **Grid patterns**: Subtle background textures +- **Phosphor green accents**: #00FF94 signature color ## Tech Stack @@ -54,10 +50,11 @@ npm run dev npm run build ``` -## Print CV +## Deployment -Press the "Download CV" button or use `window.print()` to generate the PDF CV. -Select "Save as PDF" in the print dialog. +The site deploys automatically via Forgejo CI/CD to: +- **Frontend**: https://khoavo.myds.me +- **Git**: https://git.khoavo.myds.me/vndangkhoa/kv-cv ## License diff --git a/src/App.jsx b/src/App.jsx index ac4675c..12a90d0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -543,18 +543,42 @@ const CreativeSide = ({ onBack, onSwitch, darkMode, toggleTheme }) => { const [activeProject, setActiveProject] = useState(null); const [visibleProjects, setVisibleProjects] = useState([]); const [viewMode, setViewMode] = useState('grid'); + const [scrollY, setScrollY] = useState(0); + const [isScrolling, setIsScrolling] = useState(false); + const scrollTimerRef = useRef(null); const bgClass = "bg-[var(--bg-primary)]"; const textClass = "text-[var(--text-primary)]"; const subTextClass = "text-[var(--text-secondary)]"; const borderClass = "border-[var(--border)]"; + // Scroll progress (0 to 1) + const getScrollProgress = () => { + const maxScroll = document.documentElement.scrollHeight - window.innerHeight; + return maxScroll > 0 ? Math.min(scrollY / maxScroll, 1) : 0; + }; + useEffect(() => { const handleScroll = () => { + setScrollY(window.scrollY); setShowScrollTop(window.scrollY > 400); + setIsScrolling(true); + + // Clear existing timer + if (scrollTimerRef.current) { + clearTimeout(scrollTimerRef.current); + } + + // Hide letters after 150ms of no scroll + scrollTimerRef.current = setTimeout(() => { + setIsScrolling(false); + }, 150); }; window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); + return () => { + window.removeEventListener('scroll', handleScroll); + if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); + }; }, []); // Keyboard navigation for modal @@ -627,6 +651,83 @@ const CreativeSide = ({ onBack, onSwitch, darkMode, toggleTheme }) => { + {/* Scroll-based Flying Letters - only show while scrolling */} +
+ {['V', 'N', 'D', 'K'].map((letter, idx) => { + const letterColor = ['V', 'N'].includes(letter) + ? (darkMode ? '#FFFFFF' : '#1A1A1A') + : '#00FF94'; + const progress = getScrollProgress(); + + // Spread amount: 0 at start, max at 50%, back to 0 at end + const spreadProgress = Math.sin(progress * Math.PI); + const spreadAmount = spreadProgress * 180; + + // Move down from nav toward footer + const moveDown = progress * 280; + + // Y wave for floating + const yWave = Math.sin(progress * Math.PI * 4 + idx * 1.5) * 25; + + // Only visible while scrolling and during scroll movement + const opacity = isScrolling && progress > 0.02 && progress < 0.96 ? 1 : 0; + + // Original logo positions spread: V leftmost, N rightmost, D leftmost, K rightmost + // Move them outward from their origin positions + const offsetX = [0, 0, 0, 0]; // No additional X offset, use spread + const basePositions = [ + { x: -spreadAmount - 35, y: -45 + moveDown + yWave }, // V - top left + { x: spreadAmount + 35, y: -45 + moveDown - yWave }, // N - top right + { x: -spreadAmount - 35, y: 55 + moveDown + yWave }, // D - bottom left + { x: spreadAmount + 35, y: 55 + moveDown - yWave }, // K - bottom right + ]; + + // Exact paths from VndkLogo + const letterPaths = { + V: [{ d: "M 15 25 L 30 45 L 45 25", color: letterColor }], + N: [{ d: "M 55 45 L 55 25 L 85 45 L 85 25", color: letterColor }], + D: [{ d: "M 15 55 L 30 55 A 10 10 0 0 1 30 75 L 15 75 Z", color: letterColor }], + K: [ + { d: "M 55 55 L 55 75", color: letterColor }, + { d: "M 85 55 L 55 65 L 85 75", color: letterColor }, + ], + }; + + return ( + window.scrollTo({ top: 0, behavior: 'smooth' })} + > + + {letterPaths[letter].map((pathData, pathIdx) => ( + + ))} + + + ); + })} +
+ {/* Back to Top Button */} {showScrollTop && ( @@ -876,6 +977,30 @@ const CreativeSide = ({ onBack, onSwitch, darkMode, toggleTheme }) => { {/* Contact */}