├── .eslintrc.cjs ├── .gitignore ├── LICENSE ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public ├── android-chrome-192x192.png ├── android-chrome-256x256.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── img │ ├── fishtrack │ │ ├── mockup.webp │ │ └── preview.webp │ ├── meetmate │ │ ├── dashboard.webp │ │ └── landing.webp │ ├── portfolio │ │ ├── about.webp │ │ └── landing.webp │ └── tcg │ │ ├── collection.webp │ │ └── landing.webp ├── mstile-150x150.png ├── safari-pinned-tab.svg └── site.webmanifest ├── renovate.json ├── src ├── App.tsx ├── assets │ └── globals.scss ├── components │ ├── Loader.tsx │ ├── Magnetic.tsx │ ├── MouseGradient.tsx │ ├── NavMenu.tsx │ ├── Redirect.tsx │ ├── SectionSpacer.tsx │ ├── about │ │ └── index.tsx │ ├── contact │ │ └── index.tsx │ ├── hero │ │ ├── BackgroundSVG.tsx │ │ ├── LoopingAnimation.tsx │ │ └── Navbar.tsx │ └── projects │ │ ├── Curve.tsx │ │ ├── Overlay.tsx │ │ └── index.tsx ├── hooks │ ├── useColorAnimation.ts │ └── useIsTouchDevice.ts ├── index.tsx └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | bun.lockb 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ben Böckmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Portfolio | bencodes 11 | 15 | 16 | 17 | 18 | 19 | 20 | 24 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 38 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 |
52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "portfolio_v3", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@studio-freight/react-lenis": "^0.0.47", 14 | "framer-motion": "^11.3.8", 15 | "gsap": "^3.12.5", 16 | "is-touch-device": "^1.0.1", 17 | "lodash": "^4.17.21", 18 | "lucide-react": "^0.414.0", 19 | "react": "^18.3.1", 20 | "react-dom": "^18.3.1", 21 | "react-router-dom": "^6.25.1", 22 | "react-spring": "^9.7.3" 23 | }, 24 | "devDependencies": { 25 | "@types/lodash": "^4.17.7", 26 | "@types/react": "^18.3.3", 27 | "@types/react-dom": "^18.3.0", 28 | "@typescript-eslint/eslint-plugin": "^7.15.0", 29 | "@typescript-eslint/parser": "^7.15.0", 30 | "@vitejs/plugin-react-swc": "^3.5.0", 31 | "autoprefixer": "^10.4.19", 32 | "cz-conventional-changelog": "^3.3.0", 33 | "eslint": "^8.57.0", 34 | "eslint-plugin-react-hooks": "^4.6.2", 35 | "eslint-plugin-react-refresh": "^0.4.7", 36 | "postcss": "^8.4.39", 37 | "sass": "^1.77.8", 38 | "tailwindcss": "^3.4.6", 39 | "typescript": "^5.2.2", 40 | "vite": "^5.3.4" 41 | }, 42 | "config": { 43 | "commitizen": { 44 | "path": "./node_modules/cz-conventional-changelog" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/android-chrome-256x256.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/favicon.ico -------------------------------------------------------------------------------- /public/img/fishtrack/mockup.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/fishtrack/mockup.webp -------------------------------------------------------------------------------- /public/img/fishtrack/preview.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/fishtrack/preview.webp -------------------------------------------------------------------------------- /public/img/meetmate/dashboard.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/meetmate/dashboard.webp -------------------------------------------------------------------------------- /public/img/meetmate/landing.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/meetmate/landing.webp -------------------------------------------------------------------------------- /public/img/portfolio/about.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/portfolio/about.webp -------------------------------------------------------------------------------- /public/img/portfolio/landing.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/portfolio/landing.webp -------------------------------------------------------------------------------- /public/img/tcg/collection.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/tcg/collection.webp -------------------------------------------------------------------------------- /public/img/tcg/landing.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/tcg/landing.webp -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-256x256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef, useCallback, useMemo } from "react"; 2 | import "./assets/globals.scss"; 3 | import Navbar from "./components/hero/Navbar"; 4 | import { 5 | motion, 6 | useInView, 7 | useMotionValue, 8 | useMotionValueEvent, 9 | useScroll, 10 | useTransform, 11 | } from "framer-motion"; 12 | import MouseGradient from "./components/MouseGradient"; 13 | import { debounce } from "lodash"; 14 | import BackgroundSVG from "./components/hero/BackgroundSVG"; 15 | import About from "./components/about"; 16 | import { useColorAnimation } from "./hooks/useColorAnimation"; 17 | import Contact from "./components/contact"; 18 | import Projects from "./components/projects"; 19 | import SectionSpacer from "./components/SectionSpacer"; 20 | import { useIsTouchDevice } from "./hooks/useIsTouchDevice"; 21 | import Loader from "./components/Loader"; 22 | import { ReactLenis } from "@studio-freight/react-lenis"; 23 | 24 | function App() { 25 | const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); 26 | const dimensionsRef = useRef({ width: 0, height: 0 }); 27 | const aboutRef = useRef(null); 28 | const projectsRef = useRef(null); 29 | const contactRef = useRef(null); 30 | const isMobile = useMemo(() => window.innerWidth <= 768, []); 31 | 32 | const isTouchDevice = useIsTouchDevice(); 33 | 34 | // ----- Dimension update ----- // 35 | const updateDimensions = useCallback( 36 | debounce(() => { 37 | const newDimensions = { 38 | width: window.innerWidth, 39 | height: window.innerHeight, 40 | }; 41 | dimensionsRef.current = newDimensions; 42 | setDimensions(newDimensions); 43 | }, 200), 44 | [], 45 | ); 46 | 47 | useEffect(() => { 48 | updateDimensions(); 49 | window.addEventListener("resize", updateDimensions); 50 | 51 | return () => window.removeEventListener("resize", updateDimensions); 52 | }, [updateDimensions]); 53 | 54 | // ----- Scroll animations ----- // 55 | const { scrollYProgress } = useScroll(); 56 | const backgroundGradient = useMotionValue( 57 | "radial-gradient(circle, #111111 0%, #000000 65%)", 58 | ); 59 | const textColor = useMotionValue("#FFFFFF"); 60 | const svgOpacity = useMotionValue(1); 61 | 62 | const handleScroll = useCallback( 63 | (latest: number) => { 64 | requestAnimationFrame(() => { 65 | const progress = !isMobile 66 | ? Math.max(0, Math.min((latest - 0.1) / 0.1, 1)) 67 | : Math.max(0, Math.min((latest - 0.03) / 0.1, 1)); 68 | 69 | const startColor = [0, 0, 0]; 70 | const endColor = [255, 255, 255]; // #FFFFFF 71 | 72 | const interpolateColor = (start: number[], end: number[]): string => 73 | start 74 | .map((channel, i) => 75 | Math.round(channel + (end[i] - channel) * progress), 76 | ) 77 | .join(", "); 78 | 79 | const newGradient = `radial-gradient(circle, rgb(${interpolateColor( 80 | [17, 17, 17], 81 | endColor, 82 | )}) 0%, rgb(${interpolateColor(startColor, endColor)}) 65%)`; 83 | backgroundGradient.set(newGradient); 84 | 85 | if (progress < 0.1) { 86 | document.body.style.backgroundColor = "#000000"; 87 | document.getElementById("root")!.style.backgroundColor = "#000000"; 88 | document.documentElement.style.backgroundColor = "#000000"; 89 | } else if (progress > 0.3) { 90 | document.body.style.backgroundColor = "#ffffff"; 91 | document.getElementById("root")!.style.backgroundColor = "#ffffff"; 92 | document.documentElement.style.backgroundColor = "#ffffff"; 93 | } 94 | 95 | const txtColor = `rgb(${255 - Math.round(255 * progress)}, ${ 96 | 255 - Math.round(255 * progress) 97 | }, ${255 - Math.round(255 * progress)})`; 98 | textColor.set(txtColor); 99 | const newOpacity = 1 - progress * 3; 100 | svgOpacity.set(newOpacity); 101 | }); 102 | }, 103 | [isMobile], 104 | ); 105 | 106 | useMotionValueEvent(scrollYProgress, "change", handleScroll); 107 | 108 | // ----- Color Animation ----- // 109 | const { hue1, hue2 } = useColorAnimation(); 110 | 111 | // ----- Loading Animation ----- // 112 | const [isLoading, setIsLoading] = useState(true); 113 | 114 | const landingSectionVariants = { 115 | hidden: { scale: 0.8, opacity: 0 }, 116 | visible: { 117 | scale: 1, 118 | opacity: 1, 119 | transition: { 120 | duration: 0.5, 121 | ease: "easeOut", 122 | when: "beforeChildren", 123 | staggerChildren: 0.1, 124 | delay: 0.5, 125 | type: "tween", 126 | useNativeDriver: true 127 | }, 128 | }, 129 | }; 130 | 131 | const initialState = isMobile ? "visible" : "hidden"; 132 | 133 | return ( 134 | 135 | setIsLoading(false)} /> 136 | 137 |
138 | 139 | 143 | 150 | 151 | 157 | 171 | Turning ideas into{" "} 172 | 177 | `linear-gradient(90deg, hsl(${h1}, 100%, 50%), hsl(${h2}, 100%, 50%))` 178 | ), 179 | backgroundClip: "text", 180 | WebkitBackgroundClip: "text", 181 | color: "transparent", 182 | }} 183 | > 184 | creative 185 | {" "} 186 | solutions. 187 | 188 | 201 | Innovative web developer crafting unique user experiences. 202 | 203 | 204 | 205 |
206 | 211 |
212 | 213 | 214 | 215 |
216 | 223 |
224 | 225 |
226 | 231 |
232 |
233 |
234 | ); 235 | } 236 | 237 | export default App; 238 | -------------------------------------------------------------------------------- /src/assets/globals.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @import url("https://fonts.googleapis.com/css2?family=Khula:wght@300;400;600;700;800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"); 6 | 7 | @layer base { 8 | :root { 9 | --dark: #000000; 10 | --light: #ffffff; 11 | --landing-bg-image: radial-gradient(circle, #111111 0%, #000000 65%); 12 | 13 | --gray-4: #1c1c1c; 14 | --gray-3: #666666; 15 | --gray-2: #888888; 16 | --gray-1: #b0b0b0; 17 | 18 | --svg-line: #2c2c2c; 19 | } 20 | } 21 | 22 | #root, 23 | body, 24 | html { 25 | overflow-x: hidden; 26 | background-color: var(--dark); 27 | } 28 | 29 | .contact-bg { 30 | position: relative; 31 | overflow: hidden; 32 | background-color: var(--light); 33 | &::before { 34 | content: ""; 35 | position: absolute; 36 | left: -50%; 37 | bottom: -50%; 38 | width: 100%; 39 | height: 100%; 40 | background-color: var(--light); 41 | background: radial-gradient( 42 | circle, 43 | rgb(177, 175, 255) 0%, 44 | rgba(255, 255, 255, 1) 60% 45 | ); 46 | filter: blur(100px); 47 | z-index: -1; 48 | } 49 | 50 | &::after { 51 | content: ""; 52 | position: absolute; 53 | left: 50%; 54 | bottom: -50%; 55 | width: 100%; 56 | height: 100%; 57 | background-color: var(--light); 58 | background: radial-gradient( 59 | circle, 60 | rgb(187, 233, 255) 0%, 61 | rgba(255, 255, 255, 1) 60% 62 | ); 63 | filter: blur(100px); 64 | z-index: -1; 65 | } 66 | } 67 | 68 | // Khula Font 69 | .khula-light { 70 | font-family: "Khula", sans-serif; 71 | font-weight: 300; 72 | font-style: normal; 73 | } 74 | 75 | .khula-regular { 76 | font-family: "Khula", sans-serif; 77 | font-weight: 400; 78 | font-style: normal; 79 | } 80 | 81 | .khula-semibold { 82 | font-family: "Khula", sans-serif; 83 | font-weight: 600; 84 | font-style: normal; 85 | } 86 | 87 | .khula-bold { 88 | font-family: "Khula", sans-serif; 89 | font-weight: 700; 90 | font-style: normal; 91 | } 92 | 93 | .khula-extrabold { 94 | font-family: "Khula", sans-serif; 95 | font-weight: 800; 96 | font-style: normal; 97 | } 98 | 99 | // Poppins Font 100 | .poppins-thin { 101 | font-family: "Poppins", sans-serif; 102 | font-weight: 100; 103 | font-style: normal; 104 | } 105 | 106 | .poppins-extralight { 107 | font-family: "Poppins", sans-serif; 108 | font-weight: 200; 109 | font-style: normal; 110 | } 111 | 112 | .poppins-light { 113 | font-family: "Poppins", sans-serif; 114 | font-weight: 300; 115 | font-style: normal; 116 | } 117 | 118 | .poppins-regular { 119 | font-family: "Poppins", sans-serif; 120 | font-weight: 400; 121 | font-style: normal; 122 | } 123 | 124 | .poppins-medium { 125 | font-family: "Poppins", sans-serif; 126 | font-weight: 500; 127 | font-style: normal; 128 | } 129 | 130 | .poppins-semibold { 131 | font-family: "Poppins", sans-serif; 132 | font-weight: 600; 133 | font-style: normal; 134 | } 135 | 136 | .poppins-bold { 137 | font-family: "Poppins", sans-serif; 138 | font-weight: 700; 139 | font-style: normal; 140 | } 141 | 142 | .poppins-extrabold { 143 | font-family: "Poppins", sans-serif; 144 | font-weight: 800; 145 | font-style: normal; 146 | } 147 | 148 | .poppins-black { 149 | font-family: "Poppins", sans-serif; 150 | font-weight: 900; 151 | font-style: normal; 152 | } 153 | 154 | .poppins-thin-italic { 155 | font-family: "Poppins", sans-serif; 156 | font-weight: 100; 157 | font-style: italic; 158 | } 159 | 160 | .poppins-extralight-italic { 161 | font-family: "Poppins", sans-serif; 162 | font-weight: 200; 163 | font-style: italic; 164 | } 165 | 166 | .poppins-light-italic { 167 | font-family: "Poppins", sans-serif; 168 | font-weight: 300; 169 | font-style: italic; 170 | } 171 | 172 | .poppins-regular-italic { 173 | font-family: "Poppins", sans-serif; 174 | font-weight: 400; 175 | font-style: italic; 176 | } 177 | 178 | .poppins-medium-italic { 179 | font-family: "Poppins", sans-serif; 180 | font-weight: 500; 181 | font-style: italic; 182 | } 183 | 184 | .poppins-semibold-italic { 185 | font-family: "Poppins", sans-serif; 186 | font-weight: 600; 187 | font-style: italic; 188 | } 189 | 190 | .poppins-bold-italic { 191 | font-family: "Poppins", sans-serif; 192 | font-weight: 700; 193 | font-style: italic; 194 | } 195 | 196 | .poppins-extrabold-italic { 197 | font-family: "Poppins", sans-serif; 198 | font-weight: 800; 199 | font-style: italic; 200 | } 201 | 202 | .poppins-black-italic { 203 | font-family: "Poppins", sans-serif; 204 | font-weight: 900; 205 | font-style: italic; 206 | } 207 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from "react"; 2 | import { motion, AnimatePresence, useAnimation } from "framer-motion"; 3 | 4 | type LoaderProps = { 5 | onLoadingComplete: () => void; 6 | }; 7 | 8 | const Loader: React.FC = ({ onLoadingComplete }) => { 9 | const [show, setShow] = useState(true); 10 | const controls = useAnimation(); 11 | const animationCompleted = useRef(false); 12 | 13 | useEffect(() => { 14 | const animateSvg = async () => { 15 | document.body.style.cursor = "wait"; 16 | // Start the SVG animation 17 | await controls 18 | .start({ 19 | pathLength: 1, 20 | transition: { duration: 2, ease: "easeInOut" }, 21 | }) 22 | .then(() => { 23 | document.body.style.cursor = "auto"; 24 | }); 25 | 26 | // Wait 300ms after animation completes 27 | await new Promise((resolve) => setTimeout(resolve, 300)); 28 | 29 | animationCompleted.current = true; 30 | 31 | // Only now check if the page has loaded 32 | if (document.readyState === "complete") { 33 | setShow(false); 34 | } else { 35 | window.addEventListener("load", handlePageLoad); 36 | } 37 | }; 38 | 39 | const handlePageLoad = () => { 40 | if (animationCompleted.current) { 41 | setShow(false); 42 | } 43 | }; 44 | 45 | // Start animation 46 | animateSvg(); 47 | 48 | return () => { 49 | window.removeEventListener("load", handlePageLoad); 50 | }; 51 | }, [controls]); 52 | 53 | // Call onLoadingComplete when the loader starts to fade out 54 | useEffect(() => { 55 | if (!show) { 56 | onLoadingComplete(); 57 | } 58 | }, [show, onLoadingComplete]); 59 | 60 | return ( 61 | 62 | {show && ( 63 | 80 | 89 | 99 | 100 | 101 | )} 102 | 103 | ); 104 | }; 105 | 106 | export default Loader; 107 | -------------------------------------------------------------------------------- /src/components/Magnetic.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import gsap from "gsap"; 3 | import { useIsTouchDevice } from "../hooks/useIsTouchDevice"; 4 | 5 | // Utility function for throttling 6 | function throttle(func: Function, limit: number) { 7 | let inThrottle: boolean; 8 | return function (this: any, ...args: any[]) { 9 | if (!inThrottle) { 10 | func.apply(this, args); 11 | inThrottle = true; 12 | setTimeout(() => (inThrottle = false), limit); 13 | } 14 | }; 15 | } 16 | 17 | export default function Index({ children }: { children: JSX.Element }) { 18 | const magnetic = useRef(null); 19 | const animation = useRef(null); 20 | const isTouchDevice = useIsTouchDevice(); 21 | 22 | useEffect(() => { 23 | if (isTouchDevice) return; 24 | const element = magnetic.current; 25 | if (!element) return; 26 | 27 | const maxDistance = 20; // Maximum pixel distance to move 28 | 29 | const animate = (x: number, y: number) => { 30 | if (animation.current) { 31 | animation.current.kill(); 32 | } 33 | animation.current = gsap.to(element, { 34 | x, 35 | y, 36 | duration: 0.7, 37 | ease: "power2.out", 38 | }); 39 | }; 40 | 41 | const calculateMovement = (clientX: number, clientY: number) => { 42 | const { height, width, left, top } = element.getBoundingClientRect(); 43 | let x = clientX - (left + width / 2); 44 | let y = clientY - (top + height / 2); 45 | 46 | // Limit the movement range 47 | const distance = Math.sqrt(x * x + y * y); 48 | if (distance > maxDistance) { 49 | const factor = maxDistance / distance; 50 | x *= factor; 51 | y *= factor; 52 | } 53 | 54 | return { x, y }; 55 | }; 56 | 57 | const mouseMove = throttle((e: MouseEvent) => { 58 | const { x, y } = calculateMovement(e.clientX, e.clientY); 59 | requestAnimationFrame(() => animate(x, y)); 60 | }, 16); // Throttle to about 60fps 61 | 62 | const mouseLeave = () => { 63 | requestAnimationFrame(() => animate(0, 0)); 64 | }; 65 | 66 | element.addEventListener("mousemove", mouseMove); 67 | element.addEventListener("mouseleave", mouseLeave); 68 | 69 | return () => { 70 | element.removeEventListener("mousemove", mouseMove); 71 | element.removeEventListener("mouseleave", mouseLeave); 72 | }; 73 | }, [isTouchDevice]); 74 | 75 | return React.cloneElement(children, { ref: magnetic }); 76 | } 77 | -------------------------------------------------------------------------------- /src/components/MouseGradient.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | import { useSpring, animated, config } from "react-spring"; 3 | import NavMenu from "./NavMenu"; 4 | import { Equal } from "lucide-react"; 5 | import { useScroll, useTransform, motion } from "framer-motion"; 6 | import gsap from "gsap"; 7 | 8 | const MouseGradient = ({ isMobile }: { isMobile: boolean }) => { 9 | const [isMenuOpen, setIsMenuOpen] = useState(false); 10 | const [textColor, setTextColor] = useState<"white" | "transparent">("white"); 11 | const gradientRef = useRef(null); 12 | const animation = useRef(null); 13 | 14 | const { scrollYProgress, scrollY } = useScroll(); 15 | 16 | const gradientOpacity = useTransform(scrollY, [0, 200], [1, 0]); 17 | 18 | const [buttonProps, setButtonProps] = useSpring(() => ({ 19 | color: "rgb(255, 255, 255)", 20 | backgroundColor: "rgba(0, 0, 0, 0)", 21 | config: config.gentle, 22 | })); 23 | 24 | useEffect(() => { 25 | const element = gradientRef.current; 26 | if (!element) return; 27 | 28 | const maxDistance = 80; // Maximum pixel distance to move 29 | 30 | const animate = (x: number, y: number) => { 31 | if (animation.current) { 32 | animation.current.kill(); 33 | } 34 | animation.current = gsap.to(element, { 35 | x, 36 | y, 37 | duration: 0.3, 38 | ease: "power2.out", 39 | }); 40 | }; 41 | 42 | const calculateMovement = (clientX: number, clientY: number) => { 43 | const centerX = window.innerWidth / 2; 44 | const centerY = window.innerHeight / 2; 45 | let x = (clientX - centerX) / 10; 46 | let y = (clientY - centerY) / 10; 47 | 48 | // Limit the movement range 49 | const distance = Math.sqrt(x * x + y * y); 50 | if (distance > maxDistance) { 51 | const factor = maxDistance / distance; 52 | x *= factor; 53 | y *= factor; 54 | } 55 | 56 | return { x, y }; 57 | }; 58 | 59 | const mouseMove = (e: MouseEvent) => { 60 | const { x, y } = calculateMovement(e.clientX, e.clientY); 61 | requestAnimationFrame(() => animate(x, y)); 62 | }; 63 | 64 | const mouseLeave = () => { 65 | requestAnimationFrame(() => animate(0, 0)); 66 | }; 67 | 68 | window.addEventListener("mousemove", mouseMove); 69 | window.addEventListener("mouseleave", mouseLeave); 70 | 71 | return () => { 72 | window.removeEventListener("mousemove", mouseMove); 73 | window.removeEventListener("mouseleave", mouseLeave); 74 | }; 75 | }, []); 76 | 77 | useEffect(() => { 78 | const handleScroll = () => { 79 | if (scrollYProgress.get() > 0.2 && scrollYProgress.get() < 0.4) { 80 | const t = (scrollYProgress.get() - 0.2) / 0.2; // normalize to 0-1 81 | setButtonProps({ 82 | color: `rgb(${Math.round(255 * (1 - t))}, ${Math.round( 83 | 255 * (1 - t), 84 | )}, ${Math.round(255 * (1 - t))})`, 85 | backgroundColor: `rgba(255, 255, 255, ${t})`, 86 | }); 87 | setTextColor("transparent"); 88 | } else if (scrollYProgress.get() <= 0.2) { 89 | setTextColor("white"); 90 | setButtonProps({ 91 | color: "rgb(255, 255, 255)", 92 | backgroundColor: "rgba(0, 0, 0, 0)", 93 | }); 94 | } else if (scrollYProgress.get() >= 0.4) { 95 | setButtonProps({ 96 | color: "rgb(0, 0, 0)", 97 | backgroundColor: "rgb(255, 255, 255)", 98 | }); 99 | setTextColor("transparent"); 100 | } 101 | }; 102 | 103 | window.addEventListener("scroll", handleScroll); 104 | 105 | return () => { 106 | window.removeEventListener("scroll", handleScroll); 107 | }; 108 | }, [setButtonProps]); 109 | 110 | return ( 111 | <> 112 | {!isMobile && ( 113 | 768 ? "fixed" : "absolute", 117 | top: "50%", 118 | left: "50%", 119 | width: `${ 120 | !isMobile ? Math.min(window.innerWidth, window.innerHeight) : 0 121 | }px`, 122 | height: `${ 123 | !isMobile ? Math.min(window.innerWidth, window.innerHeight) : 0 124 | }px`, 125 | transform: "translate(-50%, -50%)", 126 | pointerEvents: "none", 127 | zIndex: 0, 128 | opacity: gradientOpacity, 129 | background: `radial-gradient(circle, rgba(190, 190, 255, 0.06) 0%, transparent 50%)`, 130 | }} 131 | /> 132 | )} 133 | 134 |
135 | {window.innerWidth > 768 && ( 136 | 143 | )} 144 | 145 | 768 ? "fixed" : "absolute", 148 | right: "1.5rem", 149 | padding: "0.5rem 1rem", 150 | color: buttonProps.color, 151 | }} 152 | className="fixed top-6 right-16 z-[11] px-4 py-2 text-light text-xl poppins-regular flex flex-row gap-x-2 items-center" 153 | onClick={() => setIsMenuOpen(!isMenuOpen)} 154 | > 155 | 156 | 157 |
158 | setIsMenuOpen(false)} /> 159 | 160 | ); 161 | }; 162 | 163 | export default MouseGradient; 164 | -------------------------------------------------------------------------------- /src/components/NavMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { motion, AnimatePresence, useScroll } from "framer-motion"; 3 | import { X } from "lucide-react"; 4 | import { useLenis } from "@studio-freight/react-lenis"; 5 | 6 | type NavMenuProps = { 7 | isOpen: boolean; 8 | onClose: () => void; 9 | }; 10 | 11 | const NavMenu: React.FC = ({ isOpen, onClose }) => { 12 | const { scrollY } = useScroll(); 13 | 14 | const lenis = useLenis(); 15 | 16 | const handleNavClick = ( 17 | e: React.MouseEvent, 18 | targetId: string, 19 | ) => { 20 | e.preventDefault(); 21 | if (lenis) { 22 | const target = document.getElementById(targetId); 23 | if (target) { 24 | lenis.scrollTo(target); 25 | } 26 | } 27 | onClose(); 28 | }; 29 | return ( 30 | <> 31 | {/* Backdrop */} 32 | 33 | {isOpen && ( 34 | 43 | )} 44 | 45 | 46 | 63 | 64 | {/* Navigation Menu */} 65 | 91 | 97 | 103 | 104 | 105 |
106 |
107 | 113 |

Social

114 |
    115 | {[ 116 | { 117 | name: "LinkedIn", 118 | link: "https://linkedin.com/in/ben-böckmann-296293265", 119 | }, 120 | { 121 | name: "Instagram", 122 | link: "https://instagram.com/ben.bck_prvt", 123 | }, 124 | { name: "Github", link: "https://github.com/bencodes07" }, 125 | ].map((item, index) => ( 126 | 132 | 137 | {item.name} 138 | 139 | 140 | ))} 141 |
142 |
143 | 144 | 150 |

Menu

151 | 180 |
181 |
182 |
183 | 184 | 190 |

Get in touch

191 | 192 | info@bencodes.de 193 | 194 |
195 |
196 | 197 | ); 198 | }; 199 | 200 | export default NavMenu; 201 | -------------------------------------------------------------------------------- /src/components/Redirect.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | export default function Redirect() { 5 | const navigate = useNavigate(); 6 | 7 | useEffect(() => { 8 | navigate("/"); 9 | }, [navigate]); 10 | return null; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/SectionSpacer.tsx: -------------------------------------------------------------------------------- 1 | import { MotionValue, motion } from "framer-motion"; 2 | 3 | export default function SectionSpacer({ 4 | backgroundGradient, 5 | height, 6 | }: { 7 | backgroundGradient: MotionValue; 8 | height: number; 9 | }) { 10 | return ( 11 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/about/index.tsx: -------------------------------------------------------------------------------- 1 | import { MotionValue, useAnimationControls, motion } from "framer-motion"; 2 | import { ArrowUpRight } from "lucide-react"; 3 | import { useEffect, useState } from "react"; 4 | import Magnetic from "../Magnetic"; 5 | import { useLenis } from "@studio-freight/react-lenis"; 6 | 7 | type AboutSectionProps = { 8 | isAboutInView: boolean; 9 | isMobile: boolean; 10 | backgroundGradient: MotionValue; 11 | }; 12 | 13 | const fadeInUpVariants = { 14 | hidden: { opacity: 0, y: 50 }, 15 | visible: (custom: number) => ({ 16 | opacity: 1, 17 | y: 0, 18 | transition: { 19 | duration: 0.6, 20 | ease: "easeOut", 21 | delay: custom * 0.2, 22 | type: "tween", 23 | useNativeDriver: true 24 | }, 25 | }), 26 | }; 27 | 28 | const lineVariants = { 29 | hidden: { width: 0 }, 30 | visible: { 31 | width: "100%", 32 | transition: { 33 | duration: 1.5, 34 | ease: "easeInOut", 35 | type: "tween", 36 | useNativeDriver: true 37 | }, 38 | }, 39 | }; 40 | 41 | const About: React.FC = ({ 42 | isAboutInView, 43 | isMobile, 44 | backgroundGradient, 45 | }) => { 46 | const aboutControls = useAnimationControls(); 47 | 48 | const [hasAnimated, setHasAnimated] = useState(false); 49 | 50 | const lenis = useLenis(); 51 | 52 | useEffect(() => { 53 | if (isAboutInView && !hasAnimated) { 54 | aboutControls.start("visible"); 55 | setHasAnimated(true); 56 | } else if (!isAboutInView && hasAnimated) { 57 | aboutControls.start("hidden"); 58 | setHasAnimated(false); 59 | } 60 | }, [isAboutInView, aboutControls, hasAnimated, setHasAnimated]); 61 | 62 | const initialState = isMobile ? "visible" : "hidden"; 63 | 64 | return ( 65 | 69 | 74 | 79 | I believe in a user centered design approach, ensuring that every 80 | project I work on is tailored to meet the specific needs of its users. 81 | 82 | 83 | 88 |

89 | This is me. 90 |

91 | 95 |
96 |
101 |
102 | 107 | Hi, I'm Ben. 108 | 109 | {!isMobile && ( 110 | 111 | lenis?.scrollTo("#contact")} 115 | className="flex bg-dark rounded-full text-light pl-4 pr-6 gap-x-1 py-3 w-max poppins-regular mt-24 select-none" 116 | > 117 | 118 | Get in Touch 119 | 120 | 121 | )} 122 |
123 |
128 | 129 | I'm a 17 year-old web developer dedicated to turning ideas into 130 | creative solutions. I specialize in creating seamless and 131 | intuitive user experiences. 132 | 133 | 134 | I'm involved in every step of the process: from discovery and 135 | design to development, testing, and deployment. I focus on 136 | delivering high-quality, scalable results that drive positive user 137 | experiences. 138 | 139 |
140 | {isMobile && ( 141 | 145 | document.getElementById("contact")?.scrollIntoView() 146 | } 147 | className="flex bg-dark rounded-full text-light pl-4 pr-6 gap-x-1 py-3 w-max h-fit poppins-regular select-none mt-8" 148 | > 149 | 150 | Get in Touch 151 | 152 | )} 153 |
154 |
155 |
156 | ); 157 | }; 158 | 159 | export default About; 160 | -------------------------------------------------------------------------------- /src/components/contact/index.tsx: -------------------------------------------------------------------------------- 1 | import { MotionValue, motion, useAnimationControls } from "framer-motion"; 2 | import { Linkedin, Mail, Phone } from "lucide-react"; 3 | import { useEffect, useState } from "react"; 4 | import Magnetic from "../Magnetic"; 5 | 6 | type ContactSectionProps = { 7 | isContactInView: boolean; 8 | isMobile: boolean; 9 | backgroundGradient: MotionValue; 10 | }; 11 | 12 | const fadeInUpVariants = { 13 | hidden: { opacity: 0, y: 50 }, 14 | visible: (custom: number) => ({ 15 | opacity: 1, 16 | y: 0, 17 | transition: { 18 | duration: 0.6, 19 | ease: "easeOut", 20 | delay: custom * 0.2, 21 | type: "tween", 22 | useNativeDriver: true 23 | }, 24 | }), 25 | }; 26 | 27 | const Contact: React.FC = ({ 28 | isContactInView, 29 | isMobile, 30 | }) => { 31 | const contactControls = useAnimationControls(); 32 | 33 | const [hasAnimated, setHasAnimated] = useState(false); 34 | 35 | useEffect(() => { 36 | if (isContactInView && !hasAnimated) { 37 | contactControls.start("visible"); 38 | setHasAnimated(true); 39 | } else if (!isContactInView && hasAnimated) { 40 | contactControls.start("hidden"); 41 | setHasAnimated(false); 42 | } 43 | }, [isContactInView, contactControls, hasAnimated, setHasAnimated]); 44 | 45 | const initialState = isMobile ? "visible" : "hidden"; 46 | 47 | return ( 48 | 55 | 60 | Want to collaborate? 61 | 62 | 67 | Let's have a chat! 68 | 69 | 74 | 75 | 79 | 80 | Email 81 | 82 | 83 | 84 | 88 | 89 | Phone 90 | 91 | 92 | 93 | 98 | 99 | LinkedIn 100 | 101 | 102 | 103 | 108 |

bb

109 |

Ben Böckmann

110 | 111 |

112 | © Ben Böckmann {new Date().getFullYear()}. All rights reserved. 113 | Location: Germany 114 |

115 |

116 | This site showcases my personal projects and professional work. 117 | Content may not be used without permission. 118 |

119 |
120 |
121 | ); 122 | }; 123 | export default Contact; 124 | -------------------------------------------------------------------------------- /src/components/hero/BackgroundSVG.tsx: -------------------------------------------------------------------------------- 1 | import { MotionValue, motion } from "framer-motion"; 2 | import { useMemo } from "react"; 3 | import LoopingAnimation from "./LoopingAnimation"; 4 | 5 | type BackgroundSVGProps = { 6 | width: number; 7 | height: number; 8 | isMobile: boolean; 9 | svgOpacity: MotionValue; 10 | isLoading: boolean; 11 | }; 12 | 13 | const drawVariant = { 14 | hidden: { pathLength: 0, opacity: 0 }, 15 | visible: { 16 | pathLength: 1, 17 | opacity: 1, 18 | transition: { 19 | pathLength: { type: "spring", duration: 5, bounce: 0, delay: 0.5 }, 20 | opacity: { duration: 0.8, ease: "easeInOut", delay: 0.5 }, 21 | type: "tween", 22 | useNativeDriver: true 23 | }, 24 | }, 25 | }; 26 | 27 | const BackgroundSVG: React.FC = ({ 28 | width, 29 | height, 30 | isMobile, 31 | svgOpacity, 32 | isLoading, 33 | }) => { 34 | const renderSVGLines = useMemo(() => { 35 | if (width === 0 || height === 0 || isLoading) return null; 36 | 37 | if (isMobile) { 38 | // Render three lines for mobile: left, center, and right 39 | const centerX = width / 2; 40 | const xOffset = width * 0.3; 41 | const leftX = centerX - xOffset; 42 | const rightX = centerX + xOffset; 43 | 44 | return ( 45 | <> 46 | 56 | 66 | 76 | 77 | ); 78 | } else { 79 | const offsets = [ 80 | -0.12 - 30 / width - 0.125, 81 | -0.12 - 30 / width, 82 | -0.12, 83 | 0, 84 | 0.12, 85 | 0.12 + 30 / width, 86 | 0.12 + 30 / width + 0.125, 87 | ]; 88 | 89 | return offsets.map((offset, index) => { 90 | const x = width / 2 + width * offset; 91 | const centerY = height / 2; 92 | 93 | return ( 94 | 102 | ); 103 | }); 104 | } 105 | }, [width, height, isMobile, isLoading]); 106 | 107 | const initialState = isMobile ? "visible" : "hidden"; 108 | 109 | return ( 110 | 114 | {width > 0 && height > 0 && ( 115 | <> 116 | 123 | {renderSVGLines} 124 | 125 | 126 | 127 | )} 128 | 129 | ); 130 | }; 131 | 132 | export default BackgroundSVG; 133 | -------------------------------------------------------------------------------- /src/components/hero/LoopingAnimation.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useEffect, useRef } from "react"; 2 | import { motion, useAnimate } from "framer-motion"; 3 | 4 | type AnimationState = { 5 | leftKey: number; 6 | rightKey: number; 7 | }; 8 | 9 | type Action = { type: "INCREMENT_LEFT" } | { type: "INCREMENT_RIGHT" }; 10 | 11 | const initialState: AnimationState = { leftKey: 0, rightKey: 1 }; 12 | 13 | function reducer(state: AnimationState, action: Action): AnimationState { 14 | switch (action.type) { 15 | case "INCREMENT_LEFT": 16 | return { ...state, leftKey: state.leftKey + 2 }; 17 | case "INCREMENT_RIGHT": 18 | return { ...state, rightKey: state.rightKey + 2 }; 19 | } 20 | } 21 | 22 | type AnimatedShapeProps = { 23 | side: "left" | "right"; 24 | onComplete: () => void; 25 | width: number; 26 | height: number; 27 | isMobile: boolean; 28 | }; 29 | 30 | function AnimatedShape({ 31 | side, 32 | onComplete, 33 | width, 34 | height, 35 | isMobile, 36 | }: AnimatedShapeProps) { 37 | const [scope, animate] = useAnimate(); 38 | const animationRef = useRef(null); 39 | 40 | useEffect(() => { 41 | const runAnimation = async () => { 42 | // Draw the outline 43 | await animate(scope.current, { pathLength: 1 }, { duration: 1.5 }); 44 | // Fill the shape 45 | await animate(scope.current, { fillOpacity: 1 }, { duration: 0.5 }); 46 | // Move the shape down 47 | await animate(scope.current, { y: "50%" }, { duration: 1 }); 48 | onComplete(); 49 | }; 50 | 51 | animationRef.current = requestAnimationFrame(runAnimation); 52 | 53 | return () => { 54 | if (animationRef.current) { 55 | cancelAnimationFrame(animationRef.current); 56 | } 57 | }; 58 | }, [animate, onComplete]); 59 | 60 | const centerX = width / 2; 61 | const yStart = (height * 2) / 3; 62 | const xOffset = isMobile ? width * 0.3 : width * 0.12; 63 | 64 | const path = `M ${centerX + (side === "left" ? -xOffset : xOffset)} ${yStart} 65 | L ${centerX} ${yStart + 100} 66 | L ${centerX} ${yStart + 132} 67 | L ${centerX + (side === "left" ? -xOffset : xOffset)} ${ 68 | yStart + 32 69 | } Z`; 70 | 71 | return ( 72 | 78 | 86 | 87 | ); 88 | } 89 | 90 | interface LoopingAnimationProps { 91 | width: number; 92 | height: number; 93 | isMobile: boolean; 94 | } 95 | 96 | function LoopingAnimation({ width, height, isMobile }: LoopingAnimationProps) { 97 | const [state, dispatch] = useReducer(reducer, initialState); 98 | 99 | return ( 100 | <> 101 | dispatch({ type: "INCREMENT_LEFT" })} 105 | width={width} 106 | height={height} 107 | isMobile={isMobile} 108 | /> 109 | dispatch({ type: "INCREMENT_RIGHT" })} 113 | width={width} 114 | height={height} 115 | isMobile={isMobile} 116 | /> 117 | 118 | ); 119 | } 120 | 121 | export default LoopingAnimation; 122 | -------------------------------------------------------------------------------- /src/components/hero/Navbar.tsx: -------------------------------------------------------------------------------- 1 | export default function Navbar() { 2 | return ( 3 |
4 |

bb

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/components/projects/Curve.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { motion, useAnimationControls } from "framer-motion"; 3 | 4 | type CurveProps = { 5 | isVisible: boolean; 6 | }; 7 | 8 | export default function Curve({ isVisible }: CurveProps) { 9 | const [dimensions, setDimensions] = useState({ 10 | width: typeof window !== "undefined" ? window.innerWidth : 1920, 11 | height: typeof window !== "undefined" ? window.innerHeight : 1080, 12 | }); 13 | const controls = useAnimationControls(); 14 | 15 | useEffect(() => { 16 | function resize() { 17 | setDimensions({ 18 | width: window.innerWidth, 19 | height: window.innerHeight, 20 | }); 21 | } 22 | resize(); 23 | window.addEventListener("resize", resize); 24 | return () => { 25 | window.removeEventListener("resize", resize); 26 | }; 27 | }, []); 28 | 29 | useEffect(() => { 30 | if (isVisible) { 31 | controls.start("visible"); 32 | } else { 33 | controls.start("hidden"); 34 | } 35 | }, [isVisible, controls]); 36 | 37 | const curveHeight = dimensions.height * 0.1; // 10% of view height 38 | 39 | const initialPath = ` 40 | M0 ${dimensions.height + curveHeight} 41 | Q${dimensions.width / 2} ${dimensions.height + curveHeight} ${ 42 | dimensions.width 43 | } ${dimensions.height + curveHeight} 44 | L${dimensions.width} ${dimensions.height + curveHeight} 45 | L0 ${dimensions.height + curveHeight} 46 | `; 47 | 48 | const midPath = ` 49 | M0 ${curveHeight} 50 | Q${dimensions.width / 2} 0 ${dimensions.width} ${curveHeight} 51 | L${dimensions.width} ${dimensions.height + curveHeight} 52 | L0 ${dimensions.height + curveHeight} 53 | `; 54 | 55 | const targetPath = ` 56 | M0 ${-curveHeight} 57 | Q${dimensions.width / 2} ${-curveHeight * 2} ${ 58 | dimensions.width 59 | } ${-curveHeight} 60 | L${dimensions.width} ${dimensions.height + curveHeight} 61 | L0 ${dimensions.height + curveHeight} 62 | `; 63 | 64 | const variants = { 65 | hidden: { 66 | d: [targetPath, midPath, initialPath], 67 | transition: { 68 | duration: 1.2, 69 | ease: [0.76, 0, 0.24, 1], 70 | times: [0, 0.4, 1], 71 | }, 72 | }, 73 | visible: { 74 | d: [initialPath, midPath, targetPath], 75 | transition: { 76 | duration: 1.2, 77 | ease: [0.76, 0, 0.24, 1], 78 | times: [0, 0.6, 1], 79 | }, 80 | }, 81 | }; 82 | 83 | return ( 84 | 90 | 96 | 102 | 103 | 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/components/projects/Overlay.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowUpRight } from "lucide-react"; 2 | import { Project } from "."; 3 | import { motion } from "framer-motion"; 4 | import Magnetic from "../Magnetic"; 5 | 6 | export default function Overlay({ 7 | project, 8 | isMobile, 9 | }: { 10 | project: Project; 11 | isMobile: boolean; 12 | }) { 13 | return ( 14 | 22 |
26 |
29 |

30 | {project.title} 31 |

32 | 33 | 39 | 40 | 41 | 42 |
43 | 44 |
45 |
49 |
50 |

51 | Description 52 |

53 |
54 |

55 | {project.description} 56 |

57 |
58 |
59 |

60 | Technologies 61 |

62 |
63 |

64 |

65 | Frontend: 66 | {project.technologies.frontend} 67 |

68 |

69 | Backend: 70 | {project.technologies.backend.includes("Not Involved") ? ( 71 | Not Involved 72 | ) : ( 73 | project.technologies.backend 74 | )} 75 |

76 |

77 |
78 |
79 | 87 | {project.title.includes("TCG") && ( 88 |

89 | Disclaimer: This project was developed during my employment at 90 | TCG-Vault, where I contributed as part of the development team. 91 |

92 | )} 93 |
94 |
95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/components/projects/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState, useCallback } from "react"; 2 | import { 3 | MotionValue, 4 | motion, 5 | AnimatePresence, 6 | useSpring, 7 | useAnimationControls, 8 | } from "framer-motion"; 9 | import { useIsTouchDevice } from "../../hooks/useIsTouchDevice"; 10 | import Curve from "./Curve"; 11 | import Overlay from "./Overlay"; 12 | import { X } from "lucide-react"; 13 | import { useLenis } from "@studio-freight/react-lenis"; 14 | 15 | type ProjectsSectionProps = { 16 | isProjectsInView: boolean; 17 | isMobile: boolean; 18 | backgroundGradient: MotionValue; 19 | }; 20 | 21 | export type Project = { 22 | number: string; 23 | title: string; 24 | category: string; 25 | year: string; 26 | image: string; 27 | imageDetail: string; 28 | description: string; 29 | technologies: { frontend: string; backend: string }; 30 | color: string; 31 | link: string; 32 | }; 33 | 34 | const fadeInUpVariants = { 35 | hidden: { opacity: 0, y: 50 }, 36 | visible: (custom: number) => ({ 37 | opacity: 1, 38 | y: 0, 39 | transition: { 40 | duration: 0.6, 41 | ease: "easeOut", 42 | delay: custom * 0.2, 43 | type: "tween", 44 | useNativeDriver: true 45 | }, 46 | }), 47 | exit: { 48 | opacity: 0, 49 | y: 50, 50 | transition: { 51 | duration: 0.4, 52 | ease: "easeIn", 53 | type: "tween", 54 | useNativeDriver: true 55 | }, 56 | }, 57 | }; 58 | 59 | const Projects: React.FC = ({ 60 | isProjectsInView, 61 | isMobile, 62 | backgroundGradient, 63 | }) => { 64 | const galleryRef = useRef(null); 65 | const imagesRef = useRef(null); 66 | const itemsRef = useRef(null); 67 | const [activeIndex, setActiveIndex] = useState(-1); 68 | const [isScrolling, setIsScrolling] = useState(false); 69 | 70 | const isTouchDevice = useIsTouchDevice(); 71 | 72 | const projectsControls = useAnimationControls(); 73 | const [hasAnimated, setHasAnimated] = useState(false); 74 | 75 | const cursorX = useSpring(0, { stiffness: 200, damping: 50 }); 76 | const cursorY = useSpring(0, { stiffness: 200, damping: 50 }); 77 | 78 | const projects: Project[] = [ 79 | { 80 | number: "01", 81 | title: "MeetMate", 82 | category: "Web Development / Design", 83 | year: "2024-25", 84 | image: "./img/meetmate/landing.webp", 85 | imageDetail: "./img/meetmate/dashboard.webp", 86 | description: 87 | "MeetMate is a web application streamlining appointment management for businesses and clients. It simplifies scheduling, allowing clients to book with various companies while businesses manage availability efficiently. This approach reduces time spent on booking and organizing appointments for all parties.", 88 | color: "77, 128, 237", 89 | technologies: { 90 | frontend: "NextJS, TailwindCSS, ThreeJS", 91 | backend: "Spring Boot, GraphQL, PostgreSQL, MongoDB", 92 | }, 93 | link: "https://meetmate.dev", 94 | }, 95 | { 96 | number: "02", 97 | title: "fishtrack.", 98 | category: "iOS Development / Product Design", 99 | year: "2023-24", 100 | image: "./img/fishtrack/preview.webp", 101 | imageDetail: "./img/fishtrack/mockup.webp", 102 | description: 103 | "fishtrack is an iOS app for fishing enthusiasts to log and analyze their catches. It extracts date and location from photos, allows users to add fish details, and provides filtering options. Anglers can easily track their catches and view statistics, gaining insights into their fishing patterns over time.", 104 | technologies: { 105 | frontend: "Swift, SwiftUI, UIKit", 106 | backend: "Supabase", 107 | }, 108 | color: "0 122 255", 109 | link: "https://github.com/bencodes07/fishtrackMobile", 110 | }, 111 | { 112 | number: "03", 113 | title: "TCG-Home", 114 | category: "Frontend Development", 115 | year: "2021-Now", 116 | image: "./img/tcg/landing.webp", 117 | imageDetail: "./img/tcg/collection.webp", 118 | description: `TCG Home is an innovative online platform transforming the global niche market of collectible card games like "Magic: The Gathering". This project moves such games into the digital era by creating a comprehensive seamless portal where collecting, playing, and trading can take place.`, 119 | technologies: { 120 | frontend: "VueJS, Typescript, GraphQL", 121 | backend: "Not Involved", 122 | }, 123 | color: "121 35 208", 124 | link: "https://tcg-home.com", 125 | }, 126 | { 127 | number: "04", 128 | title: "Portfolio", 129 | category: "Web Development", 130 | year: "2024", 131 | image: "./img/portfolio/landing.webp", 132 | imageDetail: "./img/portfolio/about.webp", 133 | description: 134 | "This portfolio showcases a range of web development projects, demonstrating proficiency in creating practical, user-focused applications. From appointment management systems to specialized mobile apps, each project highlights problem-solving skills and technical expertise. Click the arrow to view the Figma Design", 135 | technologies: { 136 | frontend: "React, TailwindCSS, Framer Motion", 137 | backend: "N/A", 138 | }, 139 | color: "255 255 255", 140 | link: "https://www.figma.com/design/fSOLXbVsHPG3k61ffrfFLQ/Portfolio?m=auto&t=KL4Fad6LDLPN60Us-1", 141 | }, 142 | ]; 143 | 144 | useEffect(() => { 145 | if (isProjectsInView && !hasAnimated) { 146 | projectsControls.start("visible"); 147 | setTimeout(() => { 148 | setHasAnimated(true); 149 | }, 500); 150 | } else if (!isProjectsInView && hasAnimated) { 151 | projectsControls.start("hidden"); 152 | setHasAnimated(false); 153 | } 154 | }, [isProjectsInView, projectsControls, hasAnimated, setHasAnimated]); 155 | 156 | // ----- Hover effect ----- // 157 | 158 | const handleMouseMove = useCallback( 159 | (e: MouseEvent) => { 160 | cursorX.set(e.clientX); 161 | cursorY.set(e.clientY); 162 | }, 163 | [cursorX, cursorY], 164 | ); 165 | 166 | const handleScroll = useCallback(() => { 167 | setIsScrolling(true); 168 | setTimeout(() => setIsScrolling(false), 100); // Debounce scrolling state 169 | }, []); 170 | 171 | useEffect(() => { 172 | if (isMobile) return; 173 | 174 | const items = itemsRef.current; 175 | if (!items) return; 176 | 177 | window.addEventListener("mousemove", handleMouseMove); 178 | window.addEventListener("scroll", handleScroll); 179 | 180 | const checkHover = () => { 181 | if (isScrolling) { 182 | const hoverItem = document.elementFromPoint( 183 | cursorX.get(), 184 | cursorY.get(), 185 | ); 186 | const projectItem = hoverItem?.closest(".project-item"); 187 | if (projectItem) { 188 | const index = Array.from(items.children).indexOf( 189 | projectItem as Element, 190 | ); 191 | setActiveIndex(index); 192 | } else { 193 | setActiveIndex(-1); 194 | } 195 | } 196 | }; 197 | 198 | items.addEventListener("mouseleave", () => { 199 | setActiveIndex(-1); 200 | }); 201 | 202 | const scrollCheckInterval = setInterval(checkHover, 100); 203 | 204 | return () => { 205 | window.removeEventListener("mousemove", handleMouseMove); 206 | window.removeEventListener("scroll", handleScroll); 207 | clearInterval(scrollCheckInterval); 208 | }; 209 | }, [isMobile, handleMouseMove, handleScroll, cursorX, cursorY, isScrolling]); 210 | 211 | // ----- Overlay ----- // 212 | 213 | const [isOverlayVisible, setIsOverlayVisible] = useState(false); 214 | const [selectedProject, setSelectedProject] = useState(null); 215 | const [isContentVisible, setIsContentVisible] = useState(false); 216 | 217 | const handleProjectClick = (project: Project) => { 218 | setSelectedProject(project); 219 | setIsOverlayVisible(true); 220 | }; 221 | 222 | const closeOverlay = () => { 223 | setIsContentVisible(false); 224 | setTimeout(() => { 225 | setIsOverlayVisible(false); 226 | }, 800); 227 | }; 228 | 229 | const lenis = useLenis(); 230 | 231 | useEffect(() => { 232 | if (isOverlayVisible) { 233 | lenis?.stop(); 234 | document.documentElement.style.overflowY = "hidden"; 235 | const timer = setTimeout(() => { 236 | setIsContentVisible(true); 237 | }, 800); 238 | return () => clearTimeout(timer); 239 | } else { 240 | lenis?.start(); 241 | document.documentElement.style.overflowY = "auto"; 242 | } 243 | }, [isOverlayVisible]); 244 | 245 | // ----- Image Preloading ----- // 246 | 247 | useEffect(() => { 248 | projects.map((project: Project) => { 249 | const img = new Image(); 250 | img.src = project.image; 251 | 252 | const img2 = new Image(); 253 | img2.src = project.imageDetail; 254 | }); 255 | }, []); 256 | 257 | const initialState = isMobile ? "visible" : "hidden"; 258 | 259 | return ( 260 | 269 | {isTouchDevice || (!isTouchDevice && isMobile) ? ( 270 | 271 | 276 | Selected Projects 277 | 278 | 279 | {/* Mobile Version: Card like design */} 280 |
281 | {projects.map((project, index) => ( 282 | handleProjectClick(project)} 287 | custom={index + 1} 288 | > 289 |
294 |

{project.title}

295 |
296 |
297 |

298 | {project.category} 299 |

300 |

{project.year}

301 |
302 |
303 | ))} 304 |
305 |
306 | ) : ( 307 | 312 | 317 | Selected Projects 318 | 319 | 320 | {hasAnimated && ( 321 | 322 | {activeIndex !== -1 && ( 323 | 340 | 346 | {projects.map((project) => ( 347 | 352 | ))} 353 | 354 | 355 | )} 356 | 357 | )} 358 | 359 |
363 | {projects.map((project, index) => ( 364 | setActiveIndex(index)} 369 | onClick={() => handleProjectClick(project)} 370 | variants={fadeInUpVariants} 371 | custom={index + 1} 372 | > 373 |
374 |
375 |

376 | {project.number} 377 |

378 |

379 | {project.title} 380 |

381 |
382 |

383 | {project.category} 384 |

385 |
386 |
387 |
388 | ))} 389 |
390 |
391 | )} 392 | 393 | 394 | {(isOverlayVisible || selectedProject) && ( 395 | <> 396 | 397 | e.stopPropagation()} 404 | onTouchMove={(e) => e.stopPropagation()} 405 | > 406 | 407 | {isContentVisible && selectedProject && ( 408 | 409 | )} 410 | 411 | 412 | {isContentVisible && ( 413 | 419 | )} 420 | 421 | )} 422 | 423 |
424 | ); 425 | }; 426 | 427 | export default Projects; 428 | -------------------------------------------------------------------------------- /src/hooks/useColorAnimation.ts: -------------------------------------------------------------------------------- 1 | import { useMotionValue, useTransform } from "framer-motion"; 2 | import { useCallback, useEffect } from "react"; 3 | 4 | export const useColorAnimation = () => { 5 | const baseHue = useMotionValue(0); 6 | 7 | const mapHue = useCallback((h: number) => { 8 | // Map 0-360 to 0-270 (excluding yellow and green) 9 | h = h % 360; 10 | if (h < 60) return h; // Red to purple 11 | if (h < 180) return 60 + (h - 60) * (60 / 120); // Purple to blue 12 | return 120 + (h - 180) * (150 / 180); // Blue to red 13 | }, []); 14 | 15 | const hue1 = useTransform(baseHue, mapHue); 16 | const hue2 = useTransform(baseHue, (h) => mapHue((h + 60) % 360)); 17 | 18 | useEffect(() => { 19 | const interval = setInterval(() => { 20 | baseHue.set(baseHue.get() - 1); 21 | }, 50); 22 | 23 | return () => clearInterval(interval); 24 | }, [baseHue]); 25 | 26 | return { hue1, hue2 }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/hooks/useIsTouchDevice.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export function useIsTouchDevice() { 4 | const [isTouchDevice, setIsTouchDevice] = useState(false); 5 | 6 | useEffect(() => { 7 | const mediaQuery = window.matchMedia("(pointer: coarse)"); 8 | setIsTouchDevice(mediaQuery.matches); 9 | 10 | const handleChange = (e: MediaQueryListEvent) => { 11 | setIsTouchDevice(e.matches); 12 | }; 13 | 14 | mediaQuery.addListener(handleChange); 15 | return () => mediaQuery.removeListener(handleChange); 16 | }, []); 17 | 18 | return isTouchDevice; 19 | } 20 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import { RouterProvider, createBrowserRouter } from "react-router-dom"; 5 | import "./assets/globals.scss"; 6 | import Redirect from "./components/Redirect.tsx"; 7 | 8 | const router = createBrowserRouter([ 9 | { 10 | path: "/", 11 | element: , 12 | }, 13 | { 14 | path: "*", 15 | element: , 16 | }, 17 | ]); 18 | 19 | ReactDOM.createRoot(document.getElementById("root")!).render( 20 | 21 | 22 | , 23 | ); 24 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./src/**/*.{html,js,ts,jsx,tsx}", "./src/App.tsx"], 4 | theme: { 5 | extend: { 6 | backgroundImage: () => ({ 7 | "landing-bg-image": "var(--landing-bg-image)", 8 | }), 9 | colors: { 10 | dark: "var(--dark)", 11 | light: "var(--light)", 12 | 13 | "gray-4": "var(--gray-4)", 14 | "gray-3": "var(--gray-3)", 15 | "gray-2": "var(--gray-2)", 16 | "gray-1": "var(--gray-1)", 17 | 18 | "svg-line": "var(--svg-line)", 19 | }, 20 | }, 21 | }, 22 | plugins: [], 23 | }; 24 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "noEmit": true 11 | }, 12 | "include": ["vite.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "redirects": [] 3 | } 4 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | --------------------------------------------------------------------------------